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 <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攻撃とバッファーセキュリティーチェックについてでも書こうかと思います。
ではまた!