PolyHook 2: C++17 x86/x64 挂钩库





5.00/5 (13投票s)
PolyHook v2 - 支持多种挂钩方法的 C++17 x86/x64 库
库
引言
我花了过去两年重写 PolyHook,以修复 V1 中大量已知的边缘情况。我将简要介绍实现的挂钩方法是如何工作的,但这属于高级主题,您应该先阅读我关于此的另一篇文章,其中深入讲解了这一点。本文将重点介绍边缘情况,以及为什么我花了两年时间才在现代编译器和多架构的发布模式下使其正常工作。虽然它还不是完美的,但它在所有方面都显著地得到了改进。关于这个有多么深奥,有很多话要说,我最近才刚刚爬出来。
背景
挂钩是重定向程序控制流使其偏离原始路径的过程。通常,当无法访问源代码时,它是一个本质上低级的过程,在汇编级别或至少在编译阶段之后进行操作。根据所使用的方法,可以实现不同的效果,所有方法都允许执行一个回调,该回调会在挂钩方法被调用之前触发。某些方法允许更改函数参数或返回值。此外,某些方法会修改已编译程序的代码,而其他方法则利用对正在运行的程序透明的技术。
Bug
在 V1 中,内联挂钩存在一些未处理的边缘情况:
- 不支持跳回序言 (Jmp back into prologue)
- 间接序言(开头有 jmp)
- x64 栈被修改
- 挂钩失败导致原始函数处于部分覆盖的损坏状态
- 挂钩与蹦床创建发生竞争
以及其他挂钩方法中的许多 Bug
- 在矢量化异常处理程序中获取互斥锁
- Dr7 中未设置断点类型和宽度
- IAT 未能找到要挂钩的导入占位符
让我们看看这一切意味着什么。我们将从我最喜欢的开始。
跳入序言 (Jmps into prologue) (1)
0: 55 push ebp
1: 89 e5 mov ebp,esp <-
3: 89 e5 mov ebp,esp |
5: 89 e5 mov ebp,esp |
7: 89 e5 mov ebp,esp |
9: 90 nop |
a: 90 nop |
b: 7f f4 jg 0x1 ----
注意 `jg` 汇编指令跳回地址 `0x1`。当在 x86 上执行挂钩时,上面的序言会被一个 5 字节的 e9 样式跳转覆盖,使其变成以下内容:
0: e9 ef be ad de jmp hook_callback <--
5: 89 e5 mov ebp,esp | <--- callback executes, runs the
7: 89 e5 mov ebp,esp | overwritten instructions and
9: 90 nop | returns here once done
a: 90 nop |
b: 7f f4 jg 0x1 -------------
现在,`jg` 指向字节 `ef`,属于 `jmp`。这是一个问题,因为它在执行时会处于指令中间,不会被解释为 `jmp`,而是某些垃圾指令。有很多方法可以解决这个问题,有些比其他的更复杂。我们可以重新编码 `jg` 使其指向 `0x0`,这样它就会跟在 `jmp` 后面,不再执行垃圾代码,但当 `jmp` 发生时,它会破坏控制流,因为用户回调会触发第二次,并且执行不会像原来那样继续在 `mov ebp, esp` 处执行;所以这是错误的。
我们也可以尝试构建一个 `jmp` 表,并覆盖序言中的一点更多代码以腾出空间给 `jmp` 表条目,写入一个更宽的 `jmp` 类型一直到蹦床所在的位置。整个序言部分将被复制到蹦床,当需要执行它们时,我们可以通过重定向条件 `jmp` 到更大的 `jmp` 来做到这一点。
0: e9 ef be ad de jmp hook_callback
3: e9 ef be ad de jmp trampoline_mov_ebp_esp <- points copy in trampoline
8: 90 nop
9: 90 nop
a: 90 nop
b: 7f f4 jg 0x3
但这有一个非常大的问题。它非常困难。`jmp` 表必须在序言中,因为我们只有 +/- 127 字节的偏移量(单字节有符号 `jg` 7f **_f4_**)。这意味着我们需要的修复越多,我们覆盖的序言就越多,这可能意味着更多的修复,这意味着……是的,这是一个固定空间内试图解决的无界递归解决方案。当您需要进行如此多的修复,以至于您的 `jmp` 表增长到与您修复的第一个跳转(此示例中地址为 `b`)发生碰撞时会发生什么?我曾多次尝试实现这一点,但它引入的边缘情况比它修复的还要多,并且可以通过接下来提到的方法更好、更简单地解决。
我选择的通用解决方案是 K.I.S.S.(保持简单),只需扩展复制到蹦床的序言部分,并在跳到蹦床时修复跳转(如果它在范围内)。当前示例将变成这样:
0: e9 ef be ad de jmp hook_callback
.... nops all the way down ...
b: 90 nop
trampoline:
100: 55 push ebp
101: 89 e5 mov ebp,esp <-
103: 89 e5 mov ebp,esp |
105: 89 e5 mov ebp,esp |
107: 89 e5 mov ebp,esp |
109: 90 nop |
10a: 90 nop |
10b: 7f f4 jg 0x101 ---
让我们看一个更复杂的例子,它还需要一个 `jmp` 表条目在蹦床中:
Original function:
145804c [1]: 55 push ebp <--
145804d [2]: 8b ec mov ebp, esp | <-
145804f [2]: 74 fb je 0x145804c -- | <-
1458051 [2]: 74 ea je 0x145803d ----- |
1458053 [2]: 74 fa je 0x145804f ---------
1458055 [2]: 8b ec mov ebp, esp
1458057 [2]: 8b ec mov ebp, esp
1458059 [2]: 8b ec mov ebp, esp
Trampoline:
c11a20 [1]: 55 push ebp <-
c11a21 [2]: 8b ec mov ebp, esp |
c11a23 [2]: 74 fb je 0xc11a20 --- <-
c11a25 [2]: 74 07 je 0xc11a2e ---- |
c11a27 [2]: 74 fa je 0xc11a23 -- | --
c11a29 [5]: e9 27 66 84 00 jmp 0x1458055 |
c11a2e [5]: e9 0a 66 84 00 jmp 0x145803d <-
这些 `jmp` 使移动序言部分变得复杂。我们必须将整个部分作为一个整体移动,然后将条件 `je` 重定向到更大的 `jmp`,一旦它被重定位到蹦床。这是因为 `je` 只有 +/- 127 字节的偏移量,并且蹦床缓冲区恰好分配在该距离内的可能性极小。因此,这种扩展序言的解决方案是可行的,但要重定向所有 `jmp` 以保留代码流并在每条指令的偏移量范围内,会变得非常复杂。这在 polyhook V2 中得到了实现。
间接序言 (Indirect Prologue) (2)
结果发现编译器喜欢优化!在发布模式下,许多调用不是直接指向函数。而是先指向一个 `jmp` 表。以下展示了这一点:
foo();
typical asm:
call foo
optimized asm:
jmp 0x0
jmp table:
0: jmp foo_implementation <- jmp to actual guts of foo
5: jmp bar_implementation
10: jmp foobar_implementation
...
因此,挂钩会失败,因为这个
void (*pFnFoo)() = &foo;
不会指向 `foo` 的主体,而是指向 `jmp` 表中的 `jmp`,在那里事情会变得非常糟糕,`jmp` 表会被破坏,其他看似随机的函数会做什么,谁知道,因为它们现在指向了未知的地方。修复方法是跟踪这些 `jmp` 直到我们到达代码。这也修复了多次挂钩函数的问题,因为第二次挂钩将只是跟踪第一个回调并挂钩回调,在运行时在汇编中链接回调挂钩……是不是很棒。
栈被修改 (Stack touched) (3)
56360477b000 [1]: 55 push rbp
56360477b001 [3]: 48 89 e5 mov rbp, rsp
56360477b004 [3]: 89 7d fc mov dword ptr [rbp - 4], edi
56360477b007 [4]: 83 7d fc 00 cmp dword ptr [rbp - 4], 0
56360477b00b [2]: 7e 15 jle 0x56360477b022
56360477b00d [5]: b8 0f 00 00 00 mov eax, 0xf
56360477b012 [1]: 50 push rax <- oopsies just overwrote edi
56360477b013 [a]: 48 b8 4d 5a 53 04 36 56 00 00 movabs rax, 0x563604535a4d
56360477b01d [4]: 48 87 04 24 xchg qword ptr [rsp], rax
56360477b021 [1]: c3 ret
在 x64 的 polyhook V1 中,使用了 `push`、`mov`、`xchg`、`ret` 的指令组合来 `jmp` 回原始函数,并且该指令组合中的 push 会覆盖栈上的值。这导致挂钩函数之间的行为差异很难诊断。在 V2 中,通过使用 FF 25 样式的 `jmp<font color="#007000" face=""Segoe UI",Arial,Sans-Serif">.</font>` 来修复了这个问题。
ff 25 ef be ad de jmp [0xdeadbeef]
deadbeef: &original_function
您可以看到,没有涉及栈或寄存器使用,所以没问题。它确实混合了代码和数据,因为 `jmp` 的目标实际上是写入内存中某个位置的*.text*段……通过细致的记录和 V2 中的操作,这是没问题的,我将此数据写入蹦床的最后,数据永远不会被意外执行为代码。
错误时序言损坏 (Malformed Prologue on Errors)
可能会发生各种错误,导致挂钩在修改汇编的过程中途失败。内存分配可能失败,反汇编器可能遇到错误的指令,我们可能无法解析 `jmp` 等。如果在 V1 中出现这些情况之一,汇编代码将处于部分覆盖的状态,用户需要自行修复。这是糟糕的设计。在 V2 中,所有挂钩逻辑都操作缓存的指令字节缓冲区。写入时,它们会写入缓冲区(每个指令有一个小缓冲区)。只有当挂钩操作结束并且我们确信一切正常时,才会实际写入这些字节缓冲区并修改原始汇编。作为额外的好处,这些功能已合并到 Capstone 'next' 中。现在,与 V1 不同,PolyHook 不需要 Capstone 的分支版本就能正常工作。
蹦床创建竞态条件 (Trampoline Creation Race Condition)
V1 中的 API 旨在简单。您调用 setup,然后 hook,然后调用一个方法来获取分配的蹦床以调用原始函数:
Detour detour;
detour.setup(&hookMe, &myCallback);
detour.hook();
pTrampoline = detour.getOriginal();
然而问题在于,您只能在挂钩函数之后才能获得已分配蹦床的指针。因此,很有可能在您调用 `hook` 和填充 `pTrampoline` 变量之间,您的回调就会被调度。如果发生这种情况,那么回调就会触发并尝试调用 `pTrampoline`,它将持有无效值。然后您就会崩溃。蹦床的分配发生在 `hook()` 例程内部,所以在 V1 中没有简单的修复方法。然而在 V2 中,接口被改变了。构造函数现在将 `pTrampoline` 作为构造函数参数,并在挂钩提交到内存之前为您填写它。因为您传递的蹦床变量在挂钩覆盖原始函数之前被填充,所以您可以保证您的回调只在您的蹦床变量有效时才被触发。
Detour detour(&hookMe, &myCallback, &trampoline)
detour.hook();
矢量化异常和矢量化继续处理程序 (Vectored Exception and Vectored Continue Handlers)
要实现抛出异常的挂钩类型,`PolyHook` 需要注册一个异常处理程序。这个异常处理程序需要捕获异常,以便它可以调用回调并像挂钩从未抛出异常一样恢复。这是通过 API 完成的:
PVOID WINAPI AddVectoredExceptionHandler(
_In_ ULONG FirstHandler,
_In_ PVECTORED_EXCEPTION_HANDLER VectoredHandler
);
它接受一个指向将在异常发生时调用的函数的指针,并且可能多种挂钩类型会产生不同的异常,但它们都会被路由到同一个处理程序。如果我们查看 MSDN 的备注,它首先说的是:
引用备注
处理程序不应调用获取同步对象或分配内存的函数,因为这可能会导致问题。通常,处理程序将简单地访问异常记录并返回。
现在让我们看看 V1 处理程序代码的第一行:
std::lock_guard<std::mutex> <span class="pl-c1">m_Lock</span>(m_TargetMutex);
哎呀,这是未定义的。V2 修复了这个问题。还有另一种有趣的异常处理程序类型,即 `VectoredContinueHandler`。当异常抛出时会引发 `VectoredExceptionHandler`,但当另一个处理程序决定返回 `EXCEPTION_CONTINUE_EXECUTION` 时会引发 `VectoredContinueHandler`。调试器在您单击“播放”(而不是单步执行)时会返回此值。这是一个检测 BP 挂钩是否被使用或是否已附加调试器的不错方法。这是一篇关于这些东西的优秀文章。
开发过程中我还发现了 C++ 异常抛出的一个秘密魔法数字:
0xE06D7363: // this is ExceptionInfo->ExceptionRecord->ExceptionCode;
断点类型和大小 (BP Type and Size)
当您设置硬件断点时,调试器实际上会将断点的类型、命中地址以及命中大小写入您 CPU 的特殊寄存器中。这个位置(实际上应该说有多个位置,它有多个寄存器)是 `Dr0`-`Dr7`。您每个线程最多可以设置 4 个断点,`Dr0`-`Dr3` 存储您要断点的地址,`Dr7` 中的一些位控制它们是否启用、它们的类型和大小。在 V1 中,我有一个 Bug,我没有正确设置 `Dr7` 中的位。我写入了命中地址,然后通过写入以下内容启用了断点:
switch (m_regIdx) {
case 0:
ctx.Dr0 = (decltype(ctx.Dr0))m_fnAddress;
break;
case 1:
ctx.Dr1 = (decltype(ctx.Dr1))m_fnAddress;
break;
case 2:
ctx.Dr2 = (decltype(ctx.Dr2))m_fnAddress;
break;
case 3:
ctx.Dr3 = (decltype(ctx.Dr3))m_fnAddress;
break;
}
ctx.Dr7 |= 1ULL << (2 * m_regIdx);
这告诉 CPU 打开一个硬件断点并在地址 `m_fnAddress` 处命中,但没有说明是读取、写入还是执行时命中,也没有说明它应该监视的内存大小。要做到这一点,我需要:
ctx.Dr7 &= ~(3ULL << (16 + 4 * m_regIdx)); //00b at 16-17, 20-21, 24-25, 28-29 is execute bp
ctx.Dr7 &= ~(3ULL << (18 + 4 * m_regIdx)); // size of 1 (val 0), at 18-19, 22-23, 26-27, 30-31
这设置了一个 1 字节的断点,以便在执行时命中。供参考,这里是 `Dr7` 的位布局,来自:
https://wiki.osdev.org/CPU_Registers_x86#Debug_Registers
bit | 描述 |
---|---|
0 | 本地 DR0 启用 |
1 | 全局 DR0 启用 |
2 | 本地 DR1 启用 |
3 | 全局 DR1 启用 |
4 | 本地 DR2 启用 |
5 | 全局 DR2 启用 |
6 | 本地 DR3 启用 |
7 | 全局 DR3 启用 |
16-17 | DR0 类型 |
18-19 | DR0 大小 |
20-21 | DR1 类型 |
22-23 | DR1 大小 |
24-25 | DR2 类型 |
26-27 | DR2 大小 |
28-29 | DR3 类型 |
30-31 | DR3 大小 |
引用00b 条件表示执行断点,01b 表示写入监视点,11b 表示读/写监视点。10b 保留给 I/O 读/写(不支持)。
目前,我仍然通过同一个线程调用 `setthreadcontext` 来设置调试寄存器,这根据 Microsoft 的说法是未定义的。我猜测这没问题,因为我只设置调试寄存器,并且在我的任何测试中从未遇到失败,但我没有进行深入分析来检查这是否真的没问题。
未能找到 IAT 占位符 (Finding IAT Thunks Failed)
在 V1 中,IAT 挂钩有时会失败,因为它找不到导入。这是因为我犯了一个错误,只遍历了我自己进程的 IAT,而没有遍历它加载的其他模块。如果您想解析条目的占位符,您必须递归地进行。一个进程加载了几个模块(我称之为 DLLs),这些 DLLs 导出了一些条目。但这些 DLLs 本身也有 IAT 并且可以加载其他东西,这些东西也……然后……您明白了。这就是我的错误所在,我在 V1 中天真地只深入了一个级别,所以它有时无法找到 API。我还使用了*dbghelp.lib*来查找 `IMPORT_DIRECTORY_ENTRY_IMPORT`,这很好,但增加了一个依赖项。所以修复方法是遍历 PEB 来查找所有加载的模块,然后为每个加载的模块遍历其 IAT。
`peb` 存储了一个模块的链表在 `Peb`->`Ldr`->`InLoadOrderModuleList`,您可以从中获取图像基址。然后获取 IAT,您将图像基址转换为 `DosHeader`,然后转到 `DosHeader`->`e_lfanew`,它就是 `NTHeader`->`OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]`。您还需要仔细检查 `null` 指针,因为 IAT 中的某些字段取决于编译器而被清零。完整代码在 github 上。
结果是 V2 现在可以正确且递归地搜索 IAT(列表限制为只显示少量 API):
Module: PolyHook_2.exe
--DLL: KERNEL32.dll
----API: GetStdHandle
----API: IsDebuggerPresent
----API: OutputDebugStringA
----API: AddVectoredExceptionHandler
----API: RemoveVectoredExceptionHandler
----API: SetThreadStackGuarantee
----API: GetConsoleScreenBufferInfo
--DLL: MSVCP140.dll
----API: ?_Getgloballocale@locale@std@@CAPEAV_Locimp@12@XZ
----API: ?always_noconv@codecvt_base@std@@QEBA_NXZ
----API: ?tolower@?$ctype@D@std@@QEBADD@Z
----API: ?tolower@?$ctype@D@std@@QEBAPEBDPEADPEBD@Z
----API: ?_Getcat@?$ctype@D@std@@SA_KPEAPEBVfacet@locale@2@PEBV42@@Z
----API: ?in@?$codecvt@DDU_Mbstatet@@@std@@QEBAHAEAU_Mbstatet@@PEBD1AEAPEBDPEAD3AEAPEAD@Z
----API: ?out@?$codecvt@DDU_Mbstatet@@@std@@QEBAHAEAU_Mbstatet@@PEBD1AEAPEBDPEAD3AEAPEAD@Z
--DLL: VCRUNTIME140.dll
----API: strrchr
----API: _purecall
----API: __std_terminate
----API: __std_type_info_destroy_list
----API: memchr
----API: memmove
----API: strchr
--DLL: api-ms-win-crt-runtime-l1-1-0.dll
----API: _seh_filter_dll
----API: _configure_narrow_argv
----API: _initialize_narrow_environment
----API: _initialize_onexit_table
----API: _register_onexit_function
----API: _execute_onexit_table
----API: _crt_atexit
--DLL: api-ms-win-crt-heap-l1-1-0.dll
----API: _callnewh
----API: free
----API: realloc
----API: calloc
----API: _set_new_mode
----API: malloc
--DLL: api-ms-win-crt-utility-l1-1-0.dll
----API: rand
----API: srand
----API: qsort
--DLL: api-ms-win-crt-math-l1-1-0.dll
----API: _dtest
----API: __setusermatherr
----API: pow
----API: _fdtest
--DLL: api-ms-win-crt-stdio-l1-1-0.dll
----API: _set_fmode
----API: _get_stream_buffer_pointers
----API: fclose
----API: fflush
----API: fgetc
----API: fgetpos
----API: __stdio_common_vsprintf
--DLL: api-ms-win-crt-filesystem-l1-1-0.dll
----API: _lock_file
----API: _unlock_file
--DLL: api-ms-win-crt-string-l1-1-0.dll
----API: isalnum
----API: tolower
----API: strncpy
----API: strncmp
--DLL: api-ms-win-crt-time-l1-1-0.dll
----API: strftime
----API: _gmtime64_s
----API: _time64
--DLL: api-ms-win-crt-convert-l1-1-0.dll
----API: atoi
--DLL: api-ms-win-crt-locale-l1-1-0.dll
----API: _configthreadlocale
Module: ntdll.dll
[!]ERROR:PEs without import tables are unsupported
Module: KERNEL32.DLL
--DLL: api-ms-win-core-rtlsupport-l1-1-0.dll
----API: RtlVirtualUnwind
----API: RtlUnwindEx
----API: RtlRestoreContext
----API: RtlLookupFunctionEntry
----API: RtlInstallFunctionTableCallback
----API: RtlRaiseException
----API: RtlDeleteFunctionTable
--DLL: ntdll.dll
----API: RtlSizeHeap
----API: RtlLCIDToCultureName
----API: RtlUnicodeStringToInteger
----API: _wcslwr
----API: RtlGetUILanguageInfo
----API: EtwEventEnabled
----API: RtlpConvertLCIDsToCultureNames
--DLL: KERNELBASE.dll
----API: lstrlenA
----API: BaseFormatObjectAttributes
----API: GetVolumeNameForVolumeMountPointW
----API: AppContainerFreeMemory
----API: AppContainerLookupMoniker
----API: BasepNotifyTrackingService
----API: MoveFileWithProgressTransactedW
--DLL: api-ms-win-core-processthreads-l1-1-0.dll
----API: GetProcessTimes
----API: GetProcessId
----API: GetThreadId
----API: GetCurrentProcess
----API: GetCurrentProcessId
----API: GetThreadPriority
----API: GetThreadPriorityBoost
--DLL: api-ms-win-core-processthreads-l1-1-3.dll
----API: GetProcessInformation
----API: SetProcessInformation
----API: SetThreadIdealProcessor
----API: GetProcessShutdownParameters
--DLL: api-ms-win-core-processthreads-l1-1-2.dll
----API: GetThreadIOPendingFlag
----API: SetThreadInformation
----API: GetSystemTimes
----API: GetThreadInformation
----API: SetProcessPriorityBoost
----API: GetProcessPriorityBoost
--DLL: api-ms-win-core-processthreads-l1-1-1.dll
----API: GetProcessHandleCount
----API: SetProcessMitigationPolicy
----API: GetProcessMitigationPolicy
----API: SetThreadIdealProcessorEx
----API: GetThreadIdealProcessorEx
----API: GetThreadContext
----API: GetThreadTimes
--DLL: api-ms-win-core-registry-l1-1-0.dll
----API: RegLoadMUIStringW
----API: RegLoadMUIStringA
----API: RegNotifyChangeKeyValue
----API: RegLoadKeyA
----API: RegGetValueA
----API: RegFlushKey
----API: RegEnumValueW
--DLL: api-ms-win-core-heap-l1-1-0.dll
----API: HeapCreate
----API: HeapWalk
----API: HeapAlloc
----API: GetProcessHeap
----API: HeapFree
----API: HeapUnlock
----API: HeapSetInformation
--DLL: api-ms-win-core-heap-l2-1-0.dll
----API: LocalFree
--DLL: api-ms-win-core-memory-l1-1-1.dll
----API: QueryMemoryResourceNotification
----API: CreateMemoryResourceNotification
----API: GetLargePageMinimum
----API: GetProcessWorkingSetSizeEx
----API: GetSystemFileCacheSize
----API: SetProcessWorkingSetSizeEx
----API: SetSystemFileCacheSize
--DLL: api-ms-win-core-memory-l1-1-0.dll
----API: MapViewOfFileEx
----API: OpenFileMappingW
----API: MapViewOfFile
----API: CreateFileMappingW
----API: VirtualQueryEx
----API: VirtualQuery
----API: VirtualProtectEx
... AND SO ON ...
编译器优化 WTF 的时刻 (Compiler Optimization WTF moments)
优化编译器曾经是我最好的朋友……我们现在已经分道扬镳了。
-
编译器可能会内联您获取了函数指针的函数,导致您的指针指向另一个代码块的中间。这可能是因为函数指针从未被调用,而是被用来获取汇编地址以进行修改。将函数标记为 `__declspec(noinline)`。
- 如果编译器发现一个未被调用的函数,它可能会完全删除该函数,使您留下一个指向无效内存的悬空指针。WTF 编译器!?!?使用 `__declspec(noinline)` 和大量 volatile 变量似乎可以解决问题。此外,添加 `printf` 或其他具有副作用的函数调用也可以阻止这种行为。
- 编译器可能会重新排序语句,使其以不同的顺序执行。众所周知,但这让我吃过几次苦头。标记为 volatile 有时可以解决这个问题……
- 编译器可能会删除对未使用的变量或参数的读写操作。将所有内容标记为 volatile。
- 发布模式下的调用有时会通过 `jmp` 表进行间接调用。为什么?
结论
挂钩真的很难,但很有趣。