x64インラインフッキング 改


前置き

前回、機械語を直接CodeCaveに打ち込んで、そっちにジャンプさせていたので、フック後の処理内容を機械語で書かないといけなかったが、今回は、InjectするDLLの方に関数を定義し、そこに飛ばすことによってフック後の処理をC++でかけるようにした。


フック対象のプログラム

前回と変わらず、以下のsample.cをgccでコンパイルした sample.exe が対象。1秒毎に "hello" と表示するだけのプログラム。

sample.c
#include <stdio.h>
#include <windows.h>

int main(void) {
    while(1) {
        printf("hello\n");
        Sleep(1000);
    }
    return 0;
}


仕組み


sample.exe に、DLL Inject してフックする。
sample.exeputssleepcall myfunc に置き換え、DLLの方に定義した myfunc(MessageBox表示するだけの関数) を呼び出すようにする。call を使うので、呼び出す際はリターンアドレスがスタックに入るので、戻るときは ret をするれば元の場所に戻る。


やり方

VisualStudioでDLLのプロジェクト立ち上げ、以下をdllmain.cppにコピペする。

dllmain.cpp
#include "pch.h"
#include <Windows.h>

typedef unsigned __int64 QWORD;

void __stdcall myfunc() {
    MessageBox(NULL, TEXT("detoured!!"), TEXT("success"), MB_OK | MB_ICONEXCLAMATION);
    return;
}

bool Hook(void* target, void* myfunc, int len) {
    if (len < 12)
        return false;

    DWORD curProtection;
    VirtualProtect(target, len, PAGE_EXECUTE_READWRITE, &curProtection);
    memset(target, 0x90, len);

    // このデバッグ出力の部分は後述
    // char buf[256];
    // snprintf(buf, 256, "myfunc=%p", myfunc);
    // OutputDebugString(buf);

    *(BYTE*)target = 0x48;
    *(BYTE*)((DWORD)target + 1) = 0xb8;
    *(QWORD*)((QWORD)target + 2) = (QWORD)myfunc; // mov rax, <myfuncAddr>
    *(BYTE*)((DWORD)target + 10) = 0xff;
    *(BYTE*)((DWORD)target + 11) = 0xd0;          // call rax


    DWORD temp;
    VirtualProtect(target, len, curProtection, &temp);

    return true;
}


DWORD WINAPI dothread(LPVOID param) {
    int hookLength = 19;
    DWORD hookAddr = 0x00401564;

    if (Hook((void*)hookAddr, myfunc, hookLength))
        MessageBox(NULL, TEXT("hooked"), TEXT("info"), MB_OK);

    while (1) {
        if (GetAsyncKeyState(VK_ESCAPE))
            break;
        Sleep(50);
    }

    FreeLibraryAndExitThread((HMODULE)param, 0);
    return 0;
}


BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
    if (ul_reason_for_call == DLL_PROCESS_ATTACH)
        CreateThread(0, 0, dothread, hModule, 0, 0);
    return TRUE;
}

注目する部分は以下の部分。

*(BYTE*)target = 0x48;
*(BYTE*)((DWORD)target + 1) = 0xb8;
*(QWORD*)((QWORD)target + 2) = (QWORD)myfunc; // mov rax, <myfuncAddr>
*(BYTE*)((DWORD)target + 10) = 0xff;
*(BYTE*)((DWORD)target + 11) = 0xd0;          // call rax

ここで言う、target には実際にフックしたい場所のアドレス(今回はsample.exeの call puts があるアドレス)が入っていて、これを call myfunc になるようにしている。
しかし、これは x64 の環境でやっていて、x64のDLLとしてビルドしているので、アドレスが8Byte(64bit)になる場合があるが、実は4Byte(32bit)の DWORD は定義されてるが、8Byteの QWORD は定義されていないので、typedef unsigned __int64 QWORD; をする必要がある。

さらに、なぜ一回 rax にジャンプ先アドレスを格納してるかというと、call <64bitの絶対アドレス> 用の call のオプコードが無いため、このようにしている。
(64bitのアドレスのcallのテンプレとして覚えておくと便利かも)


実行してみて挙動確認

実際にでは "./sample.exe" を実行してみて、上記DLLをインジェクトしてみる。

このように、何回も上図のようなMessageBoxが表示されればいい。


x64dbgで上記コードの意味を確認

実際にではデバッガで処理を追って、上記のフックのコードの意味を確認する。ざっくりと天順は以下のような感じ。

  1. ./sample.exe でプログラム起動
  2. x64dbgを起動して、上記プロセスにアタッチする
  3. x64dbgで call puts の所にブレークポイント打ってそこで止めておく
  4. DLL Injection を実行して先ほどのDLLをInject
  5. x64dbgでブレークポイント残したままとりあえずループ一回分実行させる
  6. 5の後コードが書き換わっているので、そこからステップ実行とかして挙動確かめる

まず、3までやってみると、

このように、./sample.exe は本来は上記のようなアセンブリになっていて、DWORD hookAddr = 0x00401564 としたように、0x00401564call puts をしている。今回はこのアドレスから19バイト分、つまり、

call <JMP.&puts>
mov ecx, 3E8
mov rax, qword ptr ds:[<&Sleep>]
call rax

を、書き換えるような処理になっていて、以下のようにInject後なるはずである。

mov rax, <myfuncAddr>
call rax

では実際に手順5までやってみると、

インジェクトしたDLLのように、先ほどの部分が上図のように書き換わるはずである。
(ちなみに、ブレークポイントで処理を止めているので、DLL Injectした瞬間には書き換わらないが、一周ループされるとInjectされた状態になる)

では最後の手順6の通り、call rax をした直後に、ちゃんと myfunc に飛ぶか見てみる。

こんな感じでおそらくインポートテーブルを参照しに行き、そのままもう一回ステップ実行すると、

うまく行ってればこのように myfunc の中に飛ぶ。あとは ret でちゃんと元の位置に戻るかだが、このままステップ実行して一番最後の ret の直後に行ってみると、

ちょうど call rax した直後に来るはず。これで、最後の jmp sample.40155D でループで先頭に戻り、何度も何度もクラッシュせずに MessageBox が表示されればうまくいってる。


デバッグ方法(上手くいかなかった場合)

鬼門が myfunc へ飛ばないという所だと思うが、そもそもじゃあ myfunc がどのアドレスにあるのかを調べる方法が以下。

まず、VisualStudioだと printf とかがなぜか使えないので、DebugViewというものをダウンロードして適当なディレクトリに置いておく。
ダウンロードした後、DbgView.exe を実行して置けば、他のプログラムとかで OutputDebugString(TEXT("hoge")) のように呼び出した際に、それを拾って表示してくれるようになる。

ダウンロードして起動出来たら、ソースコードの方でこの部分のコメントアウトを外す。

// このデバッグ出力の部分は後述
char buf[256];
snprintf(buf, 256, "myfunc=%p", myfunc);
OutputDebugString(buf);

これで実際に先ほどの手順の5までやってみると、

関係ない出力とかもあるが、myfunc=00007FFD5BAB1294 と表示されていることがわかり、ここにちゃんと call でジャンプできているかどうかを確認すればいいことがわかる。