几周前,Windhawk,即Windows程序的定制市场,已经发布。你可以阅读公告(https://ramensoftware.com/windhawk)以了解更多细节和创建它背后的动机。在这篇文章中,我将重点介绍我在实现Windhawk的技术方面的历程。如果你喜欢读代码而不是读文字,可以看看演示实现(https://github.com/m417z/global-inject-demo)。
Windhawk允许创建mods,这些mods是被编译成DLLs的C++片段,并被加载到第三方程序中以进行定制。技术上的挑战是要能够在所需进程的背景下加载这些DLL。例如,我们可以创建一个MOD,钩住MessageBoxW)WinAPI函数,并定义该MOD应适用于所有进程。

Windhawk实现了一个被注入到所有进程中的mod管理器。将一个DLL注入到所有进程中并不是一项新的任务,它以前已经被反病毒软件、定制工具和其他程序做过多次。就我所知,这些是最常见的方法。
使用内核驱动 – 可以在这里找到一个很好的概念验证实现(https://github.com/wbenny/injdrv)。
使用 * SetWindowsHookEx (https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowshookexw)* – 可以用来安装一个钩子程序来监视系统的某些类型的事件。只适用于加载user32.dll的进程。限于同一桌面中的进程。对UWP应用程序有限制。
使用AppInit_Dlls (https://docs.microsoft.com/en-us/windows/win32/dlls/secure-boot-and-appinit-dlls)* – 一个传统的基础设施,为定制的DLLs提供了一个简单的方法,可以加载到每个交互式应用程序的地址空间。只适用于加载user32.dll的进程。从Windows 8开始,当安全启动被启用时,AppInit_DLLs基础设施被禁用。
使用深奥的、无文件记录的钩子 – 你可以在Alex Ionescu的Hooking Nirvana(https://www.youtube.com/watch?v=pHyWyH804xE)演讲中看到几个这样的例子。
这些是我对全局注入解决方案的目标。
- 最少的权限* – 我希望Windhawk即使没有管理员权限也能运行。而且,一般来说,我更希望避免安装一个对我来说太具侵入性的驱动程序,而且会影响系统的稳定性。
最小的干扰性 – 我更希望避免修改系统文件或注册表项,在内存中做所有的工作,这样所有的改变都是暂时的,不会有对系统造成永久性损害的风险。
最少的限制 – 我努力允许尽可能多的程序进行定制。例如,我试图找到一个解决方案,不限于加载user32.dll的进程,而且对UWP应用程序没有限制。
*通用的解决方案 – 我寻找一个能在所有或大多数Windows版本上工作的解决方案,并且在未来不太可能停止工作。
另外,值得列举的是,该解决方案的一些非目标。
- 隐蔽性* – DLL注入经常被恶意软件滥用,他们的目标之一是尽可能长时间地不被发现。为了实现这一目标,恶意软件作者试图找到新的注入方法,这些方法不为安全厂商所知,也不会被安全软件检测到。由于我的项目没有恶意,隐藏注入方法是没有必要的。事实上,我更倾向于采用一种尽可能透明的标准解决方案。
安全 – DLL注入经常被安全软件使用。例如,一个反病毒软件可能决定拦截所有的文件访问,并限制对敏感文件的访问。在这种情况下,确保限制不能被绕过是很重要的。我的项目没有安全问题,不需要保护它不被绕过。
寻找最佳方法
我开始寻找最适合我的目标的方法。下面是一个表格,它总结了我的发现(注意,这些并不是肯定/否定的标准,表格主要是一种判断)。
Minimal privileges | Minimal intrusiveness | Minimal limitations | Universal solution | |
---|---|---|---|---|
A kernel driver | ❌ | ❌ | ✅ | ✅ |
SetWindowsHookEx | ✅ | ✅ | ❌ | ✅ |
AppInit_Dlls | ❌ | ✅ | ❌ | ❌ |
Esoteric hooks | ❌ | ❌ | ✅ | ❌ |
在这四种方法中,SetWindowsHookEx的方法似乎是最适合的,但它有其局限性,我希望能避免。另外,使用SetWindowsHookEx感觉像是滥用了一个为不同目的而设计的工具,因为我必须选择一个事件来获得通知,即使我不需要任何事件。
经过一番思考,我决定尝试另一种方法。不使用专门的全局注入机制,而是为单个进程实现注入。然后,用它来实现全局注入,如下所示。
- 最初,枚举所有进程并注入每个进程。
- 对于每个被注入的进程,拦截新的进程的创建(例如,通过钩住CreateProcess (https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw) WinAPI函数)并注入到每个新创建的进程中。
这种方法看起来相当明显,实施起来也很简单,但实际上有各种棘手的细节需要处理。我将在这篇文章中介绍这些细节。我确信这种方法以前就已经实现和使用过了,但我没有找到一个完全有效的实现,我可以把它作为一个参考。
将DLL注入到一个进程中
通常情况下,进程注入遵循这些步骤。内存分配,内存写入,代码执行。我使用了经典而直接的注入方法。
- VirtualAllocEx用于在目标进程中分配内存。
- WriteProcessMemory用于将代码写入分配的内存中。
- CreateRemoteThread用于在目标进程中创建一个新的线程来运行所写的代码。
注入的代码加载DLL,实现所需的任务。
这种注入方法是非常古老的,也是众所周知的,互联网上有很多关于它的教程和例子,所以我不会进一步阐述。
将一个DLL注入到所有进程中
如前所述,我们的想法是枚举所有的进程,并将DLL注入到每个进程中。为了确保DLL也被加载到新创建的进程中,拦截新进程的创建,并注入到每个新创建的进程中。

一个简单的实现可以在这里找到(https://github.com/m417z/global-inject-demo/tree/686b81b8ed70ababad350f4438eb10023c49443c)。关于这个实现的一些说明。
- 当以管理员身份启动时,该程序会启用调试权限(https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/debug-privilege)。这允许将DLL注入到系统服务中。因此,这可以将DLL注入到以管理员身份启动的新创建的进程中,因为这些进程实际上是由AppInfo服务创建的,所以需要钩住其CreateProcessInternalW函数。详情请参考Pavel Yosifovich的博文Parent Process vs. Creator Process(https://scorpiosoftware.net/2021/01/10/parent-process-vs-creator-process/)。
- CreateRemoteThread不允许(https://stackoverflow.com/questions/494284/createremotethread-32-64-and-or-64-32)从一个32位进程在一个远程64位进程中创建线程。wow64ext库(https://github.com/rwfpl/rewolf-wow64ext)被用来克服这一限制。
- 在Windows 7中,如果目标进程与调用进程处于不同的会话中,CreateRemoteThread会失败。一个解决方法是使用NtCreateThreadEx代替(https://securityxploded.com/ntcreatethreadex.php)。
- 为了拦截新进程的创建,CreateProcessInternalW被钩住了。看起来所有记录的进程创建函数最后都会调用它。
- CreateProcessA → CreateProcessInternalA → CreateProcessInternalW
- CreateProcessW → CreateProcessInternalW
- CreateProcessAsUserA → CreateProcessInternalA → CreateProcessInternalW
- CreateProcessAsUserW → CreateProcessInternalW
- MinHook库(https://github.com/TsudaKageyu/minhook)用于钩住CreateProcessInternalW, MessageBoxW函数。
- 被注入后,DLL等待事件的信号,然后卸载自己。
- 关于编译和运行说明,请参考资源库的README文件。
乍一看,它似乎工作得很好,看起来相当完整。但在仔细检查并经过一些仔细的测试后,我发现有几个限制必须解决。
无法进入的流程和失败的注入
即使注入程序以管理员身份运行,即使调试权限被启用,也有一些进程是无法达到的。Windows中的几个核心系统进程被标记为受保护进程,顾名思义,它们被保护起来不被篡改,注入程序无法将DLL注入其中。这本身并不是一个问题,这些进程被保护是有原因的,我对不能篡改它们没有意见。真正的问题是,因为它们被保护了,CreateProcessInternalW没有被钩住,没有机会将DLL注入到被保护进程所创建的进程中,即使创建的进程本身没有被保护。

例如,你可以在下面的截图中看到services.exe是一个受保护的进程。因此,DLL不会被注入到注入程序后启动的子svchost.exe进程中。已经运行的svchost.exe进程由进程枚举处理。

当注入程序不是以管理员身份运行时,也存在类似的问题—它不能将DLL注入到升高的进程中,这是一个安全限制,这没有问题,但它也失去了注入由升高的进程创建的未升高的进程的机会。
例如,在下面的截图中,Windows Explorer通过任务管理器被重新启动。新的explorer.exe进程是由winlogon.exe创建的,它是高架的。

另一个常见的例子是,当一个进程崩溃时,升高的进程会创建一个不升高的进程。请看下面的截图,来自Gal De Leon的演讲《利用Windows错误报告中的错误》(https://msrnd-cdn-stor.azureedge.net/bluehat/bluehatil/2019/assets/doc/Exploiting%20Errors%20in%20Windows%20Error%20Reporting.pdf)。在这种情况下,注入程序错过了将DLL注入到未提升的WerFault.exe的机会。WerFault.exe可能反过来重新启动崩溃的程序(https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-registerapplicationrestart),它也会被错过。

我想出的解决方案是让注入程序监控新进程的创建,对于每个新创建的进程,尝试从注入程序注入到其中。
如果新进程是由一个无法访问的进程创建的,注入程序就会注入DLL,如下图所示。

另一方面,如果新进程是由一个注入了DLL的进程创建的,那么在创建进程和注入程序之间就会出现竞赛。我使用了一个mutex来确保只有其中一个注入DLL,如下图所示。

这种方法可行,但它有一个严重的缺点—如果新进程是由一个不可访问的进程创建的,那么DLL就会被异步注入,可能是在新进程开始运行之后,根据定制的用例,这可能太晚了。不幸的是,我没有找到更好的解决方案,由于这个问题不是很常见(特别是如果注入程序是以管理员身份运行的),所以它不是太糟糕。
另外,这个解决方案产生了一个新的问题,下面将介绍这个问题,它是在注入程序过早注入DLL时发生的。
过早注入的风险
在实施上述解决方案后,我注意到有时新进程无法启动。经过一番调查,我发现这只发生在控制台程序上。经过进一步调查,我找到了根本原因。
所有用户模式的线程都在LdrInitializeThunk函数中开始执行。进程运行的第一个线程在执行转移到用户提供的线程入口点之前,会执行进程初始化任务。进程初始化任务之一是在该进程是一个控制台进程的情况下创建控制台窗口。关于LdrInitializeThunk函数的更多细节,请查看Ken Johnson的这篇博文(http://www.nynaeve.net/?p=205)。
通常情况下,一个新的进程以一个暂停的线程开始。然后,客户/服务器运行时子系统(csrss.exe)得到关于刚刚创建的新进程的通知,并进行自己的处理。最后,暂停的线程被恢复,LdrInitializeThunk函数执行进程初始化任务并将执行转移到进程入口点。
由于注入程序过早地注入了DLL,新进程像往常一样,以一个暂停的线程开始。但在客户/服务器运行时子系统(csrss.exe)得到通知之前,注入程序在新进程中创建了一个新的线程,并立即开始执行(在下面的图片中用红色标记)。作为第一个运行的线程,它执行进程的初始化任务。只有在这时,csrss.exe才得到关于新进程的通知,但它并不期望该进程有一个初始化的控制台,并返回一个错误。

为了克服这个问题和其他由早期注入的线程执行引起的潜在问题,我从用CreateRemoteThread创建一个新的线程转为在进程还没有开始执行的情况下排队等待APC(异步程序调用)。关于APC的一篇伟大的技术博文,请看APC系列。Ori Damari的《用户APC API》(https://repnz.github.io/posts/apc/user-apc/)。关于实现的一些说明:
- 用了未记录的NtQueueApcThread函数,因为记录的QueueUserAPC函数由于激活上下文的处理而不适合进程间APC排队。
- NtQueueApcThread不允许在远程64位进程中从32位进程中排队购买APC。wow64ext库(https://github.com/rwfpl/rewolf-wow64ext)被用来克服这个限制。
- 对于从64位进程在远程32位进程中排队的APC,必须对地址参数进行编码。我使用了APC系列。KiUserApcDispatcher和Wow64(https://repnz.github.io/posts/apc/wow64-user-apc/)的博文,由Ori Damari作为参考。
但注入程序如何知道进程是否开始执行(然后像以前一样使用CreateRemoteThread)或不执行(然后排一个APC)?它检查是否只有一个线程,如果是的话,它是否用RtlUserThreadStart的指令指针暂停。在这种情况下,它得出结论,该进程没有开始执行,并排队等待APC,而不是创建一个远程线程。
支持UWP应用程序
我注意到的下一件事是,DLL没有被注入到UWP应用程序的进程中,如Windows Calculator。加载DLL的代码被成功注入,但DLL加载失败,出现ERROR_ACCESS_DENIED。问题是,UWP应用程序对文件系统的访问是有限的,它们没有权限加载DLL。改变DLL文件的权限可以解决这个问题。例如,可以使用以下命令来改变DLL文件的权限,使UWP应用程序能够加载它:
icacls global-inject-lib.dll /grant everyone:RX
icacls global-inject-lib.dll /grant *S-1-15-2-1:RX
icacls global-inject-lib.dll /grant *S-1-15-2-2:RX
另一个问题是,注射程序和UWP应用程序之间不能通过使用相同的互斥名称来共享互斥。UWP应用程序是沙盒的,每个UWP应用程序都有自己的对象目录。一个UWP应用程序不能以名称引用其对象目录之外的对象。我能够通过使用鲜为人知的私有命名空间API(https://docs.microsoft.com/en-us/windows/win32/api/namespaceapi/nf-namespaceapi-createprivatenamespacew)来克服这一限制。关于Windows中命名对象的伟大概述,包括UWP沙盒和私有命名空间,请查看James Forshaw的博文《Windows NT上BaseNamedObjects的简史》(https://www.tiraniddo.dev/2019/02/a-brief-history-of-basenamedobjects-on.html)。
流程缓解政策和系统错误
另一种情况是,DLL没有被注入到进程中的情况是,进程有一个缓解策略,限制图像加载到已签名的图像。在我测试的Windows 10机器上有两个这样的进程:fontdrvhost.exe和主持DiagTrack服务(diagtrack.dll)的svchost.exe。

与UWP的情况类似,加载DLL的代码被成功注入,但DLL加载失败,这次是ERROR_INVALID_IMAGE_HASH。但与UWP的情况不同,没有直接的解决方法。我可以尝试使用反射式DLL注入(https://github.com/stephenfewer/ReflectiveDLLInjection)(从内存中手动加载DLL),但我没有理会,因为它使解决方案复杂化,而且可能会有陷阱,因为能够定制程序的好处不多,反正不是很有趣。
我对不能用这种缓解措施来定制程序没有意见,但这种限制有一个令人不快的副作用。在某些情况下,当DLL加载失败时,Windows会显示一个系统错误。

he system error can be reproduced by running the following program and then running the injection program:
通过运行以下程序,然后运行注入程序,可以重现系统错误。
#include <windows.h>
int WINAPI WinMain(
HINSTANCE hInstance,
HINSTANCE hPrevInstance,
LPSTR lpCmdLine,
int nShowCmd
)
{
PROCESS_MITIGATION_BINARY_SIGNATURE_POLICY p = { 0 };
p.MicrosoftSignedOnly = 1;
if (SetProcessMitigationPolicy(ProcessSignaturePolicy, &p, sizeof(p))) {
MessageBox(NULL, L"Mitigation applied, press OK to exit", L"", MB_OK);
}
}
After some investigation, I found that this behavior can be controlled with the SetErrorMode, SetThreadErrorMode WinAPI functions. I used SetThreadErrorMode to turn off the critical-error-handler message box while trying to load the DLL.
Hooking performance
After handling all of the limitations above, the solution felt pretty solid and I didn’t encounter any other problems. But after using the computer with it for a while, I noticed that it takes noticeably longer for some programs to launch. The reason for this was that MinHook, the hooking library that I used, enumerates all the threads on the system and looks for threads that belong to the current process to suspend them. Enumerating all system threads can be very slow, on my system it took more than 300 milliseconds. I improved this by doing the following:
Instead of enumerating all threads on the system, I use the undocumented NtGetNextThread function to directly enumerate threads that belong to the current process. In addition to improving performance, it also improves stability by avoiding race conditions. For a comprehensive overview, check out the Suspending Techniques research by diversenok.
When injecting into a process which didn’t start executing yet, I skip the thread enumeration altogether, since there should be no other running threads anyway.
You can find the code that enables this in my MinHook multihook branch. Among other changes the branch has is the ability for a function to be hooked more than once. In general, I found that reliable function hooking is more tricky than it might seem at first. For example, consider what happens if a DLL sets a hook and then needs to be unloaded. When is it safe to unload it? Can you be sure? But that’s a topic for another post.
Implementation code and summary
An implementation that handles all the limitations mentioned in this post can be found here. I’m pretty satisfied with the result. I’ve been using my computer with Windhawk, which uses this global injection and hooking implementation, for several months, and I didn’t experience any stability, performance, or any other problems. I hope that Windhawk will prove itself as a reliable tool for customizing Windows programs, and I invite you to try it out.