mov rax, imm64; jmp raxによる x86-64 メソッドエントリ Hook 実験
通常であれば、DateTime.Now は素直に現在時刻を返します。
しかし、次のプログラムを実行すると、ある瞬間から「現在時刻」は西暦 9999 年 9 月 9 日になります。
DateTime.Now before hook: 2026-05-15T21:25:38.1964236+09:00
DateTime.Now while hooked: 9999-09-09T00:00:00.0000000
DateTime.Now after hook: 2026-05-15T21:25:38.2074279+09:00
面白いのは、DateTime.Now を呼び出している C# コードを書き換えていないことです。
もちろん、DateTime 自体を再コンパイルしているわけでもありません。
やっていることは、そのメソッドが本当に実行される直前に、メソッド入口の 12 バイトを書き換えるだけです。
この記事では、x86-64 における最小の核心部分だけを扱います。
つまり、JIT によって機械語になった .NET メソッドの入口を、別のメソッドへ向かうジャンプに変える方法です。
DateTime.Nowが Hook の前後でどのように変化するかを確認するための実行結果。
まず結論から
今回の実験で書き換えているのは、呼び出し側ではありません。
書き換えているのは、呼び出される側、つまり対象メソッド自身の入口です。
元の呼び出し関係は次のようになります。
呼び出し側
↓
DateTime.Now の native entry
↓
DateTime.Now の元のメソッド本体
Hook を有効にすると、入口は次のように書き換わります。
呼び出し側
↓
DateTime.Now の native entry
↓
mov rax, target
jmp rax
↓
ReplacementNow のメソッド本体
つまり、DateTime.Now を呼び出しているすべての場所を探して、それぞれの call 命令を差し替えているわけではありません。
変更しているのは、DateTime.Now 自身の「玄関」です。
後続の呼び出しが最終的にこの入口へ到達する限り、CPU が最初に見るのは元のメソッド本体ではなく、「別の場所へ行け」というジャンプ命令になります。
before / after の呼び出しフロー図。左は複数の呼び出し側が同じメソッド入口へ集まる図、右は入口が書き換えられて代替メソッドへ流れる図。
IL を書き換えるのではなく、JIT 後の native entry を書き換える
C# のレイヤーだけで考えると、この仕組みはかなり不思議に見えます。
しかし、.NET のメソッドも実際に実行されるときには、最終的に JIT が生成したネイティブコードへ落ちます。
MethodHook のコンストラクタでは、いきなりパッチを書き込むのではなく、まず source と target の両方のメソッドを準備します。
RuntimeHelpers.PrepareMethod(source.MethodHandle);
RuntimeHelpers.PrepareMethod(target.MethodHandle);
ここでの source と target は MethodInfo です。
PrepareMethod が受け取るのは RuntimeMethodHandle なので、MethodHandle を渡します。
次に、それぞれの実行可能な入口を取得します。
SourceStubPtr = GetRawEntry(source);
TargetPtr = ResolveRealEntry(GetRawEntry(target));
SourceBodyPtr = ResolveRealEntry(SourceStubPtr);
ここには、見落としやすい現実的な問題があります。
GetFunctionPointer() が返すアドレスは、必ずしも私たちが直感的に想像する「メソッド本体の最初の命令」とは限りません。実際のランタイムでは、precode、stub、あるいは何らかのジャンプ用の中継地点を指している場合があります。
そのため x86-64 の分岐では、ResolveRealEntry がいくつかの典型的なジャンプ形式を識別します。
E9 rel32
FF 25 rip+disp32
48 B8 imm64
FF E0
これは、.NET が安定した native ABI としてこの形を公開しているからではありません。
あくまでランタイム実験として、よりパッチを書き込みやすい位置へできるだけ近づくための処理です。
source メソッドに対して ResolveRealEntry を行う目的は、実際に上書きする入口位置を見つけることです。
target メソッドに対して既存のジャンプを解析するのは、パッチ先をより直接的な場所にするための実装上の方針です。ただし、これはこの記事の実装で採用している戦略であり、すべての Hook 実装で必須というわけではありません。
言い換えると、Hook の第一歩は「jmp を書けるかどうか」ではありません。
その前に、「その jmp をどこへ書くべきか」を見極める必要があります。
GetFunctionPointer()が stub を返し、そこから実際の body へ解決していくイメージ図。
主役はこの 12 バイト
source メソッドの入口が見つかれば、x86-64 で書き込むパッチはとても直接的です。
MethodHook では、x64 の patch 長を 12 としています。
Arch.X64 => 12, // mov rax, imm64; jmp rax
実際に書き込むバイト列は次のとおりです。
Span<byte> buf = stackalloc byte[12];
// 48 B8 imm64 FF E0
buf[0] = 0x48;
buf[1] = 0xB8;
BitConverter.GetBytes(to.ToInt64()).CopyTo(buf[2..10]);
buf[10] = 0xFF;
buf[11] = 0xE0;
アセンブリとして見ると、次の 2 命令になります。
48 B8 <imm64> mov rax, target
FF E0 jmp rax
意味は単純です。
- まず target メソッドの絶対アドレスを
raxに入れる - 次に
raxが指すアドレスへ無条件ジャンプする
この瞬間から、CPU が source メソッドの入口へ入ったとき、最初に見るものは元のメソッドプロローグではありません。
そこにあるのは、「すぐに target メソッドへ移動せよ」というジャンプ命令です。
48 B8/ 8 バイトの target アドレス /FF E0のブロックに分けると分かりやすいです。
なぜ 5 バイトの相対ジャンプを使わないのか
目的がジャンプだけなら、もっと短い命令を使えばよいのではないか、と思うかもしれません。
x86-64 では、よく使われる近距離ジャンプとして次の形式があります。
E9 <rel32>
これは 5 バイトで済みます。
しかし、ジャンプ先は完全な 64 ビット絶対アドレスではなく、32 ビットの相対オフセットです。
つまり、ジャンプ先は現在の命令位置からおよそ ±2GB の範囲内に収まっている必要があります。
一方で、JIT が生成する 2 つのメソッドのアドレスが、常にそれほど近いとは限りません。
異なるメソッドが別々のコード領域に配置されることもありますし、ランタイムのバージョン、プラットフォーム、プロセスのメモリ配置によっても実際のアドレスは変わります。
そのため、この記事では E9 rel32 の相対ジャンプには踏み込みません。
代わりに、より直感的な 12 バイトの絶対ジャンプに統一します。
mov rax, imm64
jmp rax
こちらは数バイト長くなりますが、完全な 64 ビットの target アドレスをそのまま扱えます。
これが、MethodHook が x64 で固定 12 バイトを上書きする理由です。
jmp は何を変えるのか
Hook を初めて見ると、「一度元のメソッドに入ってから、途中で処理が奪われる」と想像しがちです。
しかし、ここで起きていることは違います。
jmp が行うことは非常に単純です。
call のように新しい戻りアドレスをスタックへ積むことはありません。
追加の呼び出し階層を作ることもありません。
ただ、次に実行する命令の位置を別のアドレスへ変えるだけです。
今回の制御フローは次のようになります。
呼び出し側
↓ call
source メソッド入口
↓ jmp
target メソッド本体
↓ ret
呼び出し側
source メソッドは「途中まで実行されたあとに乗っ取られる」のではありません。
本当に始まる前に、別の場所へ送られているだけです。
これが Method Detour という名前にしっくり来る理由です。
元の道の途中に処理を差し込むのではなく、入口で車の流れを別の道へ誘導しているわけです。
callとjmpの制御フロー比較図。
入口を上書きする前に、元のバイト列を保存する
一度きりの実験であれば、直接パッチを書き込むだけでも動くかもしれません。
しかし、実用的な Hook には最低限もう 1 つ必要な性質があります。
それは、元に戻せることです。
そのため、ジャンプを書き込む前に、MethodHook はこれから上書きされるバイト列を保存しておきます。
_patchLenBody = PatchLengthFor(_arch);
_backupBody = new byte[_patchLenBody];
Marshal.Copy(SourceBodyPtr, _backupBody, 0, _patchLenBody);
Hook を有効にするときは、元の入口を上書きします。
WriteJump(SourceBodyPtr, TargetPtr, _arch);
Hook を無効にするときは、保存しておいたバイト列を書き戻します。
RestoreBytes(SourceBodyPtr, _backupBody, _arch);
これは、建物そのものを壊して作り直すのではなく、一時的に玄関の案内板を書き換えているようなものです。
元のメソッド本体は、消えていません。
Hook が有効な間だけ、入口の先頭 12 バイトが元の内容ではなく、target へのジャンプになっているだけです。
機械語は好き勝手に書き込めるわけではない
ここまでで、何を書けばよいのか、どこへ書けばよいのかは見えてきました。
しかし、実際にはもう 1 つ現実的な壁があります。
コードページは通常、書き込み不可です。
そのため、12 バイトのパッチを書き込む前に、対象のメモリページへ一時的な書き込み権限を付与する必要があります。
この実装では、その処理を PageWriteScope にまとめています。
using (new PageWriteScope(from, PatchLengthFor(arch)))
{
// ジャンプ命令を書き込む
}
x86-64 に限って言えば、プラットフォームごとの処理は次のようになります。
- Windows では
VirtualProtect - Linux / macOS では
mprotect
この部分は jmp ほど派手ではありません。
しかし、パッチを実際に反映させるためには欠かせない処理です。
何を書けばよいか分かっていても、どこへ書けばよいか分かっていても、そのメモリが書き込みを許可していなければ、何も変更できません。
パッチ書き込み手順のフロー図。入口を探す → 元のバイトを保存 → 書き込み権限を開く → ジャンプを書き込む → 命令キャッシュを更新 / 権限を戻す。
なぜ trampoline が出てこないのか
inline hook の話をすると、多くの資料ではすぐに trampoline が登場します。
しかし、この記事では扱いません。
理由は、この demo の目的がもっと単純だからです。
ここでやりたいのは、source メソッド全体を target メソッドへ改道することだけです。
もし次のようなことをしたいなら、trampoline が必要になります。
- まず hook 用の処理に入る
- 上書きしてしまった元の命令を実行する
- その後、元のメソッドの残りへ戻る
しかし trampoline に入ると、問題は一気に複雑になります。
- 上書きされた命令を別の場所へ移す必要がある
- 相対アドレス指定の命令を修正する必要がある
- 元の制御フローへ正しく戻す必要がある
これは非常に面白いテーマですが、別の記事で扱うべき内容です。
この記事では、最も核心的で、かつ最もきれいな 1 点だけに集中します。
あるメソッドの入口を、別のメソッドの入口へ向かうように書き換える。
自分の環境でたまたま動いただけではない
このコードが「作者の PC でたまたま動いているだけ」のデモにならないように、最小構成のリポジトリも用意しました。
dotnet-method-detour-demoは、ZYC.CoreToolkit.Hook.MethodHookを使った Method Detour の最小クロスプラットフォームデモです。
リポジトリには、最も直接的な 2 つの例だけを残しています。
-
DateTime.Nowを固定時刻へ改道する -
Guid.NewGuid()を固定 Guid へ改道する
また、GitHub Actions で smoke test を実行し、この挙動を繰り返し検証できるようにしています。
この記事では、x86-64 における最も重要なパッチ経路だけを分解しました。
リポジトリの意味は、記事中の最小原理を実際に動くコードとして確認できるようにすることです。
読者は、機械語の説明を眺めるだけでなく、clone して、実行して、自分の環境で検証できます。
最初の問いに戻る
ここで、冒頭の問いに戻れます。
なぜ DateTime.Now は突然、西暦 9999 年を返すようになったのでしょうか。
DateTime の実装を再コンパイルしたからではありません。
DateTime.Now を呼んでいるすべての場所を書き換えたからでもありません。
本当の理由は、そのメソッドが実行される直前に、入口の機械語が次の命令へ差し替えられていたからです。
mov rax, target
jmp rax
Hook 技術は、初めて触れると黒魔術のように見えることがあります。
しかし抽象を 1 枚ずつ剥がしていくと、中心にある考え方は驚くほど素朴です。
入口を見つける。バイト列を書き換える。制御フローを変える。
今回すべてを変えたのは、たった 12 バイトでした。





