Win32 下 Hook 函数的几种方法

遇到了需要 Hook 一些函数的情况,WinAPI 的 __stdcall 或是 IDA 分析出来的 __usercall,留此纪录。测试环境为 Win32,编译器为 MSVC。

 


 

 

 


  • 普通 JMP(不推荐)

Hook 一个函数,最简单的思路就是在它开头放一个 JMP 指令到我们的地址。

而需要调用回原函数时(比如只是截取参数信息),必须先还原它的开头字节才能正常调用,这在多线程中可能会出现短暂的 Hook 失效,甚至执行到非预期的汇编指令,因此一般不采用此方法。

首先定义一个 JMPCODE,大小为 5 字节。(注意对齐问题)

有一个函数 HOOK::patch 用来写入某块内存区域。(注意更改页面保护信息)

以 Hook WinAPI OutputDebugStringA 为例,只需要把它的前 5 个字节替换为 JMPCODE 即可。(注意备份前 5 个字节,及 JMP 的偏移计算)

其中 Hook 到的我们的 myOutputDebugStringA 函数实现如下:(注意还原前 5 个字节)

这是用 JMP 实现的一个最简单的 Hook。

 

 


  • GadgetJMP

基于上述普通 JMP 在多线程环境下的问题,如果能避免每次都重新写入(hook, recover, re-hook, recover, re-hook, ……)原函数的前几个字节就好了。

所以自然的一种想法就是将原函数的前几个字节拷到一个地方,每次只需先转到这个地方执行原函数的前几条指令,而后跳回原函数继续执行后面的指令就好了。

这就是带 GadgetJMP 思想,首先把原函数的前几条完整的指令拷到 Gadget 上,Gadget 最后 JMP 回原函数后面的指令继续执行,这样就可以避免更改原函数的前几个字节(Hook 完后始终保持为 JMP)而调用回真正的原函数了。

主要流程如下图:

需要注意的是这里必须 patch 完整的指令字节(至少是 5 bytes,多余的部分用 NOP 填充),所以对于不同函数需要 patch 的字节数也不尽相同。

以 Hook 上图中 base + 0x75E90 处的函数为例,需要替换它的前 6 个字节(分别为完整的 sub esp, Cpush esipush 2C)为 JMP offsetNOP

其中 Hook 到的我们的 myFunc 函数实现如下:(假设该函数为 __cdecl,接受一个参数)

这样就完成了一个带 GadgetJMP Hook。

 

 


  • 替换 IAT(仅适用 PE)

可以发现,上述方法也存在一个明显的缺点:被调用函数最好至少为 5 字节,且前几条指令最好不要是 JMP

比如 kernel32.dll 中的 OutputDebugStringA 只是一个简单的 JMPkernelbase.dll 中的 OutputDebugStringA

而要 Hook WinAPI (或是其它由 DLL 导入的函数),还可以通过 patch IAT 中对应函数的位置。

IAT ( Import Address Table ),详见 https://docs.microsoft.com/en-us/windows/win32/debug/pe-format#import-address-table

在一个 PE 文件里,可以很容易地找到 Import Table 的位置。

里面包含了导入的各种 DLL 函数。

在 PE 文件被加载的时候,系统会自动填充此表为正确的地址,而要 Hook 某个导入的函数,只需其后将 IAT 中它的地址替换为我们的就行了。

以 Hook WinAPI OutputDebugStringA 为例,实现代码如下:

其中 Hook 到的我们的 myOutputDebugStringA 函数实现如下:

这样就完成了一个简单的 IAT Hook。

可以发现 PE 中的 IAT 与 ELF 中的 GOT/PLT 存在相似之处。

 

 


  • __usercall 的转换

至此,我们已经可以很好地 Hook 一些常规的函数了。

而有些函数在 IDA 里会被分析成下图的 __usercall 调用方式:

__usercall 意味着区别于常规的 __stdcall (参数自右向左依次入栈,由被调用函数清理)或是 __cdecl(参数自右向左以此入栈,由调用者自行清理),它先把一些参数存在寄存器里,使用寄存器传参,而后才会入栈,是一种编译器优化而成的非标准的调用方法。

这就意味着我们无法使用纯 C/++ 代码来实现此函数的 Hook(甚至是调用),不过内联汇编使转换函数(__usercall <-> __stdcall)成为了可能。

关于函数调用栈操作的粗浅的理解,在先前的文章 “栈溢出 —— 初级 ROP 学习记录” 已有所提及。

在这里 Hook 可以完全仿照先前带 GadgetJMP 方法,应当把注意力集中在两种调用方法的转换上。

base + 0x75E90 处的函数 sub_475E90 为例,它接受三个参数,分别由 eaxedi 和栈传递,最后用 eax 传递返回值。

在实现上,__usercall 会更相似于 __cdecl,因为前者可以被看做是后者的一种优化。所以把原函数 Hook 至我们的 sub_475E90,实现如下:(在这里进行 enter/leave 操作是为了避免 push 参数时可能会出现溢出)

mysub_475E90 已经可以被当做是一个普通的 __stdcall 函数了。(转换成功!)

orgsub_475E90 实现了从 __cdecl__usercall 的转换。(我在这里使用了 HOOKJMP::get 函数,功能是返回原函数(或者是 Gadget)的地址,ecx 作为 this 指针传进去)(调用完原函数后要记得平衡堆栈,orgsub_475E90 选择了 __cdecl 也是为了避免需要在 __declspec(naked) 里手动平衡堆栈)

这样就可以 Hook 一个 __usercall 函数,包括正常调用原函数了。

(一个隐藏的问题,使用这种方法 Hook 时,va_list 可能无法被正确地传递。)

 

 


  • 附:Reflective PE Loader

从内存中加载 DLL 或是运行 EXE 是一个难题,这里列出几次尝试。

 

加载一个 DLL 时,LoadLibrary 其实做了两件事:修复 reloc,导入 IAT。

在本进程加载 DLL 时,只需在内存中手动解析 PE 头结构,修复 reloc 段,构建 IAT 表,然后转到 DllMain 就可以了。

本进程的 Reflective DLL Loader 是成功的,只是 DllMain 需要保持阻塞否则调用 DLL 的导出函数时会发生错误(或许可以用 GetProcAddress 避免?)。

 

而远程 Reflective DLL Loader 一个显而易见的思路就是把这个 Loader(修复 reloc,IAT)功能注入到远程进程中执行。(如下代码)

然而这么做有一定失败的机率,推测原因为 ASLR 导致 kernel32.dll 中 API 的地址发生了变化,一种可行的改进方法为不借助其它 WinAPI 而找到这些函数的地址(待坑)。

 

而本地加载 EXE 同理,修复 reloc,构建 IAT,然后转到入口点执行。代码基本同上。

然而加载大多数程序时却失败了(有个例能成功),原因未知。

远程加载 EXE 更不用说,有看到一种写法是先 ZwUnmapViewOfSection 掉原来的区块,手动修复 reloc、IAT 后用 SetThreadContext 覆盖上去,加载大多数程序时也合理地失败了。

 

以上为一些尝试。

 


 

 

发表评论

您的电子邮箱地址不会被公开。