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は機能していなかったことをここに記録しておく。

/DYNAMICBASE一問一答

/DYNAMICBASEオプションによるベースアドレスのランダマイズ(以下ASLR)について、わかっているようでわかっていない人も多いかと思ったので細かい挙動についてまとめておきます。

なお以下はすべてWindows10 1809におけるもので、それ以降やそれ以前では異なることがあります。

Q:ASLRってなに?
A:この場で言うASLRは.exeや.dllがメモリ上にマップされるときのアドレスをランダムにしようぜって機能のこと

Q:どういう恩恵があるの?
A:攻撃されたときにアドレスがばれているかどうかは、攻撃者がLv1魔王とLv100魔王ぐらいには変わる、当然隠していた方がよい

Q:.exeはどれぐらいランダムなの?
A:実測したら254通り

Q:.exeのランダムの範囲は?
A:NULLページ(0x00000000のこと)や旧来のデフォルトベースアドレス(0x00400000-0x007F0000)を除いて0x00010000ごとに254か所の中からランダム
/BASEオプションが無指定なら0x00010000から、指定されていた場合そのアドレスから0x00FE0000引いたアドレスからになる

Q:.exeの再ランダマイズされる条件は?
A:OS再起動、またはtouchやコピーで属性が更新されたとき(アンロードや時間経過やリネームなどは含まれず)

Q:.dllはどれぐらいランダムなの?
A:仕様によれば256通りらしいが再起動必須であるため労力の関係から実測は叶わなかった

Q:.dllのランダムの範囲は?
A:以下推測も多く混ざる点に注意
OS起動時に0x7F000000-0x7FFF0000の間を0x00010000ごとに256か所の中からランダムで選ぶ
そこから後ろ詰めで1つでもロードされているDLLのアドレスを埋めていき、全プロセスでアンロードされたDLL部分を開放するを延々繰り返していると思われる

Q:.dllの再ランダマイズされる条件は?
A:OS再起動のみ
アドレス変化自体は全プロセスからアンロードしてから元アドレス付近が埋まるまで他.dllをロードし保持したまま改めてロードしなおせばよい
またはtouchやコピーで属性を更新した別dllを用意して別々に同時ロードするなどでもよい

Q:結局どういうこと?
A:.exeは254通りと少ないがランダマイズされているので比較的安心していい
.dllは.dll全体で1回のランダマイズなのでアドレスが1個でもばれたら全部ばれ得るので注意が必要


VC++2013でxorが違う値を参照するバグ

VisualStudio2013のVC++にてReleaseビルドだとxor命令で参照する値が別の値になり、結果期待した動作をしないバグに遭遇した。
原因は不明だが、最小再現コードを作ったので公開しておく。

vs2013_xor_bug.cpp

#include  <cstdio>
#include <cstring>

#include <memory>

const unsigned int KEY_SIZE = 8;
const unsigned int HEADER_SIZE = 16;

void decrypt(const unsigned int size, const unsigned char * const key, unsigned char * const buf) {
  for (unsigned int i = 0, table_index = 0; i < size; i++) {
    buf[i] ^= key[table_index];
    table_index = (table_index + 1) % KEY_SIZE;
  }
}

bool readHeaderAndKey(const unsigned char * const in, const unsigned long long int file_size, const std::shared_ptr<unsigned char> &header, unsigned char * const key) {
  std::memcpy(header.get(), in, HEADER_SIZE);
  const unsigned int file_name_table_address = *reinterpret_cast<const unsigned int *>(&in[12]) ^ 0xF8A42792;
  if (file_name_table_address > file_size) {
    return false;
  }
  const unsigned int footer_size = static_cast<unsigned int>(file_size)-file_name_table_address;
  key[4] = header.get()[4] ^ reinterpret_cast<const unsigned char *>(&footer_size)[0];
  key[5] = header.get()[5] ^ reinterpret_cast<const unsigned char *>(&footer_size)[1];
  key[6] = header.get()[6] ^ reinterpret_cast<const unsigned char *>(&footer_size)[2];
  key[7] = header.get()[7] ^ reinterpret_cast<const unsigned char *>(&footer_size)[3];

  decrypt(HEADER_SIZE, key, header.get());
  return *reinterpret_cast<const unsigned int *>(&header.get()[12]) < file_size;
}

int main(unsigned int argc, const char * const argv[]) {
  const unsigned char data[HEADER_SIZE] = {0x00, 0x00, 0x00, 0x00, 0x80, 0xDF, 0x90, 0x52, 0x00, 0x00, 0x00, 0x00, 0x9E, 0x4F, 0xA4, 0xF8};
  const std::shared_ptr<unsigned char> header(new unsigned char[HEADER_SIZE]);
  unsigned char key[KEY_SIZE];
  readHeaderAndKey(data, 27420, header, key);
  for (const unsigned char c : key) {
    std::printf("%02x ", c);
  }
  readHeaderAndKey(data, sizeof(data), header, key);
  decrypt(0, NULL, NULL);
  return 0;
}

上記のコードを以下のようにコンパイルすると発生。

cl vs2013_xor_bug.cpp /O2 /Oy-

期待した動作としては
00 00 00 00 90 dc 90 52
と出力されてほしいのだが
00 00 00 00 90 cf 90 52
と出力される。
(6番目の出力がずれている)

試しにprintfしているforをstd::printf(“%02x\n”, key[5]);とでも書き換えてみると、コンパイルオプションに関わらず常にdcと表示されるようになるので、明らかにバグ。

23行目から先の逆アセンブラ画面。

選択行の部分がkey[5]へ代入する値を作っている部分、本来XOR AL,BHであるべきで実際そう書き換えると治る。

mainのprintfの後のreadHeaderAndKeyやdecryptは意味がないように見えるが、ないと発生しなくなる。
おそらく一か所でしか実行されない関数は専用の最適化が走り、それによりバグが発生する最適化の条件から外れるのだと思われる。

headerがstd::shared_ptrなのは、元はちゃんとしたクラスだったのだがshared_ptrでくるんであれば何でもいいようだったのでunsigned charにしてある。

他にもreadHeaderAndKeyの無意味そうなifや返り値も外すと発生しなくなるし、key[5]以外のxorを外したりintにまとめて一回で済ませようとしても発生しなくなる。

Visual Studio 14 CTPでも同様に発生するが、Visual Studio 2012では発生しないことを確認している。

mrubyのバイトコードフォーマット解説

この投稿はmruby Advent Calendar 2013の3日目の記事です。
2日目はsuzukazeさんのmruby-redisでランキングを実装、4日目はdycoonさんのGUIアプリケーションなどが保持するmrubyのオブジェクトのGC対策です。

はじめに

mrubyのエンジンはソースコードを逐次解釈して実行しているわけではなく、専用のバイトコードに変換して保持し、それをVirtualMachineの上で解釈して実行しています。
であれば当然コンパイル済みバイナリファイルというのも存在するわけで、ならば当然どんな仕組みになっているか気になるわけです。

というわけで、大まかなファイルフォーマットについて調べたので今回説明しようと思います。

2013/11/25当時(git hash: f5bd87b9e6d0d8a84cf866b4847c1416e4f5c622 )の物です。
それ以降のmrubyを使用する場合は以下の解説の通りではない可能性があります。

全体の構造

ヘッダーとセッションの配列からなります。
ヘッダーは必ず一つ、セッションは複数個ですが必ず終端セクションで終わります。

ヘッダー

C風に書くとこんな感じ

ubig8_t signature[4];
ubig8_t version[4];
ubig16_t crc;
ubig32_t size;
ubig8_t compiler_name[4];
ubig8_t compiler_version[4];

※ubig%d_t: 符号なし %d bitビッグエンディアンの整数です、ただしubig8_t配列は文字列の場合もあります

signature: 0x52 0x49 0x54 0x45固定(ASCIIでRITE)
version: 0x30 0x30 0x30 0x32固定(ASCIIで0002)
crc:

uint16_t CalcCrc(const uint8_t * const ptr, const unsigned int size) {
  unsigned int a = 0;
  for (unsigned int i = 0; i < size; i++) {
    a |= ptr[i];
    for (unsigned int l = 0; l < 8; l++) {
      a = a << 1;
      if (a & 0x01000000) {
        a ^= 0x01102100;
      }
    }
  }
  return a >> 8;
}

※ptrはファイル全体のうちヘッダーのはじめからcrcまでを除いた部分、sizeはそのバイト数

こんな感じらしいのだが実際動かしてみるとずれるのでたぶんエンバグしている、が、めんどいので調べていない。
mrubyの/src/crc.cに実装があるので、知りたい人はそちらを見るとよい。

size: ファイル全体の長さ、ヘッダーも含む
compiler_name: コンパイルに使われたコンパイラーの識別子、特に使用されていない
compiler_version: コンパイルに使われたコンパイラーのバージョン、特に使用されていない

セクション

セクションは全部で3種類存在します。

  • 実行コード本体
  • 元となったソースコードの行番号情報
  • 終端セクション

詳しい説明は後で書くとして、共通のセクションヘッダーは以下の通りです。

ubig8_t signature[4];
ubig32_t size;

signature: セクションの識別子です、ここでセクションが何の情報を持つかが分岐します、0x49 0x52 0x45 0x50(ASCIIでIREP)か0x4C 0x49 0x4E 0x45(ASCIIでLINE)か0x45 0x4E 0x44 0x00(ASCIIでEND\0)のどれかになります。
size: セクションのサイズです、共通のセクションヘッダーのサイズも含みます。

linenoセクション

元となったソースコードのライン番号情報です、signatureは0x4C 0x49 0x4E 0x45(ASCIIでLINE)になります。
linenoの追加ヘッダー以下の通りです。

ubig16_t count;
ubig16_t start;

count: ライン情報の個数です。
start: よくわかりません。

ここにはlinenoの詳細を書くべきなのでしょうが、ぶっちゃけ興味なかったので調べておらずわからないのですっ飛ばします。

endセクション

終端セクションです、signatureは0x45 0x4E 0x44 0x00(ASCIIでEND\0)になります。
追加ヘッダーは特にありません。

このセクションが存在した時点でセクションの読み取りは終了となります。
一応ファイルサイズなどの情報もあるのでこの情報はなくても実行できますが、おそらくは利便性か速度上の都合なのでしょう。

irepセクション

実行コード本体です、signatureは0x49 0x52 0x45 0x50(ASCIIでIREP)になります。
irepの追加ヘッダー以下の通りです。

ubig8_t version[4];

version: irepの命令セットのバージョンです、現在は0x30 0x30 0x30 0x30(ASCIIで0000)固定です。

また、irepデータ本体を1つ以上持ちます。
いくつになるかは実際にirepデータを読んでみるまでわかりません。

irepデータ

関数1つやクラスの定義など1スコープの処理をまとめたものです。
定義は以下の通り

ubig32_t size;
ubig16_t localCount;
ubig16_t registerCount;
ubig16_t childCount;
ubig32_t code_count;
Code code[code_count];
ubig32_t pool_count;
Pool pool[pool_count];
ubig32_t symbol_count;
Symbol symbol[symbol_count];

size: irepデータのサイズです、sizeそのものも含みます。
localCount: ローカル変数の個数らしいですが何に使うのかわかりませんでした。
registerCount: このirepデータを実行するのに必要なレジスタの数です。
childCount: このirepに紐づく子のirepデータの数です。
code_count: irepバイトコードの個数です。
code: irepバイトコードです。
pool_count: リテラルプールの数です。
pool: リテラルプールです。
symbol_count: シンボルリストの数です。
symbol: シンボルリストです。

さらにchildCountの分だけ、irepデータが後に続きます。
それらのirepデータにも当然childCountがついているので、childCountが1の場合でも何十個とirepデータが続いている可能性があります。

リテラルプール

コード中で使用する文字列/整数/実数のリテラルを保持しています。
各リテラルはフォーマット上は文字列として保管され、パース時点でmrb_valueへと変換されます。
定義は以下の通り

ubig8_t type;
ubig16_t len;
ubig8_t body[len];

type: mrb_vtypeの値がそのまま入っており、0:文字列、1:整数、2実数となります。
len: bodyの長さです。
body: 文字列で表現されたリテラルで、終端記号(\0)は含まれていません。

シンボルリスト

コード中で使用するシンボル(メソッド名やクラス名など)を保持しています。
各シンボルはフォーマット上は文字列として保管され、パース時点でmrb_symへと変換されます。
定義は以下の通り

ubig16_t len;
ubig8_t body[len];
ubig8_t terminate;

len: bodyの長さです。
body: 文字列で表現されたシンボルです、リテラルと違いこちらは続くterminateが必ず0x00固定なのでC言語文字列として扱うことができます。
terminate: 必ず0x00固定です、bodyをC言語文字列として扱うためで、実際load.cを覗いてみるとchar *にキャストして使用されています。

irepバイトコード

VM上で実行できるバイトコードです。
すべての命令がオペコードと引数を合わせて4byteとなっています。
書くまでもないですが、定義は以下の通り

ubig32_t body;

body & 0x0000007F: オペコードです、2013/11/25時点では0~75まで定義されています。
body & 0xFFFFFF80: 引数ですが、オペコードにより引数の数とサイズが異なります。

各オペコードの説明まで始めるとそれだけで本一冊書けてしまうのと、ファイルフォーマットとは関係が薄いためここでは解説しません。

終わりに

いかがだったでしょうか。
実はオペコード関連も多少は読んでいて、ローカル変数はインデックス管理で名前が消滅しているとか面白い話もあったのですが、文中でも書いたように一冊本が書ける勢いなので省略しました。
mrubyを使う上ではあまり役に立たないかもしれませんが、個人的には面白かったです。

おまけとして、上記通りにパースして、ちょっとだけ命令の翻訳も行うようなアプリを書いてみたのでおいておきます。

parse_mruby2.zip

ではまた。

※2014/05/14付けでLVARセクションが追加されました、それらについての追加情報は以下を参照のこと。
rubyのバイトコードフォーマット解説その2

エクスプロイトを書きつつ学ぶWindowsセキュリティー機能 ~番外編SEHOP~

自分の環境だけ守れるセキュリティ機能もいいが、理想を言うならすべての環境で有効な防御こそを目指したい。

というわけで今回は前回から予定を変更して番外編、お題目は再びSEH overwrite攻撃について。
あ、今回からタイトルもつきました、番外編ですが。

第一回の記事ではSEH overwrite攻撃への対策は完璧だなどとのたまったわけですが、SoftwareDEPはWindowsXPなど一部環境ではコンパイルオプションで有効にできず、安全性が保たれるのはあくまでユーザーがDEPを有効にした環境下だけでした。
そこで今回は新たにセキュリティ機能を導入し、SEH overwrite攻撃をSoftwareDEP抜きで防げるところまで持って行くのを目標とします。
なぜ番外編なのかというと、今回は厳密にはWindowsのセキュリティー機能ではないからです、詳細は後述。

いつものようにサンプルコード集をDLしてください。
sehop.zip

おさらいと補足事項

SEH overwrite攻撃とは、スレッドスタック(以降スタック)上の例外ハンドラへのポインターを上書きし、任意のコードを実行させる攻撃。
全てのバイナリでSafeSEHが有効なら、登録済みの正規例外ハンドラかヒープ上しか例外ハンドラに指定できなくなる。
SoftwareDEPが有効ならヒープ上も例外ハンドラに指定できなくなる。

ここまでが第一回の記事で書いたことです。

ちなみにSEH overwriteは前回のBuffer Security Checkで検出することはできません。
チェックがreturn直前であるため、それ以前に例外を発生させて例外ハンドラへと処理が移ってしまえばチェックが実行されることはないからです。
逆にいうと、Buffer Security Checkがあるからこそ、それを回避して攻撃するためにSEH overwriteへと目が向いたともいえます。
(そういう意味では、記事を書く順番が逆ですね)

exploit1:SEHOP

さて、冒頭で「ユーザー設定によらない防御を行う」と書きましたが、今回最初に紹介するSEHOPはユーザーの設定を必要とする機能の一つです。
趣旨ずれてるじゃねえかと思われる人もいるかとは思いますが、ひとまず付き合ってください。
では行ってみましょう。

SEHOPとは

SEH overwrite攻撃には実は弱点があります、それは既存のSEHの情報を読むことができない、ということです。
それを利用したのがSEHOPで、SEHのリスト構造の終端に特定の例外ハンドラを設定しておき、例外発生時に例外ハンドラをたどっていってその例外ハンドラが見つからなかった場合、例外ハンドラを上書きされたと判断して強制終了する、というものです。
これを利用すれば、SafeSEHがかかっていないバイナリが紛れていようが、ヒープ領域に例外ハンドラを設定されようが関係ありません。

実際に試してみよう

今回は第一回の記事のexploit2と同じコードを使います。
・dllmain.h

#ifdef DLL_EXPORT_FLAG
#define EXPORT __declspec(dllexport)
#else
#define EXPORT __declspec(dllimport)
#endif /* TEST_EXPORTS */

EXPORT unsigned int xor(const unsigned int a, const unsigned int b);

・dllmain.cpp

#include <cstdio>

#include <Windows.h>

#define DLL_EXPORT_FLAG

#include "dllmain.h"

void call() {
  ::MessageBoxA(NULL, "message", "title", MB_OK);
  ::exit(0);
}

unsigned int xor(const unsigned int a, const unsigned int b) {
  return a ^ b;
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
  return TRUE;
}

・test.cpp

#include <cstdio>

#include "dllmain.h"

int main(const unsigned int argc, const char * const * argv) {
  FILE * const fp = ::fopen("file", "rb");
  unsigned int size;
  char s[256];
  ::fread(&size, 4, 1, fp);
  ::fread(s, 1, size, fp);
  const unsigned int key = 0xAA;
  for (unsigned int i = 0; i < size; i++) {
    ::printf("%02x ", xor(s[i], key));
  }
  ::fclose(fp);
  ::getchar();
  return 0;
}

バイナリでのみ提供されているライブラリを使用しているためsafesehがかけられないという前提です。

サンプルのexploit1を開き、その中のtest.exeを実行してみましょう。
以前と同じく、callの内容が実行されたかと思います。

では、SEHOPを有効にしてもう一度test.exeを実行してみましょう。
(SEHOPを有効にする方法はEMETがおすすめ)
すると今度はメッセージボックスが上がらず強制終了したかと思います。

exploit1:SEHOPまとめ

softwareDEPとSafeSEHがOFFでもSEHOPが有効ならSEH overwrite攻撃は防げる。

しかし弱点もあって、SEHOPはvista以降のOSでなければ使用できない上、コンパイルオプションでは極一部を除き有効にできず、サーバー向けを除きデフォルトOFFと
エンドユーザーに提供するのがなかなかに難しい状況になっています。

では、どうするか。
コンパイルオプションで有効にできないなら自作してしまえばいいのです。

SEHOPの自作

というわけで長い前置きも終わって番外編本編、今回はセキュリティー機能を自作する回になります。
いきなりですが、まずは実際に動作させてみましょう。

実際に試してみよう

今回はexploit1のコードでsehopを実装したdllをLoadLibraryするだけのコードを使います。
差分だけ貼ると

 #include <cstdio>
 
+#include <Windows.h>
+
 #include "dllmain.h"
 
 int main(const unsigned int argc, const char * const * argv) {
+  ::LoadLibraryW(L"sehop.dll");
   FILE * const fp = ::fopen("file", "rb");
   unsigned int size;
   char s[256];

となります。

ではさっそくSEHOPを無効にした環境でexploit1_sehopを開いてtest.exeを実行してみましょう。
ちゃんと強制終了したかと思います。
試しにsehop.dllをリネームしてみて、今度はメッセージボックスが出るのを確認してみるのもいいでしょう。

次からは、このsehop.dllが何をしているのかを説明します。

sehop.dllの仕組み

やっていることはwindowsのセキュリティー機能としてのSEHOPと同じで
識別用のSEHレコードを挿入し、例外時にフックして例外ハンドラを実行する前にSEHレコードの存在をチェック、見つからなければ強制終了するというものです。

例外のフック方法

まずは例外時のフック方法から解説します。
コードは以下のとおり。

FARPROC KiUserExceptionDispatcher;
FARPROC KiUserExceptionDispatcher2;

__declspec(naked) void d_KiUserExceptionDispatcher() {
  __asm {
    pushad
  }
  exception();
  __asm {
    popad
    CLD
    MOV ECX,DWORD PTR SS:[ESP+4]
    jmp KiUserExceptionDispatcher2
  }
}

__declspec(naked) void d_KiUserExceptionDispatcherXP() {
  __asm {
    pushad
  }
  exception();
  __asm {
    popad
    MOV ECX,DWORD PTR SS:[ESP+4]
    MOV EBX,DWORD PTR SS:[ESP]
    jmp KiUserExceptionDispatcher2
  }
}

bool HookException() {
  KiUserExceptionDispatcher = ::GetProcAddress(::GetModuleHandleW(L"ntdll.dll"), "KiUserExceptionDispatcher");
  if (KiUserExceptionDispatcher == NULL) {
    return false;
  }
  KiUserExceptionDispatcher2 = reinterpret_cast<FARPROC>(reinterpret_cast<unsigned int>(KiUserExceptionDispatcher) + 5);
  const unsigned char oldCode[] = {0xFC, 0x8B, 0x4C, 0x24, 0x04};
  unsigned char newCode[_countof(oldCode)] = {0xE9};
  *reinterpret_cast<unsigned int *>(&newCode[1]) = reinterpret_cast<unsigned int>(d_KiUserExceptionDispatcher) - reinterpret_cast<unsigned int>(KiUserExceptionDispatcher) - 5;
  if (!org::click3::DllHackLib::ChangeCode(reinterpret_cast<unsigned int>(KiUserExceptionDispatcher), oldCode, newCode, sizeof(unsigned char) * _countof(oldCode))) {
    const unsigned char oldCode[] = {0x8B, 0x4C, 0x24, 0x04, 0x8B, 0x1C, 0x24};
    unsigned char newCode[_countof(oldCode)] = {0xE9, 0x00, 0x00, 0x00, 0x00, 0x90, 0x90};
    *reinterpret_cast<unsigned int *>(&newCode[1]) = reinterpret_cast<unsigned int>(d_KiUserExceptionDispatcherXP) - reinterpret_cast<unsigned int>(KiUserExceptionDispatcher) - 5;
    if (!org::click3::DllHackLib::ChangeCode(reinterpret_cast<unsigned int>(KiUserExceptionDispatcher), oldCode, newCode, sizeof(unsigned char) * _countof(oldCode))) {
      return false;
    }
  }
  return true;
}

簡単に説明すると、windowsでは例外が発生した場合はntdll.dllのKiUserExceptionDispatcherに処理が移り、その中で必要ならSEHOPのチェックなど行った後にSEH連鎖をたどって例外ハンドラを実行してます。
ので、KiUserExceptionDispatcherを最初の部分を書き換え、自作関数に向け、自作関数からはインラインアセンブラを駆使して本来のKiUserExceptionDispatcherに処理を戻す、という手順になります。

KiUserExceptionDispatcherが2つあるのはXPとそれ以降でKiUserExceptionDispatcherの処理が若干異なるため。
一応xp sp3/vista sp1/windows8/windows8.1で試して動作しましたが、windows server系では動かないなどあるかもしれません、その場合はご了承ください。

とにかくこれで例外はフックできるようになりました、次はSEHレコードの追加です。

SEHレコードの追加

まずコードは以下のとおり

struct SehRecord {
  SehRecord *next;
  const void *ptr;
};

SehRecord *GetFirstSehRecord() {
  SehRecord *record;
  __asm {
    mov eax, FS:0
    mov record, eax
  }
  return record;
}

const SehRecord *AddLastSeh(const void * const ptr) {
  SehRecord *record = GetFirstSehRecord();
  SehRecord *prev = record;
  while (true) {
    if (reinterpret_cast<unsigned int>(record->next) == 0xFFFFFFFF) {
      break;
    }
    prev = record;
    record = record->next;
  }
  SehRecord * const current = record - 1;
  SehRecord * const prevRecord = current - 1;
  prev->next = prevRecord;
  prevRecord->ptr = record->ptr;
  prevRecord->next = current;
  current->ptr = ptr;
  current->next = record + 1;
  (record + 1)->ptr = record->ptr;
  (record + 1)->next = reinterpret_cast<SehRecord *>(0xFFFFFFFF);
  return current;
}

DWORD recordIndex;
DWORD ptrIndex;

void SetSehPtr(const void * const ptr) {
  TlsSetValue(ptrIndex, reinterpret_cast<void *>(reinterpret_cast<unsigned int>(ptr) ^ 0xAAAAAAAA));
}

const void *GetSehPtr() {
  return reinterpret_cast<const void *>(reinterpret_cast<unsigned int>(TlsGetValue(ptrIndex)) ^ 0xAAAAAAAA);
}

void SetSehRecord(const SehRecord * const record) {
  TlsSetValue(recordIndex, reinterpret_cast<void *>(reinterpret_cast<unsigned int>(record) ^ 0xAAAAAAAA));
}

const SehRecord *GetSehRecord() {
  return reinterpret_cast<const SehRecord *>(reinterpret_cast<unsigned int>(TlsGetValue(recordIndex)) ^ 0xAAAAAAAA);
}

void SetSehOp() {
  boost::random::mt19937 gen(timeGetTime());
  boost::random::uniform_int_distribution<unsigned int> dist(0, 0xFFFFFFFF);
  boost::random::variate_generator<boost::random::mt19937&, boost::random::uniform_int_distribution<unsigned int> > rand(gen, dist);
  SetSehPtr(reinterpret_cast<const void *>(rand()));
  SetSehRecord(AddLastSeh(GetSehPtr()));
}

大雑把な流れとしては、乱数を生成して例外ハンドラが[本来の最後の例外ハンドラ]->[乱数]->[本来の最後の例外ハンドラ]となるように2つSEHレコードを追加します。
本来の最後の例外ハンドラはすべての例外を処理してくれるため、それより後に処理が渡ることはありません、なので乱数でいいわけです。
最後のもう一度登録しなおしているのは、windowsそのもののSEHOPと競合した場合に不正環境だと誤認されないためです。

同様にwindowsのSEHOPはSEHレコードがすべてスタック上に配置されていないと強制終了するので、SEHレコードはスタック上の使ってなさそうな領域を勝手に書き換えて使っています。
本来いけないことなのですが、main以前に読み書きされる場所なので、VC++を使う場合は問題ありません。
他の環境の場合は、スタックのサイズを取得するなどしてスタック末尾を使うといいかもしれません。

SetSehOpをスレッドが開始されるたびに実行してやればいいわけです。
スレッド開始の検知方法はdllのエントリーポイントを使っています。
また、なぜSEHに格納した値やSEHレコードのアドレスなどをxorしているかというと、XPなどでdllが固定アドレスにロードされる場合、うまいことそれを利用してSEH連鎖が組まれるおそれがあるからです。
詳細は株式会社FFRIEMETによるSEHOP実装の改良の提案を参照してください。

次は例外時のSEHOPチェック部分です。

SEHOPチェック

いつものようにコードから

void exception() {
  for (const SehRecord *record = GetFirstSehRecord(); reinterpret_cast<unsigned int>(record) != 0xFFFFFFFF; record = record->next) {
    if (record == GetSehRecord() && record->ptr == GetSehPtr()) {
      return;
    }
  }
  ::abort();
}

この短さなら説明するまでもありませんが、単純にSEH連鎖をたどっていき、登録したSEHレコードに格納した時と同じ乱数があるかをチェックしているだけです。
SEH連鎖の末尾固定だとwinodwsのSEHOPと競合するため、SEH連鎖内のどこかにあればいいとしてあります。

終わりに

どうだったでしょうか?
言うまでもないですがこのsehop.dllは単にLoadLibraryするだけで基本どのアプリでも動きます。

今回はSEHOPの自作ができたので、それを紹介したいだけの自己満足記事としての色が強かったですが、次回があればちゃんと解説メインに戻るはず。

もし次回があればこんどこそASLRの解説をやりたいと思います。
ではまた!

東方心綺楼の解析

東方心綺楼の体験版が頒布された当時自分は、東方緋想天と同様に数々のツールが作られ、単純に対戦する以上の楽しみが待っていると疑いませんでした。
しかしそれから待てど暮らせど、ツールの一つ出てくる気配はありません。
その謎は数カ月後に明かされることになります。
Web体験版の公開、意気揚々と逆アセンブラをかけた自分の目の前に広がっていたのは、経験したことのない難解なコードでした。

というわけで、東方心綺楼がなぜ解析しづらいのか、また自分がここまで解析してきた成果をまとめておこうと思います。

心綺楼の解析はなぜ難しいのか

結論から言えば、C++などのネイティブ言語だけで作られたものではないから、という一言につきます。

通常CやC++などネイティブな言語のみで作られている場合、難読化をかけたとしても似通った処理は一箇所にまとめられることになるため、ある程度知識があれば読み解くことはそう難しいことではありません。
例えば、暗号化されたファイルを読む場合、ファイルを読むReadFileのAPIをフックして場所を特定し、そこから何度かリターンを経たあたりに復号化処理が書かれているわけです。

しかし、今回の心綺楼はSquirrelというアプリ内組み込みの言語によって大部分が実装されており、上記のようには行きません。
単純に変数から読み上げるというだけで幾つもの関数を呼び出したりリターンしたりと処理があっちこっちへと行き来し、人間がコードの意味を理解しながら本質的な部分まで追う労力は生半可なものではないからです。

つまり、逆アセンブルなど機械語に翻訳される言語用に特化した解析手法ではツール作成はかなり難しいことになります。
※綺録帖のようになんとかしたツールも存在するので、完全に不可能というわけではありません

ではどうするのか
Squirrelで書かれているならば、こちらもSquirrelと同じ土俵で戦えばいいのです。

Squirrelの入り口、HSQUIRRELVMを見つける

前の章でSquirrelと同じ土俵で戦えばいいと書きました。
ではSquirrelとは何なのかといえば、アプリ内組み込みで用いられるスタックベースの小さな言語になります。
th123_aiで使われているluaと同種の言語ですね。

軽くリファレンスを読んでみればわかりますが、SquirrelのCインターフェースは、基本HSQUIRRELVMを第一引数に受けて動作するようになっているため、HSQUIRRELVMさえ自由に出来れば乗っ取れそうです。

HSQUIRRELVMを取得するため、HSQUIRRELVMを受けていて、なおかつ解析しやすいように特徴的な文字列を扱っている関数を探します。
するとSQGenerator::Yieldがよさそうだということがわかりました。

bool SQGenerator::Yield(SQVM *v,SQInteger target)
{
	if(_state==eSuspended) { v->Raise_Error(_SC("internal vm error, yielding dead generator"));  return false;}
	if(_state==eDead) { v->Raise_Error(_SC("internal vm error, yielding a dead generator")); return false; }
	SQInteger size = v->_top-v->_stackbase;
……以下略……

※SQVM *とHSQUIRRELVMはtypedefされているだけで同じ型

では実際に、OllyDbgを使って一行目の文字列で検索し、使用している関数先頭にbreakpointを設置。
その状態で心綺楼を実行し、breakpointに引っかかった時点でスタックからHSQUIRRELVMの値を取得。
さらにメモリーエディタを用いてその値を格納しているメモリーを検索すると、どうやらスレッドローカル領域に格納しているようでth135.exeの.tlsセクション内がヒットしました。
これでSquirrelの世界へ攻め入ることができます。

まずはHelloWorldから

解析してツールを作ろうというのにHelloWorldというのも変かと思われるかもしれませんが、まだ前の章で取得した値が本当にHSQUIRRELVMなのか確認できていません。
急がば回れ、ここは慎重に動作確認から行きましょう。

C++サイド

  sq_setprintfunc(v, printfunc, printfunc); // printfuncの実装については省略
  sq_pushroottable(v);
  sqstd_dofile(v, "main.nut", 0, true);
  sq_pop(v,1);

main.nut

print("HelloWorld");

ひとまずこんなコードをdllインジェクションして実行してみました、クラッシュしました。
……やはり確認は重要ですね。

なぜクラッシュしたのか

VisualStudioでスタックトレースをみてみるとsq_dofile内のreallocでランタイムチェックに失敗してクラッシュしているようです。

なぜreallocでクラッシュするのかというと、メモリーの効率化のためランタイムライブラリの内部でmallocやfreeやreallocする対象のメモリーについて管理を行なっており
にも関わらず自作コード内と心綺楼本体という別々に管理されているものを混ぜてしまったことが原因となります。

ではどうするのか

話は簡単、自作コード内と心綺楼内部に存在するsq_dofileのうち心綺楼内部の方を使えばいいのです。

というわけでHSQUIRRELVMを探してきた時と同じように、ユニークな文字列を探し、そこからサーチすることにします。
sqstd_loadfileにユニークな文字列があったので、そこを呼び出している関数一覧を出し、それらの逆アセンブラコードを軽く読んでsqstd_dofileを特定しました。

ではsqstd_dofileをそのアドレスに置き換えてみて実行してみましょう。

マルチスレッド対応でもないのにメインではないスレッドから使用しているためたまにクラッシュしますが、一応HelloWorldと表示されることもあるはずです。
ちょっと不安になる結果ではありますが、HSQUIRRELVMはこの値で正しいようです。

クラッシュ対策

前の章でとりあえずHelloWorldは出来ました。
しかしたまにクラッシュしたり、表示文字列が化けるようなのはなんとも消化不良な感が漂います。
そこで、解析を次のステップに進める前に、動作を安定化させようと思います。

動作が不安定なのは別スレッドから内部のスタックを触っているからにほかなりません。
ではメインスレッドで自分を実行させる方法はないのか、リファレンスをひっくり返して探して来ました。
その結果、sq_setnativedebughookで関数ポインターを設定すれば、Squirrelの関数が実行されたタイミングで自作コードを走らせることが可能らしいとの記述が。
念のためsq_setnativedebughookの実装も読んできましたが、特にマルチスレッドで操作して落ちる恐れも無さそうです。

というわけで現状のコードはこちら

#include <stdio.h>
#include <algorithm>
#include <boost/version.hpp>
#include <boost/static_assert.hpp>
#include <boost/filesystem.hpp>
#include <Windows.h>
#include <org/click3/dll_hack_lib.h>
#include "squirrel.h"

unsigned int baseAddr;
SQRESULT (*dofile)(HSQUIRRELVM, const SQChar *, SQBool, SQBool);

void printfunc(HSQUIRRELVM v, const SQChar* s, ...) {
  va_list arglist;
  va_start(arglist, s);
  vprintf(s, arglist);
  va_end(arglist);
}


void hook(HSQUIRRELVM v, SQInteger type, const SQChar * const sourcename, SQInteger line, const SQChar * const funcname) {
  static bool first = true;
  if (!first) {
    return;
  }
  first = false;
  sq_pushroottable(v);
  dofile(v, "main.nut", 0, true);
  sq_pop(v,1);
}

void main() {
  org::click3::DllHackLib::SetupConsole();
  baseAddr = reinterpret_cast<unsigned int>(::GetModuleHandleW(NULL));
  dofile = reinterpret_cast<SQRESULT (*)(HSQUIRRELVM, const SQChar *, SQBool, SQBool)>(baseAddr + 0x0034AAF0);
  unsigned int addr = 0;
  while(addr == 0) {
    Sleep(1000);
    addr = *reinterpret_cast<const unsigned int *>(baseAddr + 0x004D7984);
  }
  HSQUIRRELVM v = reinterpret_cast<HSQUIRRELVM>(addr);
  sq_setprintfunc(v, printfunc, printfunc);
  sq_setnativedebughook(v, hook);
}

※インジェクション部分は省いています

こころさんとの遭遇

さて、前の章でSquirrelの中にコードをインジェクションしたり、内部構造を自由に操作することができるようになりました。
では次はキャラのxy座標の取得を目標に、Squirrelの内部データサーチを行……う前にちょっと脇道にそれましょう。

まずSquirrelのグローバルな名前空間のものを一覧表示してみます。
色々面白いものは見つかりますが、キャラ情報らしきものは見つかりません。
そこでテーブル要素に限り1階層だけ下まで中身を表示するようにしてみます。

おや、save_dataの下にenable_kokoroという好奇心を刺激されるものがありますね。
ちょっと有効にしてみましょう。
※2013/08/03現在「秦こころ」は未実装であり、ストーリー以外で使用することはできません。

void hookEnableKokoro(HSQUIRRELVM v, SQInteger type, const SQChar * const sourcename, SQInteger line, const SQChar * const funcname) {
  static bool first = true;
  if (!first) {
    return;
  }
  first = false;
  sq_pushroottable(v);
  sq_pushstring(v, "save_data", -1);
  sq_get(v, -2);
  sq_pushstring(v, "enable_kokoro", -1);
  sq_pushbool(v, true);
  sq_set(v, -3);
  sq_pop(v, 2);
}

さて、この状態で心綺楼を起動してみるとどうなるのでしょうか……

キター

はい、ここまで本筋と関係のない脱線でした。
ちなみにこの状態で設定をいじったりプロファイルをいじったりするとセーブされてしまい、enable_kokoroへの操作をやめてもこころさんは有効になったままになってしまいます。
試す人は注意。

xy情報へのアクセス

とりあえず対戦中にしてから前述のグローバル名前空間内の物を列挙してみます。
すると、actor以下にplayer1といういかにもなものが増えていました。

試しにplayer1以下を表示させてみると、lifeやら何やら色々出てきて、もうこれに間違いないという気分になりますが、なぜかxyだけ見当たりません。
どういうことかと思い調べてみると__gettable以下にxとyという関数が。
これは何らかの抽象化の臭いがします。
なのでとりあえず以下の様なコードで取得を試みてみました。

void hookPrintPos(HSQUIRRELVM v, SQInteger hookType, const SQChar * const sourcename, SQInteger line, const SQChar * const funcname) {
  sq_pushroottable(v);
  sq_pushstring(v, "actor", -1);
  sq_get(v, -2);
  const unsigned int size = sq_getsize(v, -1);
  if (size < 2) {
    sq_pop(v, 2);
    return;
  }
  sq_pushstring(v, "player1", -1);
  sq_get(v, -2);
  sq_pushstring(v, "x", -1);
  if (SQ_FAILED(sq_get(v, -2))) {
    printf("error\n");
    sq_pop(v, 3);
  }
  int x;
  sq_getinteger(v, -1, &x);
  sq_pop(v, 1);
  sq_pushstring(v, "y", -1);
  if (SQ_FAILED(sq_get(v, -2))) {
    printf("error\n");
    sq_pop(v, 3);
  }
  int y;
  sq_getinteger(v, -1, &y);
  sq_pop(v, 2);
  sq_pushstring(v, "player2", -1);
  sq_get(v, -2);
  sq_pushstring(v, "x", -1);
  if (SQ_FAILED(sq_get(v, -2))) {
    printf("error\n");
    sq_pop(v, 3);
  }
  int ex;
  sq_getinteger(v, -1, &ex);
  sq_pop(v, 1);
  sq_pushstring(v, "y", -1);
  if (SQ_FAILED(sq_get(v, -2))) {
    printf("error\n");
    sq_pop(v, 3);
  }
  int ey;
  sq_getinteger(v, -1, &ey);
  printf("%d:%d  %d:%d\n", x, y, ex, ey);
  sq_pop(v, 4);
}

すると、予想通りXY情報が取得出来ました。
後から知ったことなのですが、__gettableなどはSqratというバインダーを使っていると増えるメンバーで、メソッドが見つからなかった場合の処理をフックしてC側の実態にひもづける何かなのだとか。

とにもかくにもxy情報も無事取得できました、めでたしめでたし。

終わりに

如何だったでしょうか?
主にSquirrelレイヤーに乗り入れる方法がメインで、そのあとは大体蛇足だった感じがします。

最後に上記のコードを利用して、ひたすら近づいてA連するだけのAIをおいておきます。
DLLインジェクション方法まで含んでいるので、試しに動かしてみたい方はどうぞ。
http://wordpress.click3.org/garakuta/th135hack.zip

Run-TimeErrorChecksとBufferSecurityCheck

Windowsには様々なセキュリティ機能が備わっている、しかしそれらすべてを使いこなしている人は想像以上に少ない。
自分もまた使いこなしているとはとてもじゃないが口にできない。
それでも、理解を深めようと努力することはできる。

というわけで、今回はRun-Time Error ChecksとBuffer Security Checkというセキュリティ機能について解説したいと思います。
一応続き物なので、初めての人は前回の記事からどうぞ。

今回も本記事を読む前にサンプルコード集をDLしてください。
rao_exploit.zip
ちなみにraoはreturn address overwriteの頭文字です。

exploit1:return address overwrite攻撃

前回スレッドスタック(以後スタック)上には様々な情報が配置されると書きましたが、その中にreturnアドレスがあったのを覚えているでしょうか?
returnアドレスとは、関数呼び出し時にスタック上に積まれる呼び出し元のアドレスのことで、関数の実行が終わった後に元の処理を再開するために使われます。
ですが、スタック上にあるということは当然ローカル変数のバッファオーバーフローによる影響を受けます。
そう、スタック上のreturnアドレスを利用した攻撃が存在するのです。

return address overwrite攻撃とは

return address overwrite攻撃とは、SEH overwriteと同様スタック上に配置されたreturnアドレスを任意のアドレスで上書きし、そこを実行させることにより任意のコード実行を行う攻撃を指します。
SEH overwriteとの主な違いは

  • returnするまでクラッシュさせずに処理を継続させる必要がある
  • 例外ハンドラとして実行されるわけではないのでSafeSEHの影響を受けない
  • 同様にSoftwareDEPの影響も受けない

となります。

おそらく説明だけではピンと来ないかと思うので、さっそくためしてみましょう。

実際に試してみよう

今回のソースコードはこちら。

#include <cstdio>

#include <Windows.h>

void call() {
  ::MessageBoxA(NULL, "message", "title", MB_OK);
  ::exit(0);
}

int main(const unsigned int argc, const char * const * argv) {
  FILE * const fp = ::fopen("file", "rb");
  unsigned int size;
  char s[256];
  ::fread(&size, 4, 1, fp);
  ::fread(s, 1, size, fp);
  for (unsigned int i = 0; i < size; i++) {
    ::printf("%02x ", s[i]);
  }
  ::fclose(fp);
  return 0;
}

前回同様、ファイルの先頭4byteを長さとして本体を読み込むもののバッファは256byteしかとっていないのでバッファオーバーフローが発生しうるコードです。
callはどこからも実行されていないのも以前と同じ。
今回はさらにSafeSEHのオプション付きコンパイルされており、可能ならDEPを有効にしてもかまいません。

では実行してみましょう。
exploit1のtest.exeを実行してみれば、callはどこからも呼ばれていないにもかかわらずmessageとメッセージボックスが上がったかと思います。
これが今回問題となるreturn address overwrite攻撃になります。

ちなみにfileの中身はreturnアドレスまでバッファオーバーフローさせてcallのアドレスで上書きしているだけ。
例外を吐かせるわけにはいかないので、fpやsizeを上書きしてしまうなら調整が必要でしたが上書き範囲外だったので無視しています。
※説明はしていませんが以降のexploitではfpやsizeでクラッシュしないよう調整を行っています。

exploit1まとめ

return address overwrite攻撃とはスタック上のreturnアドレスを上書きして任意コードを実行させる攻撃。
SafeSEHやDEPでは防げない。

次項では一部の開発者間ではバッファオーバーフローに有効とされているRun-Time Error Checksを使用して防いでみましょう。

exploit1_RunTimeErrorChecks:バッファオーバーフローの検出

バッファオーバーフローには実は弱点があります。
始点から目標とするメモリーまでの間全てを上書きしなければならないという点です。
それを利用したバッファオーバーフロー検出手段として、Run-Time Error Checksというものがあるのです。

Run-Time Error Checksとは

Run-Time Error Checksとは大きく分けて三つの機能があります。

  • 切り捨てが発生する整数キャストを検出
  • 未初期化の変数の使用を検出
  • スタックオーバーフローを検出

今回は必要ないのでスタックオーバーフローの検出以外は説明しませんが、興味のある方は「/RTC msdn」などでググるといいでしょう。

さてどのようにスタックオーバーフローを検出するのかというと、スタックオーバーフローが発生しそうな変数、今回の場合はsのすぐ後ろに未使用の領域をとり、そこを0xCCCCCCCCで埋めておき、関数を抜ける直前にチェック、0xCCCCCCCCから変化していたらバッファオーバーフローが発生したとしてアプリを強制終了させるというものです。

では今回も試してみましょう。

実際に試してみよう

今回はバッファオーバーフローの検出を行う/RTCsオプションをつけただけでソースコードに変更はありません。
ではさっそくexploit1_RunTimeErrorChecksのtest.exeを実行してみましょう。

どうでしょう、ちゃんと強制終了したでしょうか。
デバッグ情報付きでコンパイルしたうえで逆アセンブルしてみるとmain関数を抜ける直前で_RTC_CheckStackVarsという関数を呼び出しています。
その中では変数sの次のメモリーが0xCCCCCCCCであることをチェックしようとして失敗し、そこでエラーを吐いて強制終了する、という流れです。

exploit1_RunTimeErrorChecksまとめ

Run-Time Error Checksを使えば単純なバッファオーバーフローは検出できる。

ですが、実はRun-Time Error Checksは処理速度を大幅に低下させるなどの副作用があり、デバッグ専用のオプションとされリリースするアプリで使用することは非推奨となっています。
なぜ処理速度が低下するのかというと、変数の数に応じて仕込むデータの量が多くなるため、関数によっては仕込むのと検証に必要な時間が無視できないレベルまで伸びるためです。
また、「単純な」とついていることからわかるようにRun-Time Error Checksは簡単に回避が可能です。
次項ではそのあたりについて触れることにしましょう。

exploit2:Run-Time Error Checksは騙せる

実はRun-Time Error Checksはセキュリティー上の防御機能として設計されたわけではなく、あくまでコーディングミスによるバッファオーバーフローを検出しようとして作られたものです。
そのためセキュリティー機能としては致命的な弱点を持っているのです。

Run-Time Error Checksの弱点とは

Run-Time Error Checksの説明で「上書きチェック用に埋められるデータは0xCCCCCCCCである」と書きました。
これはRun-Time Error Checksを誰がどう使おうと変動することはありません、常に0xCCCCCCCCです。
そしてデータが配置される位置も当然変動することはありません。
つまり、Run-Time Error Checksが動作していると分かっていれば、そこを0xCCCCCCCCで埋めてしまえば容易にチェックを潜り抜けることが可能になるのです。

実際に試してみよう

今回もソースコードに変更はありません、当然/RTCsのオプションもついたままで、読み込むfileの中身を一部0xCCCCCCCCに書き換えただけになります。
ではexploit2のtest.exeを実行してみましょう。

どうでしたか?
おそらくmessageとメッセージボックスが表示されたかと思います。

exploit2まとめ

Run-Time Error Checksはセキュリティ機能ではなくデバッグ支援用のバッファオーバーフロー検出に過ぎない。
適切な個所を0xCCCCCCCCで埋めてしまえばRun-Time Error Checksによる検出回避は容易。

どうでしたでしょうか?
一部ではRun-Time Error Checksは処理速度低下のデメリットが大きいだけでセキュリティ向上するのだから有効にするべきだとする人もいるそうですが
このようにいとも簡単に突破できてしまう以上、処理速度を犠牲にしてまで有効にするべき機能ではないと私は考えます。
もちろんそれはRun-Time Error Checksを上回るセキュリティ機能が存在するからという話でもありますが。

次項では、そのRun-Time Error Checksを上回るセキュリティ機能、Buffer Security Checkについてです。

exploit2_BufferSecurityCheck:return address overwriteにとって最大の天敵

Run-Time Error Checksの弱点について説明した時、0xCCCCCCCC固定ではなく乱数にすればよいのではないか、と思った方もいるのではないでしょうか?
そう、それを実現した機能がBuffer Security Checkになります。

Buffer Security Checkとは

Run-Time Error Checksでは0xCCCCCCCC固定なのと処理速度が低下するのが問題だと書きました。
その両方に対応したのがBuffer Security Checkであり、これ単独で実質上return address overwriteを完璧に防ぐことのできるセキュリティ機能となります。

原理は簡単で、起動時に乱数を生成してグローバル領域に保持、バッファオーバーフローしうる関数の開始時点でreturnアドレスの直後にその乱数列とスタックの先頭アドレスをxorしたものを入力しておく、その後returnする直前でその値が変わっていないことを検証し、検証に失敗すればそこで強制終了するというものです。

Run-Time Error Checksに比べて優れている点は二点。
第一に、乱数計算は事前に済ませておく、データ挿入も検証も関数呼び出し一回につき一回だけ、と出来るだけ軽量な処理で実行できるということ。
第二に、乱数を利用しているため起動するごとに別の値となり、32bitアプリケーションであれば1/4,294,967,296の確率でしか突破できない堅牢さ。
この二点です。

実際に試してみよう

今回もソースコードに変更なし、コンパイルオプションとして新たにBuffer Security Checkを有効にする/GSを追加しています。
ではexploit2_BufferSecurityCheckのtest.exeを実行してみましょう。
どうでしたか?
うまくいっていれば攻撃は見事阻止されアプリは強制終了したかと思います。

デバッグ情報付きでコンパイルしたうえで逆アセンブルしてみると、まず最初に__security_init_cookieで__security_cookieに乱数値を設定、main関数最初で__security_cookieとスタックの先頭アドレスをxorしたものをセット、return直前に__security_check_cookie関数を呼び出して検証しているのが分かります。

総まとめ

バッファオーバーフローでreturnアドレスを上書きすれば任意コード実行が可能。
Run-Time Error Checksを有効にすれば単純なバッファオーバーフローなら検出して防御できる。
だが上書きチェックに埋めておくデータは0xCCCCCCCC固定なので、そうと分かれば検出回避は容易。
Buffer Security Checkなら乱数でチェックされるのでほぼ完全に阻止できる。

というわけで、みなさんBuffer Security Check(/GSオプション)は有効にしましょう。
Run-Time Error Checksはデバッグには便利ですが、セキュリティー的には大した効果がないので、リリースバイナリではかける必要はありません。
これでreturn address overwrite攻撃対策は完璧ですね。

終わりに

前回と今回でバッファオーバーフローを用いた二大攻撃手段であるSEH overwriteとreturn address overwriteの詳細と対策を述べました。
まだまだ攻撃方法はいろいろありますが、この二つが防げるだけでもだいぶ安全性が違うので、みなさんもぜひ導入しましょう。

とはいえ、前回の/SAFESEHと違って/GSはデフォルトでONなので知らず知らずに使っている開発者さんも多いかと思います。
そういう場合はラッキーと思いつつ、このオプション一個で左右されるセキュリティの大きさに思いをはせるなどしてみるのもいいかもしれません。

さて、次回は攻撃方法を特に限定することなく全般的に攻撃を困難にするASLR(アドレス空間レイアウトランダマイズ)の解説をしようかと思います。
ではまた!

SafeSEHとSoftwareDEP

SafeSEHという言葉を聞いたことがあるでしょうか?
DEP、もしくはSoftwareDEPという言葉は?
どちらもWindowsのセキュリティー機能の名称ですが、聞いたことぐらいはあっても具体的になにをどうしてアプリケーション守っているのかまでは知らない人が多いかと思います。

そこで今回は、実際にバッファオーバーフローの脆弱性をつく攻略コードを作成しながら、SafeSEHやSoftwareDEPがどのようにしてそれらの攻撃からアプリケーションを守ってくれるのかを紹介したいと思います。

前置き

まずは何を置いてもサンプルコード集をDLしてください。
seh_exploit.zip

本記事内で紹介する技術は悪用すればウイルスを作成することもできる危険な技術に当たります。
今回はウイルス作成が主目的ではないので実用性ができるだけ低い攻略コードを用いていますが、危険なことに変わりはありません。
悪用ダメ、絶対。

また本記事を読んだり、サンプルを実行することで発生したいかなる被害でも作者は一切責任を負いません。
全て自己責任でお願いします。

本記事は
Nothing but Programming様
および
株式会社フォティーンフォティ技術研究所様の「SEHオーバーライトの防御機能とそのExploit可能性」
を参考にさせていただきました。
より詳しい情報はそちらをどうぞ。

exploit1:SEHオーバーライト攻撃

WindowsにはSEH(Structured Exception Handling)という例外を処理するための機構が備わっています。
簡単に説明すると、例外発生時に呼び出す例外ハンドラのリスト、およびそれを用いた例外処理機能のことを指します。
ここで問題なのは、そのリストがスレッドスタック(以降スタックと呼称)の上、要するにローカル変数やリターンアドレスといった情報を配置するのと同じ場所に配置されていること。
なぜなら、ローカル変数のバッファオーバーフローで容易に書き換えられてしまうためです。

SEHオーバーライト攻撃とは

正式名称をSEH overwrite、その名の通りスタック上に構築されたSEHのリストを上書きし、例外発生時の呼び出し先を任意のコードに変更する攻撃を指します。
手順としては

  • バッファオーバーフローの脆弱性を利用してSEHのリストを上書きする
  • 何かしらの手段で例外を発生させる
  • SEHのリストをたどり例外ハンドラを実行=>書き換えた任意のアドレスのコードを実行

となります。
※ここから任意コードの実行につなげるにはさらに一工夫必要な場合が多いのですが今回は省略。

実際にやってみよう

ここでいったんサンプル内のexploit1を開いて、中のtest.exeを実行してみてください。
黒窓でずらずらと表示された後、「message」とメッセージボックスで表示されて、OKを押すと終了したかと思います。

これだけでは何のことやらさっぱりわからないと思うので、test.exeのソースコードを開いてみましょう。
以下のようになっているはずです。

#include &lt;cstdio&gt;

#include &lt;Windows.h&gt;

void call() {
  ::MessageBoxA(NULL, "message", "title", MB_OK);
  ::exit(0);
}

int main(const unsigned int argc, const char * const * argv) {
  FILE * const fp = ::fopen("file", "rb");
  unsigned int size;
  char s[256];
  ::fread(&amp;size, 4, 1, fp);
  ::fread(s, 1, size, fp);
  for (unsigned int i = 0; i &lt; size; i++) {
    ::printf("%02x ", s[i]);
  }
  ::fclose(fp);
  ::getchar();
  return 0;
}

ちょっと読めばすぐわかるかと思いますが、call関数はコード中のどこからも呼び出されてはいません。
かといってMessageBoxのAPIを呼び出している箇所はほかになく、なぜあのような動作になったのかわからないかと思います。

このアプリは、ファイルを開き、先頭4byteのデータ長を読み込んで、そのデータ長分だけファイルから読み込みそれを16進数で表示するアプリです。
ですが、データ長が256以下であることを前提としているため、256超のデータ長を与えることでバッファオーバーフローが発生してしまう脆弱性を持ったアプリでもあります。

そう、つまりcallが実行されてしまった秘密は読み込んでいるファイルにあるのです。

どうしてcallが実行されてしまったのか

test.exeを逆アセンブルすると、sの先頭アドレスは0x0018FE14、sizeのアドレスは0x0018FF1C、SEHにより最初に実行される関数ポインターが格納されているのが0x0018FF70、callのアドレス0x0042B390がとなります。
※コンパイル環境により異なります、この数値はzip内のexeファイルの物

一応この時点で必要な情報はすべて出ています。
ためしにfileの中をのぞいてみる、test.exeを逆アセンブルしてみるなどして、どのようにcallを呼ばせているのか調べてみるといいでしょう。

結論を言ってしまえば、0x160byte読み込ませて、その最後4byteをcallのアドレスを指すよう90 B3 42 00(リトルエンディアンなため)にして最初に呼び出される例外ハンドラをcallに変更。
0x108byte目から4byteを可能な限り大きな数値にしてforで範囲外アクセスの例外を発生させています。

逆アセンブル環境が整っているなら、ためしにtest.cppの中身を改変したうえで再コンパイルし、fileの中身を書き換えて同じことができるかを試してみるのもいいでしょう。
個人的にはollydbgとstirlingがお勧めです、前者は逆アセンブラを表示しながら実行ができ、後者はバイナリファイルをいじるのに便利です。

exploit1まとめ

SEHはローカル変数のすぐ隣に例外ハンドラを置いている。
バッファオーバーフローを利用すればローカル変数の近くは書き換え放題。
無対策だとバッファオーバーフロー脆弱性一個で任意のコード実行が可能。

言い方は悪いですが、何も防御手段を講じていない場合、バッファオーバーフローの脆弱性一個でこうも簡単に動作をいじることができてしまうのです。
ウイルスがなかなか撲滅されないわけですね。

ちなみに、「無対策だと」や「何も防御手段を講じていない場合」などとついている理由は後述します。
今は、任意コード実行なんて意外と簡単だということだけ覚えておいてください。

exploit1_safeseh:exploit1への対策

さて、前項で任意コード実行なんて簡単だと書きましたが、防御手段がないわけではありません。
もちろんバッファオーバーフローの脆弱性をなくしてしまうのが一番ですが、何百から巨大なもので何百万行にも及ぶプログラムコードから脆弱性0のアプリを作るのは現実的ではないことは大体予想がつくかと思います。
そこで登場する防御手段がSafeSEHです。

SafeSEHとはなにか

SafeSEHとは、SEHで呼び出しうるアドレスを事前に登録しておき、そこ以外が呼び出されそうになったら強制終了させる仕組みです。
動的な登録はできず、あくまで起動時のみの登録となるので、動的コード生成などとは相性が悪いですが、これがあるだけで前述したSEH overwriteは大体防ぐことができます。

実際に試してみよう

サンプルのexploit1_safesehを開いて、中のtest.exeを実行してみてください。
ソースコードは全く同じ、fileもtest.exeを逆アセンブルした情報をもとに攻略コードを仕込んであります。
しかし、今度はメッセージボックスが表示されることはなく、そのまま終了したかと思います。
違いはコンパイル時に/SAFESEHオプションをつけただけ。
SafeSEHの威力がよくわかったかと思います。

exploit1_safesehまとめ

SafeSEHというSEH overwriteを防ぐ防御機能がwindowsには備わっている。
/SAFESEHオプションをつけるだけで単純なSEH overwriteなら強制終了までしか届かない。

さて「大体」とか「単純な」とついていることからわかった人もいるかもしれませんが、SafeSEHとて万能ではありません。
次項ではSafeSEHがかかったexeファイルでSEH overwriteを行ってみましょう。

exploit2:無防備なDLL

前項ではSafeSEHによって事なきを得ましたが、実はSafeSEHはオプション付きでコンパイルされたバイナリとそれに紐づくスタックまでしか保護しません。
つまり、一つでもSafeSEHをかけていないdllなどが混ざるとそこからいくらでも任意コード実行が可能になります。
そこで今回はSafeSEHがかかっていないdllが混ざった場合にどうなるかを見ていきましょう。

アプリの改修

今度は一部機能をdllとして分離することになりました。
ソースコードは以下のようになります。

・dllmain.h

#ifdef DLL_EXPORT_FLAG
#define EXPORT __declspec(dllexport)
#else
#define EXPORT __declspec(dllimport)
#endif /* TEST_EXPORTS */

EXPORT unsigned int xor(const unsigned int a, const unsigned int b);

・dllmain.cpp

#include &lt;cstdio&gt;

#include &lt;Windows.h&gt;

#define DLL_EXPORT_FLAG

#include "dllmain.h"

void call() {
  ::MessageBoxA(NULL, "message", "title", MB_OK);
  ::exit(0);
}

unsigned int xor(const unsigned int a, const unsigned int b) {
  return a ^ b;
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
  return TRUE;
}

・test.cpp

#include &lt;cstdio&gt;

#include "dllmain.h"

int main(const unsigned int argc, const char * const * argv) {
  FILE * const fp = ::fopen("file", "rb");
  unsigned int size;
  char s[256];
  ::fread(&amp;size, 4, 1, fp);
  ::fread(s, 1, size, fp);
  const unsigned int key = 0xAA;
  for (unsigned int i = 0; i &lt; size; i++) {
    ::printf("%02x ", xor(s[i], key));
  }
  ::fclose(fp);
  ::getchar();
  return 0;
}

やってることはexploit1の時とほとんど変わっていません。
単に表示する内容をdllのxor関数を通した結果に変更しただけです。

callがdll側に移動していることからもわかるように、今度はdll側のアドレスをsehに組み込みます。

実際に試してみよう

サンプルのexploit2を開き、その中のtest.exeを実行してみましょう。
今回もcallに触れている関数がないにもかかわらず、callの内容が実行されたかと思います。

手順はexploit1とほぼ同じ、ただcallの存在する位置が/SAFESEHオプションのかかっていないdllの中に移動しただけです。
もちろんexe本体は/SAFESEHをかけています。

exploit2まとめ

SafeSEHはすべてにかけて初めて効果を発揮する、ということがよくわかったかと思います。
一応dllはアドレスがぶつかったらロードされるアドレスが変わるなどランダム要素がないわけではないのですが、ASLR(アドレス空間レイアウトランダマイズ)がかかっていない場合はほぼ固定と言い切ってもいいので、かなり簡単です。
次項では、この対策となります。

exploit2_safeseh:exploit2への対策

exploit2への対策は簡単。
要するに全部でSafeSEHのオプションを有効にしておくだけです。
幸いWindowsが提供するDllはすべてSafeSEHがかかっているので、外部ライブラリにだけ気を付けていればよいことになります。

実際に試してみよう

サンプルのexploit2_safesehを開きtest.exeを実行してみましょう。
今度もソースコードはexploit2と変わりませんが、/SAFESEHオプションがDLLでも有効なため、強制終了するまでで被害が抑えられています。

exploit2_safesehまとめ

SafeSEHをかけるなら全部でかけよう。
Windows付属のdllは元からSafeSEHかかっているので安心。

次項ではSafeSEHだけでは防げないSEH overwriteについて、です。

exploit3:SafeSEHでは防げないSEH overwrite

すべてのexeとdllにSafeSEHを施してSEH overwrite対策は万全、そう思ってはいませんか?
SafeSEHだけではすべてのSEH overwrite攻撃は防げません。

SafeSEHで防げないSEH overwriteとは

SafeSEHが禁止する例外ハンドラは、事前に登録されておらず、SafeSEHがかかったバイナリから紐づいている領域+スタック領域なのですが、実はこの定義に入らないメモリー領域が存在します。
そう、動的に確保する「ヒープ領域」(以降ヒープ)です。
C言語ではmallocで取得できるものがヒープに当たります。

ヒープはどのバイナリにも属していない扱いとなるためSafeSEHの対象外となり、ここにコードを置いて例外ハンドラとして登録できればSafeSEHがかかっていても任意コード実行ができてしまうわけです。

実際に試してみよう

今回はわかりやすさのためexploit1のコードにヒープ関連のコードを若干追加した形となります。

#include &lt;cstdio&gt;

#include &lt;Windows.h&gt;

void call() {
  ::MessageBoxA(NULL, "message", "title", MB_OK);
  ::exit(0);
}

int main(const unsigned int argc, const char * const * argv) {
  FILE * const fp = ::fopen("file", "rb");
  unsigned int size1;
  ::fread(&amp;size1, 4, 1, fp);
  char *s1 = reinterpret_cast&lt;char *&gt;(::VirtualAlloc(reinterpret_cast&lt;void *&gt;(0x00250000), size1, MEM_COMMIT, PAGE_READWRITE));
  ::fread(s1, 1, size1, fp);
  unsigned int size2;
  char s2[256];
  ::fread(&amp;size2, 4, 1, fp);
  ::fread(s2, 1, size2, fp);
  for (unsigned int i = 0; i &lt; size2; i++) {
    ::printf("%02x ", s1[i] ^ s2[i]);
  }
  ::fclose(fp);
  ::getchar();
  return 0;
}

動作としては、先頭4byteを読み込みそれをsizeとしてVirtualAllocAPIを使用してヒープを確保、そこに指定byteだけ読み込みます。
その後、再度4byte読み込み、その4byteの分だけローカル変数上に読み込むが、256byteを超えていたらバッファオーバーフローが発生する、といういつもの動作となっています。

ヒープからの確保がVirtualAllocなのは気にしないでください。

それではtest.exeを実行、する前に
DEPを有効にしている場合はDEPの例外設定にtest.exeを設定してください。
理由は後述しますが、DEPの管理下だと攻略コードは通らないようになっているためです。
また、アドレス指定でメモリーを確保している都合上、メモリーの確保に失敗して動作しないことがあります。
見慣れないエラーで落ちたらリトライしてみてください。

どうでしょうか?
ちゃんとSafeSEHされていたのにSEH overwrite攻撃が成功していたかと思います。

exploit3まとめ

SafeSEHをしていてもヒープ領域を使われるとSEH overwriteは防げない。

ではアプリ開発者はSEH overwrite攻撃に屈するしかないのでしょうか?
いいえ、ほぼ完全に防ぎきる方法が存在します。
次項はその方法について。

そしてSoftwareDEPへ

DEPという機能はご存知でしょうか?
WindowsXP SP2から入った機能で、Data Execution Preventionの略となります。

DEPとひとくくりにされていますが、その機能は大きく分けてHardwareDEPとSoftwareDEPの二つに分かれます。
HardwareDEPは強力な機能ですが、今回は関係がないので説明はまた今度にして、本題はSoftwareDEPです。

SoftwareDEPとは

SoftwareDEPとは、例外ハンドラがヒープ上を指していた場合にそこで強制終了させるというもの。
メモリーに実行可能属性をつけていようがSafeSEHをOFFにしていようが完全にシャットアウトするという、ある分野のソフトウェアに対しては正常動作すら阻害するというある意味ぶっ飛んだ機能です。

前述したDEPの例外設定にしてくれというのはこれに関係していて、要するにSoftwareDEPが有効ならばヒープを利用したSEH overwriteは完全に防御できるわけです。

実際に試してみよう

DEPが無効な方は有効にして(※要再起動)、有効な方はDEPの例外設定からtest.exeを外して実行してみましょう。
たとえCPUが未対応でHardwareDEPが無効になっていたとしても、exploit3のtest.exeの攻略コードはきっちり防がれるはずです。

総まとめ

SEH overwriteで攻撃されても、SafeSEHならバイナリに紐づく領域やスタック上で任意のコード実行は防げる。
DllがひとつでもSafeSEH未対応だと危ない。
それだけだとヒープ利用されたら防げない。
が、それもSoftwareDEPが有効なら防げる。

というわけで、みなさんDEPは可能な限り有効にしましょう。
アプリを配布する際も可能な限りSafeSEHをつけましょう。
dll形態で配布する場合はなおさらね。
※作者のは今までSafeSEHはVC++ではデフォルトONだと思っていたが違ったため大体ついてません、ごめんね!

終わりに

本記事ではSEH overwriteだけを扱いましたが、世の中にはもっといろいろな攻撃方法が存在します。
同時に防御方法も、HardwareDEPなどまだまだ説明していない機能がたくさんあります。
この記事を読んで少しでもそういうものに興味を持ってくれた方は、そういうことを考えて少しでもユーザーのセキュリティーにやさしいソフトウェア開発をしてくれたらうれしいです。

また次回があれば、今度はreturn address overwrite攻撃とバッファーセキュリティーチェックについてでも書こうかと思います。
ではまた!

非想天測1.10aパッチについて

気付いた人は気付いたかもしれませんが、th123d.datというファイルが追加されています。
リプレイの互換性があるのに追加ファイルとはどういうことなのか?
気になったので調べてみました。

とりあえずtouhouSEで展開してみると、中には文のモーション定義ファイルが一個だけ入っていました。
手元のツール(非公開品)にて解析してみると、天狗の太鼓から喰らい判定が一個削除されていることがわかりました。
天狗の太鼓と言えば、意味不明なところに喰らい判定があるバグが有名ですが、もしやこっそり修正されたのか?
と思い、検証用のリプを1.10でとり、それを1.10aで再生してみたのですが、普通に喰らいました。

調べてみると、どうやらth123d.datは追加されただけで読み込む処理は入っていないようです。
そこでth123d.datの読み込み処理を追加するパッチを作って試してみたところ、天狗の太鼓バグが発生しなくなりました。
どうやら推測は当たっていたようです。

どうしてファイル入れるだけで有効化していないのかはわかりませんが(リプレイの互換性対策?)、一応天狗の太鼓バグなどを直すつもりではいるようです。
いつになるかはわかりませんがまったり待っていましょう。

解析技術遍歴とか

>解析等を学習されていった道筋的な物を教えてはいただけないでしょうか?
とコメントをもらったので試しに書いてみる

・起
今から4年ちょっと前、PCを手に入れてから1年ちょっと、C言語は日常的に書くがまともに完成させたことがない程度の頃。
某MMORPGのサーバプログラムがCコードの形で広まり、クライアント側の解析も進んで誰でも好き放題弄れる物があり
好奇心からそれに手を出したのが始まり。
クライアント側を弄るのに様々なツールが出回っていたものの、初心者向けなわけがなく
解説サイトやらを見て回って手探りで使っていた状態。
この頃にバイナリエディタの基礎やら解析に対する考え方を覚えたと思う。
といっても、この時点では精々xmlファイルを好き勝手書き換えるぐらいで、まっとうな解析は一切やっていないし出来なかった。

・承
今から3~4年前、Cで人に見せられるコードがようやく一つって程度で
起で知り合った人から誘われ、オリジナルのMMORPG作ろうぜ!という企画に参加した頃。
サーバは他所で公開されていたMMORPG用エンジンで、クライアントも同様の物を使っていたのだけど
開発途中のものであったために基本を除いて一切説明がなく行き詰まり、手探りに開発を続けざるを得ない状態。
バイナリエディタでexeを開いてスクリプト中で使用できる関数名一覧を持ってきたり
名前しかわからぬ関数に適当に引数投げて動作を調べたり
こういったことばかり1年近く続けていたので、解析技術の基礎がついたのはこの頃?
ちなみに、最終的には頓挫しました。 俗に言う黒歴史、もしくは若気の至り。

・転
今から2~3年前、Perlを本格的に使い出し、Cではドマイナーなプログラムコンテストなら本戦にいけることもあった程度で
RagnarokOnline(以降RO)にはまっていたら不正対策と称してnProtectが実装され、RO起動中はPerlや一部プログラムが動作しなくなり憤慨していた頃。
起承の間に偶然検索に引っかかったなどで、一通りハックや改造といった情報を読んでいて愚かにも判った気になっていたので
Perl禁止とか自力で解除してやると息巻いていた頃。
今まで知識の上だけであった逆アセンブラ、DLLインジェクション、Win32API、メモリーエディターなどを実際に使うようになったのはコレが原因。
RoAddr.dllに多少とはいえ関わったのもこの頃。
解析手法やら改造手法はほぼ独学で、方法を思いついてはnProtectに撃退され「くそ、その手があったか」という風にいろいろ学び取っていたと思う。
たぶん自分の改造関連技術はこの時点から変わってないと思う。
一応能力は上がっているけど、それはただ経験を積んで諸々最適化されただけ。

・結
今から0.5~2年前、ただひたすらにコード書いていた日々。
ファイルフォーマットに興味を持ち車輪の再発明しまくったり
東方という自分の嗜好にドストライクなゲームに出会い、ついでにツール作成の場が用意されていたので
そちらの開発にも手を出してなおさらプログラム漬けになっていた頃。
バイナリエディタの使い方に習熟したのはこの頃だし
ファイルフォーマットについて知識が付き、バイナリ列を見るだけでなんとなく意味を読み取れるようになったのもこの頃。
一言で言えば起承転で得た知識を経験に昇華した時代。

こんなので参考になるかどうかは判らないけど自分はこうやって技術身につけました。

ちなみに、もし人に「解析技術身につけたいんだけど、どうしたらいい?」と聞かれたなら
「何してもいいからその辺のゲームのデータファイルを展開するアプリを自作しろ、話はそれからだ」と返します。
なぜって自分がそんなでしたからね。
「解析してみたい!」と考える程度に知識あるなら、後は時間さえかければ応用だけで何とかなるはずっていうのが自論です。