他人の空似

2014 年 10 月 25 日

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

Filed under: 解析 — 中の人 @ 9:08 PM

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では発生しないことを確認している。

2013 年 12 月 3 日

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

Filed under: 解析 — 中の人 @ 12:02 AM

この投稿は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

2013 年 9 月 22 日

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

Filed under: 解析 — 中の人 @ 8:44 PM

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

というわけで今回は前回から予定を変更して番外編、お題目は再び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の解説をやりたいと思います。
ではまた!

2013 年 8 月 4 日

東方心綺楼の解析

Filed under: 東方,解析 — 中の人 @ 12:24 AM

東方心綺楼の体験版が頒布された当時自分は、東方緋想天と同様に数々のツールが作られ、単純に対戦する以上の楽しみが待っていると疑いませんでした。
しかしそれから待てど暮らせど、ツールの一つ出てくる気配はありません。
その謎は数カ月後に明かされることになります。
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

2013 年 2 月 11 日

Run-TimeErrorChecksとBufferSecurityCheck

Filed under: 解析 — 中の人 @ 11:50 PM

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(アドレス空間レイアウトランダマイズ)の解説をしようかと思います。
ではまた!

2013 年 2 月 10 日

SafeSEHとSoftwareDEP

Filed under: 解析 — 中の人 @ 11:37 PM

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 <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);
  ::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 <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;
}

やってることは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 <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 size1;
  ::fread(&size1, 4, 1, fp);
  char *s1 = reinterpret_cast<char *>(::VirtualAlloc(reinterpret_cast<void *>(0x00250000), size1, MEM_COMMIT, PAGE_READWRITE));
  ::fread(s1, 1, size1, fp);
  unsigned int size2;
  char s2[256];
  ::fread(&size2, 4, 1, fp);
  ::fread(s2, 1, size2, fp);
  for (unsigned int i = 0; i < 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攻撃とバッファーセキュリティーチェックについてでも書こうかと思います。
ではまた!

2011 年 9 月 25 日

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

Filed under: 東方,解析 — 中の人 @ 6:49 AM

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

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

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

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

2009 年 9 月 16 日

解析技術遍歴とか

Filed under: 解析 — 中の人 @ 1:20 PM

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

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

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

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

2009 年 4 月 9 日

某東方二次製作ゲームの解析過程その2

Filed under: 解析 — 中の人 @ 12:12 PM

解析2:その他ファイルの取得

音楽ファイルとは別にもう一つファイルサイズが大きいファイルが存在します。
他にファイルサイズが大きいファイルが存在しない、exeファイル自体も非常に小さい、などからこれがその他の画像ファイルや効果音などなどを内部に保持していると見て間違いなさそうです。

とりあえず前回と同じようにバイナリエディタで開いてみることにします。
kaiseki4
なにやら凄い見覚えがある並び方をしているのが見て取れると思います。
どうやら音楽ファイルと同じ形式でファイルリストを保持しているようなので、試しに前回作ったツールで展開してみることにします。

予想通り展開できました、ですが展開できたファイルを開こうとしても開くことが出来ません。
試しに出来たファイルをバイナリエディタで開いてみると
kaiseki5
0x09に見える臼NGというのがpngの識別子です(正確にはその後の4byteも含みます)
他のファイルを開いても全て最初4byteがLZSSであることと、PNGの識別子が直接確認できることからなにかしら圧縮がかかっていると推測できますね。

ここでまず冒頭4byteに書かれているLZSSについて説明します。
(正式な仕様書の類を見たわけではなく全てコードを読んだ経験からきている知識のため、一部間違えている可能性があります)
軽く検索をかければ判ることですが、LZSSというのは有名な圧縮アルゴリズムの一種で
動的に辞書を作り、距離と長さをあらわすデータに遭遇したら辞書中の距離位置のデータを長さだけ取り出して展開先データとするものです。
(詳細についてここで詳しく述べることはありませんので自分で調べてください)
このアルゴリズム自体ポピュラーなもので、割と色々な実装が行われているためLZSSだとわかっただけでは具体的な展開方法まではわかりません。
そこでまず、バイナリから可能な限り情報を引き出すことにします。

第一に、このソフト上で実装されているLZSSはバイト単位で管理されていることがわかります。
通常であればビット単位で一致不一致を示すフラグ1bit、その後一致であれば距離と長さを表すデータ、不一致であれば元データ1byteという流れになるため
バイナリエディタ上で元データを一部でも読み取れるはずがありません。
なので、一致不一致を示すbit列はどこか一箇所にまとめてあるか、byte単位で表現されていると推測されます。

次に0x08番地に存在する0xFFについて、上の画像だけではわかりませんがほぼ全てのファイルにおいてそこが0xFFで固定でした。
また、png以外にもテキストファイルを含んでいたのですが、そのテキストファイルを見る限り0xFFのあと8byteは必ず元データがそのままという流れになっているようです。
さらに0x31~0x3A番地に注目してください、ここはおそらくtSoftwareという文字列が元データだと推測できますが、間に0xFFという余分な情報が挟まれているのがわかります。

まず0x39番地の余分な1byteはそのままつなげるだけで復元可能な点から、間の0xFFが距離と長さを示す情報以外のデータだと推測できます。
LZSSにおいて他に存在しうるデータといえば、辞書サイズか一致不一致フラグのみですが
辞書サイズがデータ上に点在している可能性は非常に低いため、この1byteが一致不一致フラグであると推測できます。

次に0xFFの後必ず元データが8byte並ぶことから8つ分の不一致をあらわす情報だと仮定することが出来ます。
それは0x08番地が固定である事からも読み取ることが可能で、辞書にデータが存在しない初期の状態では必ず不一致バイトが来ることが推測できるからです。

これらのことから、一致不一致フラグを8つまとめることでbit入出力を減らして効率化しているであろうことが推測できました。
(0x9Fの場合一致5不一致2一致1となるので、元データ5byte距離長さデータが2つ最後に元データ1byteとなります)
現時点では推測に推測を重ねる形ですが、試しにそうであると仮定して0xFF以外のフラグに遭遇するまで展開するコードを書いてみれば問題なく取れているが判ります。
また、0xFF以外のフラグに遭遇した後のデータを並べてみれば、その後2byte元データとは違うデータが混じっていることがわかりました。
これはやはり一致不一致フラグを8つまとめたものであり、さらに距離と長さを示す情報は2byteであろうことも推測できます。

この時点で一致フラグを無視し、不一致分だけ全て読み出すものを作りました。
png画像に用いてもいまいち結果がわかりませんが、テキストデータはNULL文字を含まない形に展開できたためここまでは推測が当たっていることが判ります。

次に必要になってくるのが辞書の作成方法と辞書サイズです。
辞書サイズは大きく分けて固定サイズ、ファイル内に保持している、ファイルサイズから算出の三方式があります。
ですが、ファイルサイズから算出であれば距離を表現するのに使うビット数が可変になるため現在のように2byte固定はありえません、なので算出では無いでしょう。
次にファイル内に保持している可能性として0x04番地の値が思い浮かびますが、必ずファイルサイズ以上の値を保持している、距離と長さを表現するバイト数は2byteであるため辞書サイズも最大で2byteである点からおそらくは違うでしょう。
というわけで、現在は固定サイズであると仮定して先に進みます。

細かい説明は省きますが、pngはフォーマットの使用上原則的に16byte目まで固定です。
(ほぼ確実に89 50 4E 47 0D 0A 1A 0A 00 00 00 0D 49 48 44 52となる)
なので2枚目の画像の0x11~0x13番地のFE ED F0の部分については展開後の様子が推測可能になります。

まず0x11番地の0xFEは一致不一致フラグで2進数に直すと11111110となるので0x12~0x13番地の2バイトが距離と長さを示す情報です。
次に展開先のデータですが、00 00 00 0Dで次の不一致データが0Dであるため00が3byte続く形に展開されると推測できます。
ですが、そこまでの12byteを見ても00が1byteも存在しません。

LZSSには良く使われる手法の一つに、事前に登場しやすいバイト列を辞書登録しておくことで圧縮効率を上げるというものがあります。
さらにその中で一番使われているのが辞書全体を00で埋めておくというもので
今回もその例であると推測可能です。
なので、既に辞書登録されているデータの前後どちらかとなるので登録済み12byte+長さ3byte分前の指定で15、もしくは直後指定で0だと仮定できます。
他にも辞書中を相対的に参照する距離ではなく絶対座標として参照する方式もありますが、現時点では推測不可能であるため無視することにします。

次に長さですが、使用するバイト数以下の長さでは置き換えても無駄であるため
基本的に置き換え必要バイト長+1が長さの最低値となります。
なので、長さ0をこの最低値として定義することで最低値分だけ長い一致まで表現することが可能になります。
(長さに1byte割り振った場合、そのままでは長さ255までしか格納できないが最低値を3と定義すると長さ258まで格納できる)
今回の場合は置き換えに2byte使用しているので、この手法が用いられていれば最低値3になります。
つまり今回は3byteなのでそのまま3か圧縮されて0だと仮定できます。

上記のことを踏まえてED F0の部分を眺めてみます。
まず、ビット列に戻すと1110 1101 1111 0000という並びになります。
この時点でまず1が出現する最多組み合わせである15 3よりも1が多い事に気づけます。
なので、距離か長さもしくは両方の推測が外れているとわかります。

現在考えられる可能性といえば、距離が相対ではなく絶対座標製である可能性です。
私自身理由が良くわかっていないのですが、絶対座標方式の場合辞書登録の始点が0x00番地からではないことが多いです。
なので、今回の例についても格納されているであろう数値が推測できません。
(それが目的の可能性もありますが、暗号化を目的としない圧縮アルゴリズムであるため可能性は低いと考えられます)

まず、効率化の観点から距離長>長さ長になることは確実なので、今回の例で言えば少なくとも距離長に10bitは割り振られていると考えられます。
それを踏まえてどちらか該当しないかと考えれば、辛うじて後半4byteが長さを表現しているのではないかと推測できます。
この時点で距離長が12bitと推測できるため、12bitで表現しうる最大値0xFFF+1=0x1000が辞書サイズであるという仮定も成り立ちます。
またbit入出力をなくすなど演算速度を重視していることから単純に2byte目の上位4bitを1byte目の前か後につなげる形だと思われるので0xFEDか0xEDFが距離であると考えられます。

もし距離が0xFEDであるならば、登録データ前を参照していた場合は0xFED+3で始点0xFF0、後を参照していた場合は0xFED-12で始点0xFE1。
0xEDFであれば前参照で0xEE2、後参照で0xED3。
現時点まで全ての推測が正しい場合はこの4パターンのどれかで展開できることになります。

ここまで来れば後は簡単で、実際に実装してみて4パターン全て試してみることにします。
格納されていたファイル自体多いので全て同じように調べて回ればどれか一つに絞り込めるかもしれませんが、実際検証した方が早いのでコードを組んでみることに。

結果としてみごと辞書始点0xFF0で展開に成功しました。


上の文章だけ読むと適当な推測を積み重ねていったら運良く正解にたどり着けただけのように思えるかもしれませんが、実際そのとおりです。
ただし上記の10倍程度は推測を重ね、その全てで失敗した結果この結論にたどり着いた形になります。

一例を挙げるならば、過去に登場していない0x00の3byteが圧縮されているという部分
ここで私は最初LZSSだけではなくランレングス圧縮も併用されており、距離と長さデータだと思っていた部分のうち1bitをフラグにして一部ランレングスとして機能しているのではないか?と考えました。
上記のとおりそんなことは無かったのですが、最初はそうとしか思えずランレングスの実装例をいくつもあさったものです。

結果として、本来であればデバッガーやメモリーエディターの力を借りておそらく3時間程度で済む解析に丸2日かけた計算になります。
やはりデバッガーは偉大だなと思いつつ、ランレングスやLZSSについてそれなりの知識を得られたので悪い試みではなかったと思っています。
もう一度やりたくはないですが……

ちなみに、今回と前回であげた例は非常に簡単な部類です。
本来であればバイナリエディタで見た時点でまっとうに意味を理解できる文字列が存在していること自体珍しく、デバッガーの力なしでは解析の足がかりにさえたどり着けないことも多いです。
最終的には上記と同じ道筋を辿るとはいえ、一般的な解析ではまず砂漠から一本の針を探すような途方もない作業が待っており、上記解析過程にたどり着いた時点で勝ちといっても過言ではありません。
そういう意味では、今回の記事は解析過程と銘打つには少々的外れな内容だったのかもしれませんね。

というわけで、解析過程の解説記事はこれで終了となります。
もしこのような奇特な記事を最後まで読んだ方がいるならありがとう、そしてお疲れ様でした。

2009 年 4 月 7 日

某東方二次製作ゲームの解析過程

Filed under: 解析 — 中の人 @ 10:39 AM

開発滞りまくってそろそろ罵声飛んできそうな状態ですが
火打石的依頼が舞い込んできたので、その過程を解説風に書いてみるテスト。

概要:とあるソフトの解析依頼
目的:ソフト内で使用されているデータの切り出し(可能であれば一般形式への変換も)
制限:ソフトの立ち上げ禁止

三つ目はWeb認証があるとかで、変なことされてネット対戦出来なくなると困るからだとか
起動もしないで解析とか無茶言うなって話ですが、考えてみれば地霊殿の解析はバイナリエディターだけだったなということで試しにやってみることに。


解析1:BGMファイルの取得

BGMというのはファイル容量が大きいためメモリー上に保持し辛く、遅延がダイレクトに影響を及ぼすため
大抵はその他ファイルとは別に展開が高速なフォーマットでアーカイブされていることが多いです。

今回のソフトもその例に漏れず、.musという判りやすい拡張子でどでかいファイルを保持していました。
本来ならこのファイルを撤去した上で起動し音がならないか試すのですが、今回は起動禁止なので音楽ファイルと決め打つことにします。

とりあえず先頭を少量切り出してきてバイナリエディタで開きます(容量が大きいため、そのままだと固まる)
するとこんな感じ(一部実際の内容から変えてあります)
kaiseki1
oggという音楽ファイルの拡張子が見えるので間違ってはいない様子。

おそらくは最初のPACKが識別子、その後80byte区切りでファイル情報らしきものが仕舞われているのが見て取れます。
問題はPACKから最初のファイル名までの4byteが一つ目の音楽ファイルに関する情報なのか、このファイル全体に関係する情報なのか判別付かない点ですが
二個目以降のファイル名の前4byteを見てみると数字の傾向が違うことが見て取れるので、今はとりあえずファイル全体に関連する情報だと思っておくことにします。

次にこの80byteが具体的にどういう構造をしているのか調べる作業に入ります。
まず膨大な0の山と、ファイル名~0で埋まっている部分を合計するとどれも64byteであることから
このフォーマットにおいてファイル名は64byteの決めうちであることが判ります。
残り16byteも断定したいところですが、今はまだどこで区切ればいいのかも判然としないので後回しにします。

ここで少し話がそれますが、一般にこういったアーカイブ形式の場合
ファイルリストと一緒にファイルの個数、もしくはファイルリストのサイズがついてくるか
ファイルリストがリスト構造になっており、終端まで辿ることで個数を判別できる仕様になっていることがほとんどです。

ですが、今はまだそれらしき情報を見つけていません、そこでこのファイルにいくつファイルが格納されているのか調べることにします。
その結果41個入っているらしいことが判りました。16進数になおすと0x29となります。
そう、先ほど後回しにしたPACKの後の4byteも同じく0x29でビンゴです。これがファイル個数だと思っていいでしょう。

念のためリスト構造の可能性を考えて内容不明の16byteから次のファイル名情報を指していそうな絶対or相対アドレスを探しますが見つかりません。
これはデータそのものに関するデータで、ファイルリスト自体に関連する情報ではないようです。

本来はここで16byteを全て切り出し1/2/4byteごとに区切ってみて特異点がないか探す作業になるのですが、流石に面倒くさいので別方向からアプローチすることにします。
kaiseki2

これは適当に手元にあったwavをoggエンコードした物をバイナリエディタで開いた画像です。
先頭4byteのOggSはoggフォーマットであることを示す識別子で、oggフォーマットのファイルであれば全て先頭4byteはこうなっています。
今回は既にファイル名からoggファイルが格納されていることはわかっているので、ためしにOggSで検索かけてみることにします、すると……
kaiseki3

見事にヒットしました、その後の流れを見比べてもoggの生データであると見て間違いなさそうです。
さらにこのoggファイルのアドレスをよく見ると面白いことがわかります。
一枚目の画像をよく見てください、二個目のファイル名の前8byteの所に0x00000D00と格納されているのが見て取れます。
試しにそのほかのファイル名の前8byteが指すアドレスの部分を見てみても全てOggSの識別子に行き当たります。
つまりこれがファイル格納位置だということですね。

残るはファイルサイズですが、ファイル位置がわかっているため次のファイル位置を元におおよそのサイズを割り出すことが出来ます。
今回の例で言えば二つ目のファイル位置が0x0005E700であるため、0x5E700-0xD00で0x5DA00以下であることがわかります。
ちょうど一つ目のファイル位置の次4byteに非常に近い数値が格納されているのがわかりますね。
念のため他のファイル情報も計算して見比べてみましたが全て非常に近い数値を取っていることがわかります。
つまり、これがファイルサイズということになります。

試しにバイナリエディタを使って手動で切り出してみましたが問題なく再生されました。
どうやらこれで問題ないようです、やったね!

ここまでをまとめると
4byte:PACK
4byte:格納されたファイル個数
ここからファイルリスト
64byte:ファイル名
8byte:不明
4byteファイル格納位置
4byte:ファイルサイズ
となります。

不明の8byteが気になりますが、自分にはわからなかったため気にしないことにします。
そんな適当でいいのかと思う人もいるかもしれませんが、解析の世界では割と普通。
判らないならとりあえず無視して、あとで食い違うことがあったらそのときに引っ張り出して繰ればいいのです。

ちなみに、上記の結果を基にした切り出しツールはCUIで余分な機能を色々つけても100行の1800文字で収まります。
下手をすればC言語始めて一週間の人でも書けちゃうレベル。

解析1と銘打ってあるとおりこのあと画像やら効果音も切り出していますが、そっちはまだ現在進行形なので終わった頃にでも

Powered by WordPress