MinHook是一个常用的 InlineHook 库,学习完整个项目后对 Hook 技术会有更深的认识。
项目路径:TsudaKageyu/minhook: The Minimalistic x86/x64 API Hooking Library for Windows (github.com)
1. 基本原理
1)获取原函数地址
2)将原函数的前五个字节改为跳转指令跳转到 FakeFunc
3)FakeFunc 执行完后跳转到内存槽,执行原函数指令后跳回原函数的后续指令
x86特点:
x86,2GB 的用户空间,jmp call 指令,经过一次跳转就可以到达目标地址。
x64特点:
x64,128TB 的用户空间,jmp call 指令,经过一次跳转往往无法到达目标地址。
由于内存空间较大,利用 E9 指令可能会跳不到 FakeFunc,即在原函数附近申请一片内存作为跳板,将原函数前五个字节指令改为跳转指令使其跳转到跳板的 Relay 中,Relay 处存放 FF 25 指令,使其跳转到 FakeFunc,FakeFunc 执行完后即跳到内存槽中执行原函数指令后跳回原函数后续指令
2. 比较重要的两个结构体
typedef struct _HOOK_ENTRY
{
LPVOID TargetFunctionAddress;
LPVOID FakeFunctionAddress;
LPVOID MemorySlot;
UINT8 Backup[8]; //恢复Hook使用的存放原先数据
UINT8 PatchAbove : 1; // Uses the hot patch area. 位域:1位
UINT8 IsEnabled : 1; // Enabled.
// UINT8 queueEnable : 1; // Queued for enabling/disabling when != isEnabled.
UINT Index : 4; // Count of the instruction boundaries.???
UINT8 OldIPs[8]; // Instruction boundaries of the target function.???
UINT8 NewIPs[8]; // Instruction boundaries of the trampoline function ???
} HOOK_ENTRY, *PHOOK_ENTRY; //44字节
typedef struct _TRAMPOLINE
{
LPVOID TargetFunctionAddress;
LPVOID FakeFunctionAddress;
LPVOID MemorySlot; // MemorySlot 32字节
#if defined(_M_X64) || defined(__x86_64__)
LPVOID Relay; // [Out] Address of the relay function. 原函数 到 Fake函数的中转站
#endif
BOOL PatchAbove; // [Out] Should use the hot patch area? //Patch --->补丁 //0xA 0xB
UINT Index; // [Out] Number of the instruction boundaries.
UINT8 OldIPs[8]; // [Out] Instruction boundaries of the target function. //恢复
UINT8 NewIPs[8]; // [Out] Instruction boundaries of the trampoline function. //Hook
} TRAMPOLINE, *PTRAMPOLINE;
3. MiniHook可Hook的函数类型
1)普通函数
计算从 Memory 跳转到原函数后续指令的偏移值 offset,保存在 jmp 指令中作为跳转地址,将 jmp 指令写入 memoryslot
2)函数入口为 jmp
①E9 指令:保存原函数 jmp 指令跳转处的地址,保存在自己的 jmp 指令中作为跳转地址将jmp指令写入 memoryslot
②EB 指令:
a. 第一条短跳转地址在原函数前五个字节,将其写入 memoryslot,如果第二条指令还是 jmp 跳转地址超过前五个字节,此时把该地址保存在 jmp 指令中作为跳转地址,写入 memoryslot
b. 第一条指令是稍远的短跳转,构造 jmp 指令,计算此地址和 memoryslot 之间的偏移值,保存在 jmp 指令中作为跳转地址,写入memoryslot
可能会遇到比较越界问题:即第一步短跳转到不远处执行代码,后面又短跳转到前五个字节处
3)函数入口为call(E8)
①得到原函数call指令跳转处的地址,计算此地址和memoryslot之间偏移值,保存在call指令中作为跳转地址,写入memoryslot
②call指令会将下一条指令压入,不能直接跳出循环,还需要计算从Memory跳转到原函数后续指令的偏移值offset,保存在jmp指令中作为跳转地址,写入memoryslot
4)函数入口为jcc
①得到原函数指令跳转处的地址
②第一个跳转的地址在原函数的前五个字节里,保存跳转处的地址,存入memorySlot,继续循环
③若第二条指令还是jcc,跳转地址超过五个字节,进入else块,把MemorySlot后续改为jcc跳到原函数跳转地址处
可能会遇到比较越界问题:即第一步短跳转到不远处执行代码,后面又短跳转到前五个字节处
5)函数入口为ret
直接返回
6)热补丁Hook
①如果原函数所有指令不足五个字节,将其写入memoryslot退出循环,判断能否进行短跳转
②如果只能写短跳转,使用热补丁技术,确定该函数前面的地址为可执行地址,前面的内容可改,设置热补丁标志
③改变原函数:构造jmp短跳转,使其可以从原函数跳到原函数之前五个字节处,前五个字节被改为jmp指令跳转到FakeFunc
4. MiniHook注意点
1)跳转地址偏移值计算公式
计算公式:目标 = 源 + Offset + 5 Offset = 目标 - (源 + 5)
2)函数的真正地址
当调用自定义的函数时,代码执行到被调用函数处,第一条指令将是一条jmp指令跳转到真正的被调用函数入口地址,即测试自定义函数第一步就是获得原函数的真正地址
(利用汇编,传入假函数地址,此地址加1即得真实地址)
3)64位构建内存槽
确定Ring3层进程空间访问的范围,将MemoryBlock地址范围控制在原函数地址±1024MB,最后还要对MaxAddress进行微调:MaxAddress -= MEMORY_BLOCK_SIZE - 1;
4)ShellCode
x86:jmp-5个字节 call-5个字节 jcc-6个字节
x64:jmp-14个字节 call-16个字节 jcc-16个字节
CALL_ABS call = {
0xFF, 0x15, 0x00000002, // FF15 00000002: CALL [RIP+8]
0xEB, 0x08, // EB 08: JMP +10
0x0000000000000000ULL // Absolute destination address
};
JMP_ABS jmp = {
0xFF, 0x25, 0x00000000, // FF25 00000000: JMP [RIP+6]
0x0000000000000000ULL // Absolute destination address
};
JCC_ABS jcc = {
0x70, 0x0E, // 7* 0E: J** +16
0xFF, 0x25, 0x00000000, // FF25 00000000: JMP [RIP+6]
0x0000000000000000ULL // Absolute destination address
};
CALL_REL call = {
0xE8, // E8 xxxxxxxx: CALL +5 + xxxxxxxx Push Eip Jmp Ret
0x00000000 // Relative destination address
};
JMP_REL jmp = {
0xE9, // E9 xxxxxxxx: JMP +5+xxxxxxxx
0x00000000 // Relative destination address
};
JCC_REL jcc = {
0x0F, 0x80, // 0F8* xxxxxxxx: J** +6+xxxxxxxx
0x00000000 // Relative destination address
};
5)FF 25绝对地址
x64跳转指令都是使用绝对地址,不需要计算偏移值
6)线程同步问题
① 加锁和Sleep
第一次进入不会进入循环,第一次后 g_isLocked 赋值为 TRUE,后面其他线程进入后会进入循环
static VOID EnterSpinLock(VOID)
{
SIZE_T spinCount = 0;
while (InterlockedCompareExchange(&g_isLocked, TRUE, FALSE) != FALSE)
{
if (spinCount < 32)
Sleep(0);
else
Sleep(1);
spinCount++;
}
}
函数调用了原子操作函数,这整个操作过程是锁定内存的,其它处理器不会同时访问内存,从而实现多处理器环境下的线程互斥。
Sleep(0)与Sleep(1):
Sleep 的意思是告诉操作系统自己要休息 n 毫秒,这段时间片可以让给另一个就绪的线程。
n=0 时,当前线程放弃自己剩下的时间片,仍然是就绪。Sleep(0)只允许优先级相等或更高的线程使用当前CPU,其它线程等待。如果没有合适的线程,当前线程会重新使用CPU时间片
n=1 时,要当前线程放弃剩下的时间片,休息一下。且所有其它就绪状态的线程都有机会竞争时间片,而不用在乎优先级。
② 热补丁
Hook 原函数的前两个字节,将其改为 EB F9 短跳转指令,即跳转到原函数之前五个字节处,若此处内存被 0xcc 填充,将此处设置为 E9 指令跳转到 FakeFunc 中
解决了线程同步问题:避免 Hook 时其他线程执行前几个字节指令,热补丁 Hook 的为无效指令,所以即使有其他线程执行,也不会造成错误
7)解决了 Hook 重入问题
①设置了内存槽存放原函数被Hook的指令及跳回原函数后续指令的指令
②使用热补丁
8)钩子链
出现原因:一个函数被多次Hook,形成了Hook链
解决方法:
①新Hook加入Hook链
②穿透Hook链,跳过Hook点,直接钩在原函数上(风险较大)
③重载内核模块(重新加载一遍模块,即得到新的原函数)
9)指令缓存
hook 原函数后跳转到 FakeFunc,虽然修改了内存中的指令,但有可能被修改的指令已经被缓存起来了,再执行,CPU 可能会优先执行缓存中的指令,使得修改的指令得不到执行
解决方法:需要调用 FlushInstructionCache(GetCurrentProcess(), pPatchTarget, patchSize);来刷新缓存
注意:如果使用WriteProcessMemory写其他进程内存,不需要再额外调用FlushInstructionCache函数刷新缓存,因为WriteProcessMemory本身就会调用NtFlushInstructionCache函数来刷新缓存
10)暂停线程,捕获上下文,提取EIP / RIP
改写原函数的前五个字节指令时,需要挂起所有线程,循环遍历比对查找,如果有线程的 EIP/RIP 指向原函数的前五个字节处,将其 EIP/RIP 改为指向 MemorySlot 里保存的相应指令处(利用 HookEntry->NewIPS, 这里面存了保存在 MemorySlot 里指令的偏移长度)
在恢复 Hook 时需要对线程的EIP/RIP进行恢复:利用 HookEntry->OldIPS,里面存的该指令在原函数指令的偏移长度