MinHookの仕組みと実装:インラインフック技術の深掘り

MinHookは、Windows環境で動作する軽量なx86/x64用インラインフックライブラリです。その内部構造を理解することで、バイナリレベルでのコード置換やプログラムの実行フロー制御に関する深い知識を得ることができます。

1. 動作の基本原理

MinHookによるフックの基本的な流れは以下の通りです:

  1. フック対象となる関数(ターゲット関数)のアドレスを特定する。
  2. ターゲット関数の先頭数バイト(通常は5バイト)を、独自の関数(デトア関数/FakeFunc)へのジャンプ命令に書き換える。
  3. デトア関数の実行後、元の処理を続行するために「トランポリン(Memory Slot)」を経由して、退避しておいた元の命令を実行し、ターゲット関数の残りの処理へ戻る。

アーキテクチャごとの差異

  • x86: ユーザー空間が2GBに制限されており、E9命令(相対ジャンプ)によって1回のジャンプでほぼどこへでも到達可能です。
  • x64: 128TBという広大なアドレス空間を持つため、5バイトの相対ジャンプ(E9)ではデトア関数に届かない場合があります。そのため、ターゲット関数の近傍(±2GB以内)に「リレー(Relay)」用のメモリ領域を確保し、そこからFF 25(絶対ジャンプ)を用いてデトア関数へ遷移する手法が取られます。

2. 主要なデータ構造

MinHookの管理情報を保持する構造体を、意図を維持しつつ再定義します。

typedef struct _HOOK_DESCRIPTOR
{
    LPVOID pTargetAddress;     // フック対象関数のアドレス
    LPVOID pDetourAddress;     // ユーザー定義の代替関数アドレス
    LPVOID pTrampolineAddress; // 元の命令+復帰ジャンプを格納する領域
    UINT8  originalData[8];    // バックアップ用データ

    struct {
        UINT8 isHotPatch : 1;  // ホットパッチ領域を利用するか
        UINT8 isEnabled  : 1;  // フックが有効か
    } flags;

    UINT   boundaryCount;      // 命令境界の数
    UINT8  instructionOffsets[8]; // 命令の境界オフセット
} HOOK_DESCRIPTOR, *PHOOK_DESCRIPTOR;

typedef struct _TRAMPOLINE_INFO
{
    LPVOID pTarget;
    LPVOID pDetour;
    LPVOID pTrampoline;

#if defined(_M_X64) || defined(__x86_64__)
    LPVOID pRelayBuffer;       // x64用の中継アドレス
#endif
    BOOL   useHotPatch;
    UINT   count;
    UINT8  oldIPs[8];          // オリジナル側の命令境界
    UINT8  newIPs[8];          // トランポリン側の命令境界
} TRAMPOLINE_INFO, *PTRAMPOLINE_INFO;

3. 対応する関数形式と命令解析

MinHookは単なる命令の上書きではなく、ターゲット関数の先頭命令に応じた高度な解析を行います。

一般的な関数

トランポリン領域から元の関数の続きへ戻るためのオフセットを計算し、JMP命令を生成します。

JMP命令で始まる関数

  • E9 (相対JMP): ジャンプ先の絶対アドレスを計算し、自身のトランポリン内に再構築します。
  • EB (短距離JMP): ジャンプ先がフック範囲内(5バイト以内)か、範囲外かを確認し、適切に命令を書き換えます。

CALL命令 (E8) で始まる関数

CALL命令のジャンプ先を再計算してトランポリンに配置します。また、CALLは戻りアドレスをスタックに積むため、トランポリン実行後に元の関数の「次の命令」へ正しく戻るよう制御します。

JCC命令 (条件付きジャンプ)

条件分岐の成立・不成立に関わらず正しいアドレスへ遷移できるよう、条件を反転させたり、ジャンプ先アドレスを再計算したJCC命令をトランポリン内に配置します。

ホットパッチ (Hot Patching)

関数の先頭が「MOV EDI, EDI」(2バイト)で始まっており、その直前に十分な空き領域がある場合、この領域を利用してスレッドセーフなフックを実現します。

4. 実装における重要なトピック

ジャンプオフセットの計算

相対ジャンプのオフセット計算式は以下の通りです:
Offset = 遷移先アドレス - (現在のアドレス + 命令サイズ)

x64におけるメモリ管理

x64では相対ジャンプの範囲制限を回避するため、VirtualAllocを用いてターゲット関数のアドレスから±2GB以内の位置にトランポリン用のメモリを確保する必要があります。この際、割り当て可能な範囲を探索するロジックが組み込まれています。

シェルコードの構造

各命令のバイナリ表現は以下のようになります:

// x64 絶対ジャンプ (14 bytes)
BYTE jmp_abs_x64[] = {
    0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, // JMP [RIP+0]
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 // 64bit Address
};

// x86/x64 相対ジャンプ (5 bytes)
BYTE jmp_rel[] = {
    0xE9,                               // JMP rel32
    0x00, 0x00, 0x00, 0x00              // 32bit Offset
};

スレッド同期とスピンロック

フックの有効化・無効化を安全に行うため、スピンロックを用いた排他制御が行われます。

static VOID AcquireLock(VOID)
{
    LONG spinThreshold = 0;
    while (InterlockedCompareExchange(&g_lockFlag, TRUE, FALSE) != FALSE)
    {
        if (spinThreshold < 40)
            Sleep(0); // 優先度の高いスレッドに譲る
        else
            Sleep(1); // 強制的にコンテキストスイッチ
        spinThreshold++;
    }
}

命令キャッシュのフラッシュ

メモリ上のコードを書き換えた後は、CPUが古い命令(キャッシュされているもの)を実行しないように、FlushInstructionCacheを呼び出してキャッシュを無効化する必要があります。

実行コンテキストの調整

フックを適用する瞬間に、他のスレッドがまさにその書き換え対象の命令を実行している可能性があります。MinHookは全スレッドを一時停止し、各スレッドのEIP/RIPをチェックします。もし書き換え範囲内にIPがある場合、それをトランポリン内の適切な位置へ修正することで、クラッシュを防止します。

タグ: Windows-API C++ Hooking x86-64 reverse-engineering

8月15日 00:11 投稿