エクスプロイトを書きつつ学ぶ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の解説をやりたいと思います。
ではまた!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です