概述:Detours 的说明和简单使用
Detours
Detours 是微软提供的一套工具,主要用于win32 API的拦截
仓库中有挺多示例程序可供参考。
使用 Detours 的大致流程:
在 Detours 的工作过程中,
DetourTransactionBegin函数首先用于启动一个截获(Hook)或解除截获(UnHook)的过程,这个过程中可能包含对多个函数的修改。然后,开发者可以使用DetourAttach或DetourAttachEx函数将 Detour 钩子附加到目标函数上,或使用DetourDetach函数将钩子从目标函数中分离出来。
相关函数说明
DetourAttach 函数说明
DetourAttach 是最常用的附加函数,将 Detour 钩子附加到目标函数上。
LONG DetourAttach(
_Inout_ PVOID *ppPointer, // 指向目标函数指针的指针(会被修改为指向 trampoline)
_In_ PVOID pDetour // Detour 函数地址
);ppPointer:传入时指向目标函数原地址,成功后会被修改为指向 trampoline(跳板代码),可在 Detour 函数中通过它调用原始函数pDetour:你的 Hook 函数地址,签名必须与目标函数一致- 返回值:
NO_ERROR表示成功
⚠️
DetourAttach内部使用默认参数(5 字节跳转),对大多数 x86/x64 函数已足够。
DetourAttachEx 函数说明
DetourAttachEx 是 DetourAttach 的扩展版本,提供了对 Hook 过程更精细的控制,允许获取额外的诊断信息和控制桩(stub)的大小。
函数原型
LONG DetourAttachEx(
_Inout_ PVOID *ppPointer, // 指向目标函数指针的指针
_In_ PVOID pDetour, // Detour 函数地址
_Out_ PDETOUR_TRAMPOLINE *ppRealTrampoline, // 接收 trampoline 可执行代码的指针
_Out_ PVOID *ppRealTarget, // 接收目标函数原始入口点
_Out_ PVOID *ppDetour // 接收 detour 函数入口点
);参数详解
| 参数 | 方向 | 说明 |
|---|---|---|
ppPointer | In/Out | 与 DetourAttach 相同。传入目标函数地址指针,成功后被修改为指向 trampoline(可用于调用原函数) |
pDetour | In | 你的 Hook 函数地址 |
ppRealTrampoline | Out | 接收 trampoline 可执行代码块的指针。trampoline 是 Detours 动态分配的一段可执行内存,包含被覆盖的原始指令 + 跳转回原函数剩余部分的代码。这个指针可以用于直接执行 trampoline 代码,而无需通过 ppPointer 间接调用 |
ppRealTarget | Out | 接收目标函数的 原始入口地址(即 Hook 前的函数起始地址)。这个值在 Hook 前后不变,始终指向原始函数的入口点 |
ppDetour | Out | 接收 Detour 函数的 入口地址。这个值在 Hook 前后不变,始终指向你的 Hook 函数入口 |
返回值
NO_ERROR(0):成功ERROR_INVALID_BLOCK:目标函数代码段过短,无法放置跳转指令ERROR_INVALID_DATA:目标函数已有其他 Hook 或代码格式不支持- 其他 Win32 错误码
与 DetourAttach 的区别
| 特性 | DetourAttach | DetourAttachEx |
|---|---|---|
| 基本附加功能 | ✅ | ✅ |
| 获取 trampoline 代码指针 | ❌ | ✅ ppRealTrampoline |
| 获取原始入口地址 | ❌ | ✅ ppRealTarget |
| 获取 Detour 入口地址 | ❌ | ✅ ppDetour |
| 诊断/调试支持 | 有限 | 完整 |
使用场景
- 调试与诊断:获取 trampoline 和原始入口地址,用于验证 Hook 是否正确安装
- 多层 Hook 链:当你需要在多个 Hook 之间手动跳转时,
ppRealTrampoline提供了直接访问 trampoline 代码的能力 - Hook 验证:对比
ppRealTarget和ppPointer的值,确认 Hook 已正确生效 - 安全工具开发:在安全监控场景中,需要精确追踪每个 Hook 的安装状态和位置
使用示例
#include <iostream>
#include <Windows.h>
#include "detours.h"
#pragma comment(lib, "detours.lib")
using namespace std;
// 原函数指针
void (*OldGetLocalTime)(LPSYSTEMTIME) = GetLocalTime;
// Hook 函数
void NewGetLocalTime(LPSYSTEMTIME lpSystemTime) {
OldGetLocalTime(lpSystemTime);
lpSystemTime->wMinute = 52;
}
void StartHookEx() {
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
// 扩展参数
PDETOUR_TRAMPOLINE pRealTrampoline = nullptr;
PVOID pRealTarget = nullptr;
PVOID pDetourEntry = nullptr;
LONG hr = DetourAttachEx(
&(PVOID&)OldGetLocalTime,
NewGetLocalTime,
&pRealTrampoline,
&pRealTarget,
&pDetourEntry
);
if (hr != NO_ERROR) {
wprintf(L"DetourAttachEx failed: %d\n", hr);
DetourTransactionAbort();
return;
}
LONG commitHR = DetourTransactionCommit();
if (commitHR != NO_ERROR) {
wprintf(L"Transaction commit failed: %d\n", commitHR);
return;
}
// 输出诊断信息
wprintf(L"[Hook 诊断信息]\n");
wprintf(L" Trampoline 地址: %p\n", pRealTrampoline);
wprintf(L" 原始入口地址: %p\n", pRealTarget);
wprintf(L" Detour 入口地址: %p\n", pDetourEntry);
wprintf(L" OldGetLocalTime: %p (指向 trampoline)\n", OldGetLocalTime);
}
void EndHookEx() {
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourDetach(&(PVOID&)OldGetLocalTime, NewGetLocalTime);
DetourTransactionCommit();
}
int main() {
SYSTEMTIME time1, time2;
GetLocalTime(&time1);
wprintf(L"Hook 前: %02d:%02d\n", time1.wHour, time1.wMinute);
StartHookEx();
GetLocalTime(&time2);
wprintf(L"Hook 后: %02d:%02d\n", time2.wHour, time2.wMinute);
EndHookEx();
return 0;
}⚠️ 注意事项
ppRealTrampoline指向可执行内存:该地址可以直接被调用(call),但不应被修改或释放——Detours 在DetourDetach时会自动处理ppRealTarget不等于ppPointer修改后的值:ppRealTarget始终是原始函数入口地址,而ppPointer被修改后指向 trampoline 代码块。调用 trampoline(通过ppPointer)等效于调用原始函数,但 trampoline 可能包含额外的指令修复- 所有扩展参数都可以传
NULL:如果你不需要某个输出参数,传NULL即可,功能退化为DetourAttach - 线程安全:与
DetourAttach一样,必须在事务内调用
DetourUpdateThread 函数说明
DetourUpdateThread 函数是 Detours 库中的一个重要函数,用于在 Detour 事务处理过程中将特定的线程列入更新范围。Detours 是一个用于在运行时修改二进制代码(如函数钩子)的库,它允许开发者在不修改原始代码的情况下,拦截和修改函数的执行流程。
DetourUpdateThread 函数的作用是在这个事务过程中,将一个或多个线程列入需要更新的范围。这是因为在多线程环境中,直接修改函数的代码可能会导致竞态条件,即一个线程正在执行原始代码时,另一个线程可能已经修改了该代码。通过 DetourUpdateThread 将线程列入更新范围,Detours 库可以确保在事务提交时,所有相关线程的代码都被正确地更新,从而避免竞态条件。
具体来说,DetourUpdateThread 函数的作用是:
- 将指定的线程加入到 Detour 事务的更新列表中。
- 在事务提交时,Detours 库会遍历这个更新列表,并对列表中的每个线程执行必要的代码更新操作。
需要注意的是,在调用 DetourTransactionCommit 或 DetourTransactionCommitEx 函数提交事务之前,所有的附加、分离和线程更新操作都不会生效。一旦事务被提交,所有的修改就会立即生效,所有相关线程的代码都会被更新以反映这些修改。
因此,DetourUpdateThread 函数是 Detours 库中用于确保多线程环境下代码修改一致性和安全性的重要工具之一。
可以看下 DetourUpdateThread 的源码,如下所示:
LONG WINAPI DetourUpdateThread(_In_ HANDLE hThread)
{
LONG error;
// If any of the pending operations failed, then we don't need to do this.
if (s_nPendingError != NO_ERROR) {
return s_nPendingError;
}
// Silently (and safely) drop any attempt to suspend our own thread.
if (hThread == GetCurrentThread()) {
return NO_ERROR;
}
DetourThread *t = new NOTHROW DetourThread;
if (t == NULL) {
error = ERROR_NOT_ENOUGH_MEMORY;
fail:
if (t != NULL) {
delete t;
t = NULL;
}
s_nPendingError = error;
s_ppPendingError = NULL;
DETOUR_BREAK();
return error;
}
if (SuspendThread(hThread) == (DWORD)-1) {
error = GetLastError();
DETOUR_BREAK();
goto fail;
}
t->hThread = hThread;
t->pNext = s_pPendingThreads;
s_pPendingThreads = t;
return NO_ERROR;
}
hookDemo
修改 GetLocalTime ,使其返回的分钟数为 52
#include <iostream>
#include <Windows.h>
//包含Detour的头文件和库文件
#include "detours.h"
#pragma comment (lib,"detours.lib")
using namespace std;
//保存函数原型(用指针存储要拦截的API)
void (*OldGetLocalTime)(LPSYSTEMTIME) = GetLocalTime;
//拦截后要执行的操作(这里是将时间的分改为52)
void NewGetLocalTime(LPSYSTEMTIME lpSystemTime) {
OldGetLocalTime(lpSystemTime);
lpSystemTime->wMinute = 52;
}
//下钩子函数
void StartHook() {
//开始事务
DetourTransactionBegin();
//更新线程信息
DetourUpdateThread(GetCurrentThread());
//将拦截的函数附加到原函数的地址上
DetourAttach(&(PVOID&)OldGetLocalTime, NewGetLocalTime);
//结束事务
DetourTransactionCommit();
}
//撤钩子函数
void EndHook() {
//开始detours事务
DetourTransactionBegin();
//更新线程信息
DetourUpdateThread(GetCurrentThread());
//将拦截的函数从原函数的地址上解除
DetourDetach(&(PVOID&)OldGetLocalTime, NewGetLocalTime);
//结束detours事务
DetourTransactionCommit();
}
int main()
{
//获取本地时间
SYSTEMTIME time, time2;
GetLocalTime(&time);
cout << time.wHour << ":" << time.wMinute << endl;
//下钩子
StartHook();
//下钩子后再次获取本地时间
GetLocalTime(&time2);
cout << time2.wHour << ":" << time2.wMinute << endl;
//撤钩子
EndHook();
return 0;
}Detours 原理深入剖析
以上部分讲的是 Detours 的「怎么用」,这部分深入讲它的「内部原理」: 整体拦截架构、
_DETOUR_TRAMPOLINE结构体、以及它是如何保证原子性的。 以下内容基于microsoft/Detours的detours.cpp/detours.h源码(本地仓库Windows-API-Usage\Windows\DetourDemo\Detours内即为完整源码)。
一、Detours 的整体拦截架构
Detours 的核心思想非常朴素,只有三步:
- 覆盖:把目标函数
pbTarget开头的几条指令(cbRestore字节,至少 5 字节)改写成一个跳转指令,跳向我们的 Detour 函数。 - 搬家:把被覆盖掉的原始指令「搬」到动态分配的一块可执行内存里(trampoline),并在后面补一条跳转跳回原函数的剩余部分
pbRemain。 - 中转:把用户持有的原函数指针
*ppPointer改写为指向 trampoline,这样用户在 Detour 函数里调用「原函数」时,实际执行的是 trampoline 里的那几条被搬走的指令 + 跳回剩余部分。
可以用一张图概括:
┌─────────────────────────────────────────────────────────────┐
│ 内存布局(以 x86 为例,x64 类似) │
└─────────────────────────────────────────────────────────────┘
原函数 pbTarget: Detour 函数: Trampoline(rbCode):
┌──────────────────┐ ┌──────────────────┐ ┌────────────────────────┐
│ instr1 (搬走) │ │ 我们的 Hook 逻辑 │ │ instr1' (原指令的副本) │
│ instr2 (搬走) │ │ ... │ │ instr2' (原指令的副本) │
│ instr3 (搬走) │ │ call [tramp] │ │ jmp [pbRemain] │←─┬─┐
│ ... │ │ ... │ └────────────────────────┘ │ │
├──────────────────┤ └──────────────────┘ │ │
│ jmp [rbCodeIn]───┼───────► (跳到 trampoline) │ │
│ (5/14 字节跳转) │ │ │
├──────────────────┤ │ │
│ instr4 (剩余部分) ◄───────────────────────────────────────────────────────────┘ │
│ instr5 │ ← pbRemain │
│ ... │ │
└──────────────────┘ │
▲ │
│ 用户持有的 *ppPointer(原函数指针)被改写为指向 trampoline ───────────────┘
│ (原函数指针 = trampoline 首地址,等价于「执行原函数」,但多了一层跳板)
关键点:原函数代码段并不是整体被替换,只是开头 cbRestore 字节被一条跳转覆盖,其余指令仍在原处(pbRemain)。这保证了对函数主体几乎零侵入。
二、_DETOUR_TRAMPOLINE 结构体详解
_DETOUR_TRAMPOLINE 是 Detours 最核心的数据结构,它描述了一块 trampoline 内存:既保存了被搬走的原始指令,又保存了原始指令的备份(用于撤销 Hook 时恢复),还记录了指令对齐信息。
⚠️ 该结构体在
detours.h中是**不透明(opaque)**的(只声明typedef struct _DETOUR_TRAMPOLINE DETOUR_TRAMPOLINE, *PDETOUR_TRAMPOLINE;),真正的定义在detours.cpp内部。不同架构定义不同,下面给出最常见的 x86 和 x64 两个版本。
x86 版本(DETOURS_X86)
struct _DETOUR_TRAMPOLINE
{
BYTE rbCode[30]; // 搬走的指令副本 + 跳回 pbRemain 的 jmp
BYTE cbCode; // rbCode 中已使用的字节数(被搬走的指令长度)
BYTE cbCodeBreak; // 填充字节,方便调试
BYTE rbRestore[22]; // 原始目标代码的备份(用于 UnHook 时恢复)
BYTE cbRestore; // 原始目标代码长度(被覆盖的字节数,>=5)
BYTE cbRestoreBreak; // 填充字节,方便调试
_DETOUR_ALIGN rAlign[8]; // 指令对齐数组:记录原指令/搬移后指令的偏移对应关系
PBYTE pbRemain; // 原函数中「第一段没被搬走的指令」地址(即搬走代码之后)
PBYTE pbDetour; // Detour 函数(Hook 函数)入口地址
};
// sizeof(_DETOUR_TRAMPOLINE) == 72x64 版本(DETOURS_X64)
struct _DETOUR_TRAMPOLINE
{
BYTE rbCode[30]; // 搬走的指令副本(x64 单条指令最长 15 字节)
BYTE cbCode; // rbCode 已使用字节数
BYTE cbCodeBreak; // 填充
BYTE rbRestore[30]; // 原始目标代码备份
BYTE cbRestore; // 原始目标代码长度
BYTE cbRestoreBreak; // 填充
_DETOUR_ALIGN rAlign[8]; // 指令对齐数组
PBYTE pbRemain; // 搬走代码之后的第一条指令地址
PBYTE pbDetour; // Detour 函数入口
BYTE rbCodeIn[8]; // jmp [pbDetour] —— 存放在原函数入口处的间接跳转指令
};
// sizeof(_DETOUR_TRAMPOLINE) == 96逐字段说明
| 字段 | 作用 |
|---|---|
rbCode[30] | 搬走的指令副本。构造时先用 DetourCopyInstruction 一条条把原函数开头的指令复制进来(指令边界严格对齐),随后在末尾追加一条 jmp 跳回 pbRemain。这是 trampoline 真正可执行的部分。 |
cbCode | rbCode 中实际填了多少字节(= 搬走的指令总长度)。 |
rbRestore[30] | 原始代码的备份。CopyMemory(pTrampoline->rbRestore, pbTarget, cbTarget) 把原函数开头原封不动存一份,UnHook(DetourDetach)时用它把覆盖的字节写回原位,实现完美还原。 |
cbRestore | 被覆盖的原始字节数(至少 SIZE_OF_JMP=5 字节,即能容纳一条跳转)。这也是最终要写回/覆盖的长度。 |
rAlign[8] | 指令对齐表。记录每条被搬走的指令在「原地址」和「trampoline 内」的偏移对应关系。它在两个地方极其关键:① 指令内的相对跳转(call/jmp rel32)搬移后需重定位偏移;② 当有线程正在执行被搬走的指令时,用于把线程的 EIP/RIP 从原地址重映射到 trampoline 对应位置。 |
pbRemain | 原函数中「搬走代码之后」的第一条指令地址。trampoline 末尾的 jmp [pbRemain] 就跳到这里继续执行原函数剩余逻辑。 |
pbDetour | Detour(Hook)函数入口,trampoline 构造时写入。 |
rbCodeIn[8](仅 x64) | x64 特有的 jmp [pbDetour] 间接跳转。因为 x64 的 jmp rel32 只有 ±2GB 范围,无法保证目标函数和 Detour 函数都在范围内,所以先用 FF 25 间接跳转(通过 pbDetour 这个 8 字节地址槽)跳到 Detour。 |
为什么 x64 需要
rbCodeIn(间接跳转)? x86 的E9 jmp +imm32即可到达 32 位地址空间任意位置;而 x64 的E9相对跳转只有 ±2GB。Detours 无法保证 Detour 函数一定在目标函数 ±2GB 内,所以在 x64 下:
- 原函数入口处写入的是一条
jmp [rip+disp](FF 25)间接跳转,间接目标就是 trampoline 里的pbDetour指针槽(即rbCodeIn)。- 写入的这 14 字节(
FF 25+ 4 字节偏移 + 8 字节地址槽)覆盖了原函数开头。rbCodeIn这块「跳板地址槽」位于 trampoline 内,随 trampoline 一起分配在目标函数 ±2GB 范围内。
三、如何保证原子性(Atomicity)
「原子性」在 Detours 里有两层含义:① Hook 操作的原子性(事务机制);② 单条跳转写入的原子性(不产生半改写指令)。下面分别说明。
3.1 单条跳转写入的原子性 —— 一次内存写完成跳转
修改目标函数代码时,最危险的是:另一个线程正在执行原函数,而我们在改写它的代码,导致线程执行到「写了一半」的指令。Detours 的解决思路是只做一条内存写就完成跳转安装:
- x86:在目标函数入口写入一条 5 字节的
E9 jmp +imm32。CPU 保证对齐的 4 字节写是原子的,且整个跳转是通过「先写 jmp 那 1 字节(0xE9),再写 4 字节偏移」或整体写入完成的——DetourTransactionCommit里detour_gen_jmp_immediate直接把整条指令写入。 - 配合线程挂起 + EIP 重映射(见 3.2),保证没有线程卡在「半条指令」上。
实际上 Detours 更核心的原子性保障来自事务 + 挂起线程 + 指令重映射的组合,而不是依赖单条指令的原子写。因为只要有一个线程正停在要被覆盖的指令中间,即使单条写入是原子的,那个线程的后续执行也会错乱。
3.2 事务机制(Transaction)—— 整体原子性
这是「如何保证原子性」的主角。Detours 用「事务」把一组 Hook/UnHook 操作打包成要么全部成功、要么全部回滚的原子单位。核心流程:
DetourTransactionBegin()
│ ┌──────────────────────────────────────────────┐
▼ │ 记录 s_nPendingThreadId = 当前线程 ID(此后所有操作都算同一事务) │
DetourUpdateThread(线程) │
▼ │ 把目标线程 SuspendThread 挂起,加入 s_pPendingThreads 链表 │
DetourAttach / DetourDetach / ... │
▼ │ 构造 DetourOperation 链表 s_pPendingOperations(只做准备工作, │
│ │ 不改写目标代码!真正改写发生在 Commit) │
DetourTransactionCommit() │
▼ └──────────────────────────────────────────────┘
│ 1. 遍历 s_pPendingOperations:对每个操作,把目标函数开头覆盖成跳转(或写回 rbRestore)
│ 2. 遍历 s_pPendingThreads:GetThreadContext 读取每个挂起线程的 EIP/RIP,
│ 若落在被覆盖区域内,用 rAlign 把 EIP/RIP 重映射到 trampoline 对应位置,SetThreadContext 写回
│ 3. 恢复页面保护(VirtualProtect 还原为 dwPerm)
│ 4. FlushInstructionCache 刷新指令缓存(保证 CPU 执行的是新指令)
│ 5. 释放临时资源、ResumeThread 恢复所有挂起线程为什么需要「挂起线程 + 指令重映射」?
因为改代码时,可能有一个线程正停在即将被覆盖的指令处。如果只改代码不处理线程,该线程恢复后会继续执行已被覆盖的区域,必然出错。Detours 的做法:
DetourUpdateThread在 Commit 前把相关线程挂起(SuspendThread)。- Commit 时通过
GetThreadContext拿到每个挂起线程的EIP/RIP。 - 如果该
EIP/RIP落在pbTarget .. pbTarget+cbRestore(被覆盖区),就用rAlign对齐表把它重映射到 trampoline 里对应的位置(cxt.DETOURS_EIP = pTrampoline + detour_align_from_target(...))。 SetThreadContext写回,这样线程恢复后实际执行的是 trampoline 里被搬走的等价指令,语义完全一致。
// detours.cpp —— Commit 时对每个挂起线程做 EIP/RIP 重映射(核心片段)
for (t = s_pPendingThreads; t != NULL; t = t->pNext) {
CONTEXT cxt;
cxt.ContextFlags = CONTEXT_CONTROL;
if (GetThreadContext(t->hThread, &cxt)) {
for (o = s_pPendingOperations; o != NULL; o = o->pNext) {
if (!o->fIsRemove) { // 安装 Hook:EIP 在原函数被覆盖区内 → 重映射到 trampoline
if (cxt.EIP >= (ULONG_PTR)o->pbTarget &&
cxt.EIP < (ULONG_PTR)o->pbTarget + o->pTrampoline->cbRestore) {
cxt.EIP = (ULONG_PTR)o->pTrampoline
+ detour_align_from_target(o->pTrampoline,
(BYTE)(cxt.EIP - (ULONG_PTR)o->pbTarget));
SetThreadContext(t->hThread, &cxt);
}
}
}
}
}指令边界问题的根源:
cbRestore是「至少能放下一条跳转的整指令数」。DetourCopyInstruction会一条一条完整复制指令,保证cbRestore一定是完整指令边界(不会把一条指令劈成两半)。同时rAlign记录每条指令原偏移→trampoline偏移的对应,使 EIP 重映射精确到「同一条指令」。
为什么要 FlushInstructionCache?
x86/x64 的 CPU 有指令缓存(I-Cache)和分支预测。代码被改写后,缓存里可能还是旧指令。FlushInstructionCache 强制刷新,确保后续执行取到新指令。
// detours.cpp —— Commit 收尾:恢复保护 + 刷新指令缓存
VirtualProtect(o->pbTarget, o->pTrampoline->cbRestore, o->dwPerm, &dwOld);
FlushInstructionCache(hProcess, o->pbTarget, o->pTrampoline->cbRestore);页面保护(VirtualProtect)的作用
目标函数所在的页原本可能只是 PAGE_EXECUTE_READ(只读)。写代码前需要先 VirtualProtect(..., PAGE_EXECUTE_READWRITE, &dwOld) 临时放开写权限,写完再 VirtualProtect(..., o->dwPerm) 还原成原来的保护属性。这既满足改写需要,又不永久放宽页面权限(安全)。
Detours 还维护自己的 trampoline 内存池(region):
detour_alloc_trampoline在目标函数 ±2GB 范围内找一块 64KB 对齐的空闲内存页作为 region(因为跳转/访问受 ±2GB 范围限制)。- 一个 region(
DETOUR_REGION_SIZE=0x10000)被切成若干 trampoline 块,用pFree空闲链表管理。- trampoline 页平时设为
PAGE_EXECUTE_READ,仅在写时临时放开(detour_writable_trampoline_regions/detour_runnable_trampoline_regions)。
3.3 失败回滚(Abort / 部分失败)
事务还保证回滚:
DetourTransactionAbort()手动中止:遍历已记录的操作,把已写回的目标代码还原(或释放已分配的 trampoline),恢复页面保护,恢复线程。- 任一步失败(
s_nPendingError != NO_ERROR)时,后续操作直接短路返回错误,Commit 会走到DetourTransactionAbort清理现场,避免留下半 Hook 状态。
3.4 原子性总结(对照表)
| 机制 | 解决什么问题 | 实现要点 |
|---|---|---|
| 事务(Transaction) | 一组 Hook/UnHook 的整体原子性:全部成功或全部回滚 | Begin → 操作 → Commit/Abort;失败短路 |
| 挂起线程 + EIP/RIP 重映射 | 改代码时,线程正在被覆盖区域执行 | DetourUpdateThread 挂起;Commit 时 rAlign 重映射 EIP 到 trampoline |
| 完整指令边界复制 | 不产生半条指令 | DetourCopyInstruction 逐条复制,cbRestore 为整指令数 |
| 相对/间接跳转一次写入 | 单点写入完成跳转 | x86 E9;x64 FF 25 间接跳转(rbCodeIn) |
| FlushInstructionCache | 指令缓存里是旧指令 | Commit 收尾强制刷新 |
| VirtualProtect 临时放开 | 只读代码页无法写 | 写前放开,写后还原 dwPerm |
s# 三点五、DetourTransactionCommit 的详细执行过程(源码级)
上文 3.2 从「原子性」角度讲了 Commit 的整体骨架。本节把
DetourTransactionCommitEx(DetourTransactionCommit是其pppFailedPointer=NULL的封装)拆到每一条代码,讲清楚它到底对原函数和**trampoline(跳板)**做了什么。
先看两个关键定义,后面会反复用到:
// 每个待执行操作(Attach 或 Detach)在事务里抽象成一个 DetourOperation
struct DetourOperation {
BOOL fIsRemove; // FALSE=安装Hook,TRUE=卸载Hook
PBYTE *ppbPointer; // 用户持有的原函数指针 *ppPointer
PDETOUR_TRAMPOLINE pTrampoline; // 本次操作关联的 trampoline
PBYTE pbTarget; // 目标函数原始入口(要被改写的位置)
DWORD dwPerm; // 改写前的原始页面保护属性(commit 后要还原)
DetourOperation *pNext; // 事务内操作链表
};DetourAttachEx / DetourDetach 在事务内只负责“准备工作”,把上面的 DetourOperation 挂进 s_pPendingOperations 链表,并不会真的改写目标代码。真正改写发生在 Commit。
① Commit 的第一步:检查与短路
LONG WINAPI DetourTransactionCommitEx(_Out_opt_ PVOID **pppFailedPointer)
{
if (pppFailedPointer != NULL) *pppFailedPointer = s_ppPendingError;
if (s_nPendingThreadId != (LONG)GetCurrentThreadId())
return ERROR_INVALID_OPERATION; // 不是本事务发起线程
// 只要事务里任何一个 Attach/Detach 记录过错误,整体回滚
if (s_nPendingError != NO_ERROR) {
DetourTransactionAbort();
return s_nPendingError;
}要点:
- 只能由开启事务的同一线程提交,否则
ERROR_INVALID_OPERATION。 - 只要
s_nPendingError非零(例如某个DetourAttach因函数太短返回ERROR_INVALID_BLOCK),Commit 直接走DetourTransactionAbort整体回滚,绝不留下半 Hook 状态。
② 遍历操作链表:真正改写原函数 / 恢复原函数
for (o = s_pPendingOperations; o != NULL; o = o->pNext) {
if (o->fIsRemove) {
// ---- Detach(卸载):把备份的原始指令写回原函数入口 ----
CopyMemory(o->pbTarget, o->pTrampoline->rbRestore,
o->pTrampoline->cbRestore);
*o->ppbPointer = o->pbTarget; // 原函数指针指回真实入口
}
else {
// ---- Attach(安装):在原函数入口写入跳转 ----
#ifdef DETOURS_X64
// 1) 在 trampoline 的 rbCodeIn 槽里生成 `jmp [rip+disp]` 间接跳转
detour_gen_jmp_indirect(o->pTrampoline->rbCodeIn,
&o->pTrampoline->pbDetour);
// 2) 在目标函数入口写 `jmp [rip+disp]` → 跳到 rbCodeIn(再跳到 Detour)
PBYTE pbCode = detour_gen_jmp_immediate(o->pbTarget,
o->pTrampoline->rbCodeIn);
pbCode = detour_gen_brk(pbCode, o->pTrampoline->pbRemain);
// 3) 把用户的原函数指针改写为指向 trampoline 的 rbCode
*o->ppbPointer = o->pTrampoline->rbCode;
#endif
#ifdef DETOURS_X86
// x86:相对跳转 E9 jmp +imm32 即可覆盖整个 32 位地址空间
PBYTE pbCode = detour_gen_jmp_immediate(o->pbTarget,
o->pTrampoline->pbDetour);
pbCode = detour_gen_brk(pbCode, o->pTrampoline->pbRemain);
*o->ppbPointer = o->pTrampoline->rbCode;
#endif
}
}这一段就是「如何修改原函数」的答案,逐字节拆开:
x86 改写原函数(detour_gen_jmp_immediate)→ 在 pbTarget 处写入 5 字节:
E9 <imm32> ; jmp rel32 —— 相对跳转,跳向 Detour 函数因为 x86 整个地址空间只有 32 位,一条 E9 相对跳转足够到达任意 Detour。
x64 改写原函数 → 因为 E9 的相对偏移只有 ±2GB,无法保证 Detour 一定在目标函数 ±2GB 内,所以改成间接跳转(detour_gen_jmp_indirect 生成 FF 25):
FF 25 <disp32> ; jmp qword ptr [rip+disp32] —— 读内存里的 8 字节目标地址再跳
... + 8 字节地址槽(放在 trampoline 的 rbCodeIn,即 jmp 的目标)即:原函数入口先间接跳到 trampoline 内的 rbCodeIn 地址槽,再经由它跳到 Detour 函数。
同时做了两件指针改写:
- 物理改写原函数入口(写跳转指令)。
- 把用户持有的
*ppPointer从「原函数入口」改为「trampoline 的rbCode」——这样用户在 Detour 里调用「原函数」时,实际跑的是被搬走的指令 + 跳回剩余部分的逻辑。
⚠️ 注意顺序:先改写目标代码,再改指针。代码写完后,只要有一个线程正在执行这条函数,下一次调用就会先撞上跳转进入 Detour。
③ 遍历挂起线程:EIP/RIP 重映射
for (t = s_pPendingThreads; t != NULL; t = t->pNext) {
CONTEXT cxt; cxt.ContextFlags = CONTEXT_CONTROL;
if (GetThreadContext(t->hThread, &cxt)) {
for (o = s_pPendingOperations; o != NULL; o = o->pNext) {
if (o->fIsRemove) {
// 线程正停在 trampoline 里 → 重映射回原函数对应位置
if (cxt.DETOURS_EIP >= (ULONG_PTR)o->pTrampoline &&
cxt.DETOURS_EIP < (ULONG_PTR)o->pTrampoline + sizeof(*o->pTrampoline)) {
cxt.DETOURS_EIP = (ULONG_PTR)o->pbTarget +
detour_align_from_trampoline(o->pTrampoline,
(BYTE)(cxt.DETOURS_EIP - (ULONG_PTR)o->pTrampoline));
SetThreadContext(t->hThread, &cxt);
}
} else {
// 线程正停在原函数被覆盖区 → 重映射到 trampoline 对应指令
if (cxt.DETOURS_EIP >= (ULONG_PTR)o->pbTarget &&
cxt.DETOURS_EIP < (ULONG_PTR)o->pbTarget + o->pTrampoline->cbRestore) {
cxt.DETOURS_EIP = (ULONG_PTR)o->pTrampoline +
detour_align_from_target(o->pTrampoline,
(BYTE)(cxt.DETOURS_EIP - (ULONG_PTR)o->pbTarget));
SetThreadContext(t->hThread, &cxt);
}
}
}
}
}这一步处理「改写代码时正巧有线程执行到被覆盖区」的竞态:
- 安装时:若线程的
EIP/RIP落在pbTarget..pbTarget+cbRestore(即将被覆盖的指令区),用rAlign对齐表把它映射到 trampoline 里同一条指令的等价位置,SetThreadContext写回,线程恢复后继续执行语义完全一致的搬移指令。 - 卸载时:反向——线程若停在 trampoline 内,映射回原函数对应指令位置。
detour_align_from_target/detour_align_from_trampoline就是查_DETOUR_TRAMPOLINE.rAlign[]这张「原偏移 ↔ trampoline 偏移」对照表,保证重映射精确到同一条指令(不劈半)。
④ 恢复页面保护 + 刷新指令缓存 + 收尾
HANDLE hProcess = GetCurrentProcess();
for (o = s_pPendingOperations; o != NULL;) {
DWORD dwOld;
// 把页面保护还原为改写前的 dwPerm(通常 PAGE_EXECUTE_READ)
VirtualProtect(o->pbTarget, o->pTrampoline->cbRestore, o->dwPerm, &dwOld);
// 强制刷新指令缓存,确保 CPU 取到新指令而非旧缓存
FlushInstructionCache(hProcess, o->pbTarget, o->pTrampoline->cbRestore);
if (o->fIsRemove && o->pTrampoline) {
detour_free_trampoline(o->pTrampoline); // 卸载时归还 trampoline
o->pTrampoline = NULL; freed = TRUE;
}
delete o; o = n;
}
s_pPendingOperations = NULL;
if (freed && !s_fRetainRegions) detour_free_unused_trampoline_regions();
detour_runnable_trampoline_regions(); // 收紧 trampoline 页保护
for (t = s_pPendingThreads; t != NULL;) { ResumeThread(t->hThread); ... }
s_pPendingThreads = NULL;
s_nPendingThreadId = 0;
return s_nPendingError;
}收尾四件事:
- 还原页面保护:改代码前用
VirtualProtect(..., PAGE_EXECUTE_READWRITE)临时放开写权限,Commit 后VirtualProtect(..., dwPerm)还原成只读可执行,避免永久放宽页面权限(安全)。 FlushInstructionCache:x86/x64 有指令缓存,改写后必须刷新,否则 CPU 可能继续执行缓存的旧指令。- 归还 trampoline:卸载(Detach)时把 trampoline 块归还空闲链表,若整个 region 空了且不保留,则
detour_free_unused_trampoline_regions释放整块 region。 ResumeThread恢复所有被DetourUpdateThread挂起的线程。
三点六、trampoline(跳板)是怎么创建出来的
Commit 里反复用到的
rbCode、rbRestore、rAlign、pbRemain这些字段,在DetourAttachEx的detour_finish_attach阶段就已填充好。这里把「创建跳板」的完整过程串起来。
① 在目标函数 ±2GB 内分配内存(detour_alloc_trampoline)
static PDETOUR_TRAMPOLINE detour_alloc_trampoline(PBYTE pbTarget)
{
// 跳转/访问受 ±2GB 范围限制,必须在 pbTarget 附近找内存
detour_find_jmp_bounds(pbTarget, &pLo, &pHi);
...
// 先查已有 region 的空闲块;没有就新分配一个 region
pbTarget = pbTarget - (ULONG)((ULONG_PTR)pbTarget & 0xffff); // 向下取整到 64KB
PVOID pbNewlyAllocated = detour_alloc_trampoline_allocate_new(pbTarget, pLo, pHi);
...
s_pRegion->dwSignature = DETOUR_REGION_SIGNATURE;
// 把 region 除第一个 trampoline 外的块挂进空闲链表
for (int i = DETOUR_TRAMPOLINES_PER_REGION - 1; i > 1; i--) {
pTrampoline[i].pbRemain = pFree; pFree = (PBYTE)&pTrampoline[i];
}
s_pRegion->pFree = ...;
}- 一个 region =
DETOUR_REGION_SIZE(0x10000,64KB),在目标函数 ±2GB 内找一块空闲内存页分配。 - region 被切成
DETOUR_TRAMPOLINES_PER_REGION个 trampoline 块,用pFree空闲链表管理(DetourRegion.pFree指回下一个空闲块,块与块通过pbRemain字段串起来,形成嵌入式空闲链表)。 - trampoline 页平时设为
PAGE_EXECUTE_READ,仅写时临时放开,写回后收紧(detour_writable/runnable_trampoline_regions)。
② 逐条搬移原指令(DetourCopyInstruction)
while (cbTarget < cbJump) { // 至少要搬够能容纳一条跳转的长度(>=5字节)
PBYTE pbOp = pbSrc;
pbSrc = DetourCopyInstruction(pbTrampoline, &pbPool, pbSrc, NULL, &lExtra);
pbTrampoline += (pbSrc - pbOp) + lExtra;
cbTarget = (LONG)(pbSrc - pbTarget);
// 记录「原偏移 → trampoline 偏移」对照,供 EIP 重映射用
pTrampoline->rAlign[nAlign].obTarget = cbTarget;
pTrampoline->rAlign[nAlign].obTrampoline = pbTrampoline - pTrampoline->rbCode;
nAlign++;
if (detour_does_code_end_function(pbOp)) break; // 遇到 ret 等结尾指令就停
}
...
pTrampoline->cbCode = pbTrampoline - pTrampoline->rbCode; // 搬走指令总长度
pTrampoline->cbRestore = cbTarget; // 被覆盖字节数
CopyMemory(pTrampoline->rbRestore, pbTarget, cbTarget); // 备份原指令(用于恢复)
pTrampoline->pbRemain = pbTarget + cbTarget; // 剩余代码起点
pTrampoline->pbDetour = (PBYTE)pDetour;
// 末尾补一条跳回 pbRemain 的 jmp
pbTrampoline = detour_gen_jmp_indirect(pbTrampoline, &pTrampoline->pbRemain); // x64创建跳板的核心逻辑:
DetourCopyInstruction逐条解码原函数开头指令,原样复制进rbCode,且保证完整指令边界(绝不劈半)。- 至少复制
SIZE_OF_JMP(5 字节),否则放不下跳转指令,复制不足会返回ERROR_INVALID_BLOCK。 - 搬移中做重定位:原指令里若有相对跳转/访问(
call rel32等),DetourCopyInstruction会修正偏移,因为指令从原地址搬到了 trampoline,相对距离变了。 - 记录
rAlign对齐表:每条指令的「原偏移 → trampoline 偏移」,这是第 ③ 步 EIP 重映射的依据。 - 备份原始指令到
rbRestore:这是 Detach 时CopyMemory(pbTarget, rbRestore, cbRestore)写回原函数的「原材料」,实现完美还原。 - 末尾补
jmp [pbRemain]:trampoline 执行完搬走的指令后,跳回原函数剩余部分继续执行。 - 记录
pbRemain(剩余代码起点)、pbDetour(Detour 函数入口)。
创建跳板的完整流程时序
DetourAttachEx(...)
│
├─ detour_alloc_trampoline(pbTarget) ① 在 ±2GB 内分配 trampoline 块(region 池)
├─ 逐条 DetourCopyInstruction 搬指令 ② 复制原指令 + 修正相对偏移 + 记 rAlign
├─ 备份原指令到 rbRestore ③ 为将来 Detach 还原留底
├─ 末尾补 jmp [pbRemain] ④ 搬完跳回原函数剩余部分
├─ VirtualProtect(PAGE_EXECUTE_READWRITE) ⑤ 临时放开目标页写权限
└─ 生成 DetourOperation 挂进链表 ⑥ 准备工作完成,等 Commit 真正改写
│
DetourTransactionCommit() ▼
├─ 遍历操作:原函数入口写跳转(→Detour) / 写回rbRestore(卸载) ① 改写原函数
├─ 挂起线程 EIP/RIP 重映射(rAlign) ② 处理竞态
├─ VirtualProtect 还原 + FlushInstructionCache ③ 还原保护+刷新缓存
├─ 归还 trampoline / 释放空 region ④ 资源回收
└─ ResumeThread 恢复线程 ⑤ 放行四、什么时候需要事务?(与上文的呼应)
上文提到「单个 DetourDetach 不需要事务」,这里补充底层原因:单个 Detach 本质上就是对目标代码一条原子写(写回 rbRestore)+ 释放 trampoline,不涉及多操作的协调;而只要涉及多个函数的 Hook/UnHook,就必须用事务保证它们要么一起生效、要么一起回滚,否则会出现「A 已 Hook、B 失败」的中间态。
实践建议:
- 单函数单次 Hook/UnHook → 可以直接
DetourAttach/DetourDetach(内部仍是事务 API 的最小用法)。 - 多函数、多步骤、需要回滚 → 必须
Begin/Commit/Abort包裹,并配DetourUpdateThread。
更严谨的说法:Detours 的
DetourAttach/DetourDetach本质上都要求在事务内调用(源码里会检查s_nPendingThreadId,不在事务内会返回ERROR_INVALID_OPERATION)。所谓的「单个不需要事务」,是指「不需要额外自己包一层多操作事务」,Detours 内部依然用事务 API 完成单点写入。
五、关键源码速查
| 函数 / 宏 | 位置(detours.cpp) | 作用 |
|---|---|---|
struct _DETOUR_TRAMPOLINE | L113(x86) / L322(x64) | trampoline 结构体定义 |
detour_gen_jmp_immediate | L132(x86) / L344(x64) | 生成 E9 jmp +imm32 |
detour_gen_jmp_indirect | L140 / L352 | 生成 FF 25 jmp [rip+imm32](x64 用) |
detour_alloc_trampoline | L1397 | 在 ±2GB 内分配 trampoline(region 池) |
detour_finish_attach | L~2140 | 构造 trampoline(搬指令 + 记录对齐 + 备份) |
DetourTransactionBegin | L~1590 | 开启事务,独占 s_nPendingThreadId |
DetourUpdateThread | L~1960 | 挂起线程加入 s_pPendingThreads |
DetourTransactionCommit | L1661 | 封装 → DetourTransactionCommitEx(NULL) |
DetourTransactionCommitEx | L1686 | ①改写原函数跳转 ②EIP/RIP重映射 ③还原保护+flush ④归还trampoline ⑤ResumeThread |
DetourTransactionAbort | L~1640 | 回滚:还原代码 + 释放 + 恢复线程 |
detour_align_from_target/trampoline | L1642/L1634 | 查 rAlign 表做 EIP 重映射 |
detour_gen_brk | — | 用 0xCC 填充跳转后的剩余空间(调试) |
detour_writable/runnable_trampoline_regions | L1189/L1201 | 临时放开/收紧 trampoline 页保护 |
参考
- Detours 官方源码:
microsoft/Detours(src/detours.cpp、include/detours.h) - 本地源码副本:
Windows-API-Usage\Windows\DetourDemo\Detours\ - microsoft/Detours - GitHub