Return-oriented Programmingとは何かを学ぶ
こんにちは、エンジニアの日下部です。
本日は脆弱性への攻撃手法として有名なReturn-oriented Programming(以降ROP)について解説してみようかと思います。
かなり長くなるのと技術的に込み入った内容になるため注意してください。
また筆者の個人的趣味の問題でWindows上での話となります。
注意事項
本ページで掲載するソースコードおよび技術情報はセキュリティに関する技術向上を目的として提供されています。
他人の財産を害すような何らかの違法行為に使用した場合法的に罰せられる恐れがあります。
くれぐれもそのような目的での使用は避けてください。
対象読者層
- C++の基本的知識を持つ
- アセンブリ言語の基本的知識を持つ
- 脆弱性やBufferOverflowやNX Bitについて理解している
- ROPがクラッキングに使われていることは把握しているが具体的には知らない
Return-oriented Programming概略
BufferOverflow脆弱性などと共に語られることの多いROPですが、NX Bitを回避出来るなどと語られる程度でその詳細について理解している人はあまり多くないのではないでしょうか?
ROPとは疑似的なプログラミング技法です。
ROPでおおよその処理は実装可能です。
既存のコードを切り貼りして動作するという原理上通常のプログラミング言語は持たない様々な特性(例えばNX Bitの回避など)を持っており、それらがクラッカーにとって都合がいいため利用されている形です。
動作原理としてはメモリ上に既にマップされている既存の機械語の中から何か+retの命令片を探してスタックに積んでいくと、何かの部分だけを連続で実行したがごとき結果が得られるという物です。
例えば
; ~色々なコード~
pop eax;
ret;
; ~色々なコード~
call eax;
ret;
; ~色々なコード~
があった時にスタックに
- pop eax部のアドレス
- ExitProcessのアドレス
- call eax部のアドレス
- 0
と積んだうえでretを実行するとExitProcess(0)を実行できます。
Return-oriented Programmingでプログラミングが出来る?
ROPはプログラミング技法だと書きました。
しかし実際どの程度のことができるかは直感的にはわからないかと思います。
そこで今回は実際にROPでプログラミングを行おうかと思います。
教材用として実用視点では色々と手抜きではありますがお付き合いください。
またプログラム全文は https://github.com/lepidum/rop で公開されています。
※メモリレイアウトなどに強く依存しているため動作しない可能性があります。
Return-oriented ProgrammingでFizzBuzz
題材は初歩的なプログラミング題材としてよく使われるFizzBuzzを採用しましょう。
一応説明しておくと数をひたすら数えていき、3の倍数ではFizz、5の倍数ではBuzz、15の倍数ではFizzBuzz、それ以外は現在の数を出力するというものです。
出力先は標準出力。 ただし簡便化のためROPで使いやすい関数をC++レイヤーで定義してやります。
void __stdcall putsNumber(const unsigned int * const value) {
std::printf("%d\n", *value);
}
void __stdcall putsString(const unsigned char * const value) {
std::printf("%s\n", value);
}
__stdcallを採用、format文字列決め打ち、固定長引数、参照外しも内側でやっています。
処理上限は特に設けず無限にカウントアップしてctrl+cなどで終了とします。
さらにROPの簡便化のため、変数配置用のメモリは別途確保したうえでアドレスが既知という前提で実装を行います。 具体的には以下のメモリを使用しています。
struct Data {
unsigned int counter;
char fizz[8];
char buzz[8];
char fizzbuzz[12];
};
同様にスタック上のアドレスも既知な前提を置きます。
アルゴリズム
FizzBuzzとは言いましたが書き方は色々あります。
今回は以下のコードのROP実装を目指しましょう。
unsigned int i = 1;
while (true) {
putsNumber(&i); i++;
putsNumber(&i); i++;
putsString("Fizz"); i++;
putsNumber(&i); i++;
putsString("Buzz"); i++;
putsString("Fizz"); i++;
putsNumber(&i); i++;
putsNumber(&i); i++;
putsString("Fizz"); i++;
putsString("Buzz"); i++;
putsNumber(&i); i++;
putsString("Fizz"); i++;
putsNumber(&i); i++;
putsNumber(&i); i++;
putsString("FizzBuzz"); i++;
}
簡便化のためifを取り除いてあります。
gadgetの探索
ROPにおいて使用する何か+retのコード片はgadgetと呼称されます。
ROPを組むためにはまず何を置いてもgadgetを探索しなければなりません。
今回は以下のコードによって探索するものとします。
std::pair<unsigned int, unsigned int> getTextArea(HMODULE module) {
const unsigned int baseAddr = reinterpret_cast<unsigned int>(module);
const IMAGE_DOS_HEADER &mz = *reinterpret_cast<const IMAGE_DOS_HEADER *>(baseAddr);
const IMAGE_NT_HEADERS32 &pe = *reinterpret_cast<const IMAGE_NT_HEADERS32 *>(baseAddr + mz.e_lfanew);
const IMAGE_SECTION_HEADER * const sectionHeaders = reinterpret_cast<const IMAGE_SECTION_HEADER *>(pe.FileHeader.SizeOfOptionalHeader + reinterpret_cast<unsigned int>(&pe.OptionalHeader));
const std::string target = ".text";
for (unsigned int i = 0; i < pe.FileHeader.NumberOfSections; i++) {
const IMAGE_SECTION_HEADER §ion = sectionHeaders[i];
if (target == reinterpret_cast<const char *>(section.Name)) {
return std::make_pair(
baseAddr + section.VirtualAddress,
baseAddr + section.VirtualAddress + section.SizeOfRawData);
}
}
return {};
}
unsigned int searchGadget(const unsigned int size, std::function<bool(const unsigned char *)> func) {
const std::pair<unsigned int, unsigned int> area = getTextArea(::GetModuleHandle("ntdll.dll"));
for (unsigned int addr = area.first; addr < area.second - (size - 1); addr++) {
if (func(reinterpret_cast<const unsigned char *>(addr))) {
return addr;
}
}
return 0;
}
/* ~略~ */
const unsigned int ret = searchGadget(1, [](auto mem) {return mem[0] == 0xC3; });
const unsigned int movEaxEcx = searchGadget(3, [](auto mem) {return mem[0] == 0x8B && mem[1] == 0xC1 && mem[2] == 0xC3; });
const unsigned int popEcx = searchGadget(2, [](auto mem) {return mem[0] == 0x59 && mem[1] == 0xC3; });
const unsigned int movDwordPtrDsEaxEcx = searchGadget(3, [](auto mem) {return mem[0] == 0x89 && mem[1] == 0x08 && mem[2] == 0xC3; });
const unsigned int movEaxDwordPtrDsEax = searchGadget(3, [](auto mem) {return mem[0] == 0x8B && mem[1] == 0x00 && mem[2] == 0xC3; });
const unsigned int incEax = searchGadget(2, [](auto mem) {return mem[0] == 0x40 && mem[1] == 0xC3; });
const unsigned int xchgEaxEcx = searchGadget(2, [](auto mem) {return mem[0] == 0x91 && mem[1] == 0xC3; });
const unsigned int xchgEaxEsp = searchGadget(2, [](auto mem) {return mem[0] == 0x94 && mem[1] == 0xC3; });
Windowsであればどのプロセスでも必ずリンクしているntdll.dllを対象に、必要なgadgetをコード領域から全探索しているだけです。
コード領域なのはNX Bitで実行属性が付与されている必要があるためです。
以降では以下のgadgetを使用します。
- ret単独
- mov eax, ecx
- pop ecx
- mov DWORD PTR DS:[eax], ecx
- mov eax, DWORD PTR DS:[eax]
- inc eax
- xchg eax, ecx
- xchg eax, esp
固定値のロード
まずは何を置いても固定値をレジスタにロード出来なければお話になりません。
幸いにもスタックが使用できるので
- pop ecx
- ecxレジスタにロードしたい固定値
と積めばecxにロードできます。
また他のレジスタ、例えばeaxにロードする際は
- pop ecx
- ecxレジスタにロードしたい固定値
- mov eax, ecx
などと別のgadgetを組み合わせることで行えます。
メモリの書き込み
書き込みも単純にgadgetの組み合わせて実現できます。
- pop ecx
- 書き込みたいアドレス
- mov eax, ecx
- pop ecx
- 書き込みたい内容
- mov DWORD PTR DS:[eax], ecx
で4byte書き込むことができます。
これを用いて実際のコードでは
rop.push_back(popEcx);
rop.push_back(fizz);
rop.push_back(movEaxEcx);
rop.push_back(popEcx);
rop.push_back(0x7A7A6946);
rop.push_back(movDwordPtrDsEaxEcx);
rop.push_back(popEcx);
rop.push_back(fizz + 4);
rop.push_back(movEaxEcx);
rop.push_back(popEcx);
rop.push_back(0);
rop.push_back(movDwordPtrDsEaxEcx);
と書くことで
data.fizz = "Fizz"
相当の処理を実現しています。
同様にdata.counterやdata.buzzやdata.fizzbuzzの初期化も行えます。
スタックポインタの変更
ROPはスタック上にプログラミングを行うため、スタックポインタを前後させることで分岐やループが実装出来ます。
そこでスタックポインタを操作する必要があるのですが、スタックポインタは単純にespレジスタであるため、espを変更するgadgetさえあれば問題なく変更できます。
- pop ecx
- 変更後スタックポインタ
- mov eax, ecx
- xchg eax, esp
これで任意のスタックに飛ぶことができます。
関数の呼び出し
ただの関数呼び出しであれば以下で行えます。
- 呼びたい関数のアドレス
- ret
- 渡したい引数1
- 渡したい引数2
- …
ただしこれだと既に通過済みのスタックが関数内のローカル変数などで上書きされ破壊されます。
今回は無限ループで過去のスタックも実行したいのでこれは困ります。
そこである程度空のスタックを積んでおおよその破壊を回避したうえで、それでも破壊される個所を毎回復元するよう実装します。
これはメモリの書き込みとスタックポインタの変更で説明した手順を組み合わせれば可能です。
- pop ecx
- スタック上の関数アドレス部のアドレス
- mov eax, ecx
- pop ecx
- 関数アドレス
- mov DWORD PTR DS:[eax], ecx
- ここまでで関数呼び出し部分が復元される。
- pop ecx
- スタック上の関数アドレス部のアドレス
- mov eax, ecx
- xchg eax, esp
- ~十分な数の空データ~
- ここは実行されない
- 呼びたい関数アドレス
- ここは毎回復元されるので初期値は0でもよい
- ret
- 渡したい引数1
- 渡したい引数2
- …
これでスタックを破壊されることなく関数を呼びだすことが出来ました。
ループ
関数呼び出しでスタック破壊を回避出来たので、ループは単純にスタックポインタの変更で説明した方法でespを書き換えるだけで実現できます。
ループ末尾で
- pop ecx
- ループ最初のスタックアドレス
- mov eax, ecx
- xchg eax, esp
とするだけです。
実際にFizzBuzzを書く
必要なパーツは揃ったので後は実際に実装するだけです。
そうしてできた完成品のコードは記事最下部の物を参照してください。
※build関数がROP構築部分です。
どうでしょう? かなり窮屈ですが意外と普通にプログラミング出来ることが伝わったかなと思います。
今回は簡便化の都合で触れませんでしたが、条件分岐やもっと複雑な関数呼び出しも可能です。
ただ単に攻撃手法としてしか認識していなかった人にとってはそれなりに驚きのある体験だったのではないかと思います。
FizzBuzzをBufferOverflow脆弱性から実行してみる
とはいえここで終わりではありません。
実際にBufferOverflowへの攻撃も試みてみましょう。
といっても簡易的に、ROP処理のスタート部分をBufferOverflowに書き換えるだけです。
※/RTCおよび/GS無効
// ROP実行のBufferOverflow利用版
void run2Main() {
static char buffer[256];
char name[32];
static std::vector<unsigned int> rop = build(reinterpret_cast<unsigned int>(buffer), reinterpret_cast<unsigned int>(&name), 12);
::memcpy(name, &rop[0], rop.size() * 4); // BufferOverflow!
std::printf("name: %s\n", name);
return;
}
// ROPするための領域確保用関数
void run2() {
char dummy[65536 * 4];
::memset(dummy, 0, sizeof(dummy));
run2Main();
::printf("%s", dummy);
return;
}
memcpyでBufferoverflowが発生しています。
そこでreturnアドレスが上書きされており、return時にROP内に処理が移っています。
nameからreturnアドレスまでの距離がスタック上は12データの48byteあるため、その分はpaddingを挟んでいます。
このようにROPで書けてしまえばBufferOverflowから直に実行させることが可能なため、BufferOverflowなどの文脈でよく耳にするわけです。
ただ、実際に上記のFizzBuzzを任意のBufferOverflow脆弱性アプリに挿入するには、スタックのアドレスの特定、書き換え可能メモリのアドレス特定、/GSの回避、ntdll.dllの厳密なバージョン特定、などが必要です。
本記事では解説しませんが、OSの防御技術など様々な対策がなされているということだけ覚えておいてください。
総評
- ROPはコード片をつなげてスタック上でプログラミングする技術
- FizzBuzzぐらいは余裕で書ける
- BufferOverflowにつなげて悪用しやすい
実はROPはクラッキング文脈以外でも使用されており、例えばSpectre対策技術のRetpolineはROP技術により実装されているとかなんとか。
ROPをする優位性が実際に生まれることは稀ではありますが、ただの攻撃技術としてしか理解しないのはある種の損失を生む可能性はあるかもしれません。
終わりに
最後に https://github.com/lepidum/rop のソースコード全文を眺めてみると得るものもあるかもしれません。
被害に備えるにしろ守るにしろこの記事が参考になれば幸いです。