众所周知内联挂钩用途广泛,我们小学二年级就学过使用Detours或MinHook这样知名而优秀的第三方库,抑或手动在函数入口覆写一条跳转指令即可实现。后者的弊端倒是容易窥见,但前者中提到的Detours作为微软的官方手笔却没有成为一边倒的首选,在我见过的一些大型企业IT项目的实践中,让其它第三方Hook库(如MinHook、mhook)占了一席之地。
我知道这是各个地方企业IT的实践所出真知,而理论上的缘由我还是想自己去找一找,作为微软的迷弟也想知道为何Detours不尽人意。于是有了现在这半纸拙笔作为答卷,以及综合改进后的结果SlimDetours,供相互学习交流。
正文介绍4个内联挂钩时值得考虑的问题,分别在Detours、MinHook、mhook三者间做对比,以及在成果SlimDetours中的实现。在SlimDetours的技术Wiki中有对应原文,将随项目保持更新。
原文: 技术Wiki:应用内联钩子时自动更新线程
内联挂钩需要修改函数开头的指令实现跳转,为应对有线程正好运行在要修改的指令上的可能,需要更新处于此状态的线程避免其在修改指令时执行非法的新老共存的指令。
Detours提供了DetourUpdateThread函数更新线程,但需要由调用方传入需要进行更新线程的句柄:
DetourUpdateThread
也就是说,需要由调用方遍历进程中除自己以外的所有线程并传入给此函数,用起来比较复杂且不方便。
Detours更新线程非常精细,它通过使用GetThreadContext与SetThreadContext准确地调整线程上下文中的PC(程序计数器)到正确位置,实现参考Detours/src/detours.cpp于4b8c659f · microsoft/Detours。
GetThreadContext
SetThreadContext
但Detours对线程的更新在x64下仍有遗漏的地方,参考我为此提交的PR #344: Improve thread program counter adjustment。
[!TIP]虽然它的官方示例“Using Detours”中有DetourUpdateThread(GetCurrentThread())这样的代码,但这用法无意义且无效,应使用其更新进程中除当前线程外的所有线程,详见DetourUpdateThread。但即便以正确的方式更新线程,也会带来一个新的风险,见 技术Wiki:更新线程时避免堆死锁。
DetourUpdateThread(GetCurrentThread())
MinHook做得比较好,它在挂钩(和脱钩)时调用CreateToolhelp32Snapshot获取其它线程并自动更新它们,然后像Detours一样准确地更新线程上下文中的PC(程序计数器)。
mhook在挂钩(和脱钩)时调用NtQuerySystemInformation获取其它线程并自动更新它们。但更新线程的方式相对笨拙,若线程正好位于要修改指令的区域则等待100毫秒,最多尝试3次,实现参考mhook/mhook-lib/mhook.cpp于e58a58ca · martona/mhook:
SlimDetours获取其它线程的方式有两个,以NT5为目标时同样调用NtQuerySystemInformation,而以NT6+为目标(默认)时则采用NtGetNextThread以大幅提升性能和正确性保障。
NtGetNextThread
线程的更新沿袭了Detours并进行了一些修正和改进。
要点:
完整实现参考KNSoft.SlimDetours/Source/SlimDetours/Thread.c于main · KNSoft/KNSoft.SlimDetours。
原文: 技术Wiki:更新线程时避免堆死锁
原版Detours使用了CRT堆(通过new/delete),更新线程时如果挂起了另一个也使用此堆且正持有堆锁的线程,Detours再访问此堆就会发生死锁。
new/delete
Raymond Chen在博客“The Old New Thing”的文章《Are there alternatives to _lock and _unlock in Visual Studio 2015?》中详细讨论的挂起线程时出现CRT堆死锁问题正是同一个场景,也提到了Detours,这里引用其原文不再赘述:
Furthermore, you would be best served to take the heap lock (HeapLock) before suspending the thread, because the Detours library will allocate memory during thread suspension.此外,最好在挂起线程前占有堆锁(HeapLock),因为Detours库将在线程挂起期间分配内存。
SlimDetours提供了示例:DeadLock演示Detours死锁的发生与在SlimDetours中得到解决。
其中一个线程(HeapUserThread)不断调用malloc/free(等效于new/delete):
HeapUserThread
malloc/free
另一个线程(SetHookThread)不断使用Detours或SlimDetours挂钩和脱钩:
SetHookThread
[!NOTE]SlimDetours会自动更新线程(参考 技术Wiki:应用内联钩子时自动更新线程),所以不存在DetourUpdateThread这样的函数。
同时执行这2个线程10秒,然后发送停止信号(g_bStop = TRUE;)后再次等待10秒,如果超时则大概率发生死锁,将触发断点,可以在调试器中观察这2个线程的调用栈进行确认。例如指定使用Detours运行此示例"Demo.exe -Run DeadLock -Engine=MSDetours",以下调用栈可见堆死锁:
g_bStop = TRUE;
"Demo.exe -Run DeadLock -Engine=MSDetours"
使用SlimDetours运行此示例"Demo.exe -Run DeadLock -Engine=SlimDetours"则能顺利通过。
"Demo.exe -Run DeadLock -Engine=SlimDetours"
mhook使用VirtualAlloc分配内存页代替HeapAlloc分配堆内存,是上文末尾提到的一个解决方案。
VirtualAlloc
HeapAlloc
MinHook与SlimDetours都新创建了一个私有堆供内部使用,避免此问题的同时也节约了内存使用:
[!NOTE]Detours已有事务机制,所以此堆无需序列化访问。
MinHook在其初始化函数MH_Initialize中创建,而SlimDetours在首个被调用的内存分配函数中进行一次初始化时创建,故没有也不需要单独的初始化函数。
MH_Initialize
原文: 技术Wiki:分配Trampoline时避免占用系统保留区域
挂钩库分配Trampoline时一般优先从挂钩目标函数附近寻找可用的内存空间,如此挂钩系统API时十分可能占用系统DLL使用的区域,导致本应加载到该位置的系统DLL加载到别地并额外进行重定位操作。
Windows自NT6起引入ASLR,随之为系统DLL在用户模式下明确地预留了一段区域,使得同一个系统DLL在不同进程中都能映射到这片保留区域的同一位置,加载一次后即可复用该次重定位信息避免后续加载再次进行重定位操作。
这个机制在《Windows Internals 7th Part1》第五章《Memory management》的“Image randomization”小节有详细说明,此处不再赘述,我参考该书并经过分析ntoskrnl.exe!MiInitializeRelocations得到的确切保留范围是:32位进程:[0x50000000 ... 0x78000000),共640MB64位进程:[0x00007FF7FFFF0000 ... 0x00007FFFFFFF0000),共32GB
ntoskrnl.exe!MiInitializeRelocations
即使没有ASLR,也可以考虑从顶部保留一定大小的区域。
Detours作为微软官方的挂钩库,已考虑到系统保留区域不能给Trampoline使用这一点,但它硬编码了仅适用于NT5的[0x70000000 ... 0x80000000]地址范围进行规避:
此范围仅适用于NT5,64位NT5中ntdll.dll、kernel32.dll、user32.dll也仍在此范围内。
同样注意到此问题的jdu2600为Detours开了一个非官方的PR microsoft/Detours PR #307 想更新这个范围以适配最新的Windows。
MinHook与mhook都是熟知的Windows API挂钩库,遗憾的是它们似乎都没有考虑到这个问题。
32位系统ASLR的预留范围大小仅640MB,直接规避即可。而对于64位系统则复杂一些,ASLR的预留范围有32GB,太大而不可能全部规避。结合ASLR的排布规则和Trampoline的选址需求,视Ntdll.dll之后1GB范围为要规避的保留范围是合理的,这个考虑与上面提到的PR一致。要注意这个范围可能被分成两块,例如以下场排布场景:
Ntdll.dll
Ntdll.dll被ASLR随机加载到保留范围内较低的内存地址,后续DLL随后排布触底时,将切换到保留范围顶部继续排布,在这个情况下“Ntdll.dll之后的1GB范围”便是2块不连续的区域。
SlimDetours的具体实现与规避范围均有别于上述PR,为不同NT版本进行了更周到的考虑,比如在NT6.0及NT6.1中ASLR可以被注册表HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management项MoveImages键设置关闭。还调用NtQuerySystemInformation了获得比硬编码更确切的用户地址空间范围,协助约束Trampoline的选址,参考KNSoft.SlimDetours/Source/SlimDetours/Memory.c于main · KNSoft/KNSoft.SlimDetours。
HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management
MoveImages
NtQuerySystemInformation
原文: 技术Wiki:实现延迟挂钩
通常挂钩DLL中函数的做法需要先将对应的DLL加载到进程空间并定位它的地址(例如,使用LoadLibraryW + GetProcAddress)。
LoadLibraryW
GetProcAddress
对于为特定程序设计的钩子,通常它们的目标函数将迟早被调用,DLL也是进程需要的,所以早些加载对应的DLL没什么问题。而对于被注入到不同进程中的钩子(尤其是全局钩子),它们不知道各个进程是否需要此DLL,所以通常它们仍将DLL加载到各个进程空间并挂钩函数,即使进程本身并不想要这个DLL。
试想一下一个有不少依赖项的全局钩子试图挂钩各种系统DLL的函数,则会将所有涉及的DLL都带入到所有进程进行加载和初始化,开销极大。
“延迟挂钩”是此问题的一个好方案。即如果目标DLL已加载则立即执行挂钩,否则等到目标DLL加载到进程的时候挂钩。
显然,实现“延迟挂钩”的关键是在第一时间获得加载DLL的通知。“DLL加载通知”机制自NT6被引入,这正是我们需要的。
参考LdrRegisterDllNotification函数,DLL加载(与卸载)的通知将被发送给由此函数注册的回调,并且DLL映射的内存区域在那时可用,同时我们可以进行挂钩。
尽管Microsoft Learning提示相关API可能会被更改或删除,但它们的用法一直没变,仅自NT6.1将所持的锁由LdrpLoaderLock变为了专用的LdrpDllNotificationLock。总之,请保持回调尽可能简单。
LdrpLoaderLock
LdrpDllNotificationLock
[!TIP]如果你想了解Windows上“DLL加载通知”的内部实现,参考我为ReactOS贡献的ReactOS PR #6795。不要参考WINE的实现,因为它截至此文编写时存在错误,例如,它的LdrUnregisterDllNotification没有检查节点是否处于链表中就进行了移除。
LdrUnregisterDllNotification
示例:DelayHook演示了使用此机制实现在DLL加载时挂钩其中函数。
很久以前问过我的一个导师,当时他简单回答了一句“Detours有时不稳定”,然后顿了顿补充“别的有时也不稳定,不一样”。之后我没有细问,也没有细究,毕竟直到后来当我在某处主笔时,才有去权衡。即使只站在企业安全的角度,或者只站在追求稳定的角度,权衡的结果都未必只有一个。
经过这番“一探”,绝不是“哪一个Hook库更好”可以断下的结论,毕竟之前也说了这只是“半纸”答卷。相信看到这里,也会冒出更多问题——
我对这两个问题答案的猜想都和它们的名字有关,一个要“Min”,一个要“Microsoft”,便有不得不妥协的地方。那我再起一个SlimDetours便是,Detours的骨架最好,以此为基础由C++改为C,再补过上面正文的4个问题,便不比已有的轮子差了,至此可告一段落。实践出真知,与理论一致后才觉得更牢靠。
后面还有很多可以完善的点,比如反汇编引擎的更新、对CFG的考虑、内核态的支持……
最后,有怀疑、错漏的地方欢迎挑战和指出,以及各种意见和建议,就如高质量代码也正是在来回的CR中铸就的一样。本人最近在考虑新的工作机会,如有技术上合适的地方尚希不吝相告。
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 (CC BY-NC-SA 4.0) 进行许可。
Ratin <ratin@knsoft.org>中国国家认证系统架构设计师ReactOS贡献者
LONG
WINAPI DetourUpdateThread(_In_
HANDLE
hThread);
while
(GetThreadContext(hThread, &ctx))
{
...
if
(nTries < 3)
// oops - we should try to get the instruction pointer out of here.
ODPRINTF((L
"mhooks: SuspendOneThread: suspended thread %d - IP is at %p - IS COLLIDING WITH CODE"
, dwThreadId, pIp));
ResumeThread(hThread);
Sleep(100);
SuspendThread(hThread);
nTries++;
}
(!g_bStop)
p =
malloc
(4);
(p != NULL)
free
(p);
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
signed 支持!非winapi的挂钩支持怎么样? fastcall以及类函数挂钩
fengyunabc 感谢分享。2.2和2.3的问题我以前都遇见过,最终也是采取也类似作者使用的方法解决。
blowfish 系统不支持,hook很难做到100%安全。 比如正在执行detour_thread_suspend()时,某个线程调用了CreateThread()又搞出来一个新线程
NoHeart 那 VEH Hook 可以替代这inline Hook 吗?