- DLL 注入和劫持利用 Windows 中的搜索顺序和库加载机制,在合法进程中执行恶意代码。
- 进程注入技术(远程 DLL、直接代码、APC、空心进程、反射 DLL)利用了 OpenProcess、VirtualAllocEx 和 CreateRemoteThread 等 API。
- 该检测结合了内存分析、API 调用监控和 DLL 加载控制,并使用了 EDR、数字签名和进程监视器等工具。
- 预防措施包括安全编码、使用绝对路径、AppLocker/WDAC 等策略以及 DEP、ASLR 和 CFG 等内存缓解措施。
当我们谈到Windows 系统中的 DLL 注入和 DLL 劫持时,我们不仅仅是在探讨一个技术难题,而是攻击者最常用的系统渗透手段之一,他们利用这些手段窃取数据,并在不引起怀疑的情况下以更高的权限运行程序。这是一系列由来已久的攻击技术,但它们仍然非常有效,因为它们利用了 Windows 将库加载到内存中的底层机制。
如果你曾经好奇为什么一个恶意 DLL 文件就能让整个组织瘫痪,原因就在这里:只需一个合法进程加载一个被篡改的库,攻击者的代码就能继承其所有权限,绕过许多杀毒软件,在最糟糕的情况下,甚至可以在系统中潜伏数月之久。接下来,我们将详细分析其工作原理、存在的各种技术、它们带来的实际风险,以及你可以采取哪些措施来检测和减轻其影响。
什么是 DLL?为什么它对攻击者如此重要?
动态链接库 (DLL)本质上是一个共享库,其中包含可供多个应用程序同时重用的代码和数据。Windows 广泛使用 DLL 来避免代码重复,并更有效地管理内存、磁盘空间和系统资源。
从用户的角度来看,这并不神秘:它们不像普通程序那样打开;应用程序运行时会透明地使用它们。然而,对于系统而言,它们是至关重要的组件:它们包含各种函数、图形资源、驱动程序以及各种使软件能够正常运行的例程。
这些文件大多以.dll结尾,但也可能以 .drv、.drov 甚至 .exe 可执行文件的形式出现,作为库文件运行。单个文件可以被多个进程同时调用,这意味着如果该文件遭到入侵,攻击者可以同时渗透多个应用程序。
这就是它的妙处(也是问题所在):如果你设法让一个进程加载一个被篡改的 DLL,那么该库中的代码将以该进程的权限运行,无论它是用户应用程序、特权服务,甚至是本应保护你的安全软件。
DLL劫持:概念与操作
DLL劫持是一种利用某些Windows应用程序查找所需库的方式进行攻击的技术。许多程序并非总是指定每个DLL的确切路径,而是让系统按照其默认的搜索顺序进行查找。
当应用程序尝试仅通过名称加载DLL 文件,而未提供完整路径,也未验证其完整性或来源时,就会出现问题。如果攻击者设法将一个与预期 DLL 文件同名的恶意文件放置在可访问的目录中,Windows 系统可能会在加载合法文件之前加载伪造的文件,从而使攻击者控制进程。
这项技术自Windows 2000时代就已存在,至今仍然有效,因为其基本搜索机制几乎没有改变:系统会按照特定顺序扫描多个目录,直到找到目标文件。攻击者正是利用了这一顺序,将他们修改后的文件版本置于列表的更上方。
在现实世界中,DLL劫持已被用于供应链攻击,通过入侵受信任的供应商来分发经过数字签名但恶意的DLL,就像SolarWinds事件中发生的那样,一个简单的修改后的库引发了对美国政府机构的大规模入侵。
Windows 系统中 DLL 搜索顺序以及漏洞被利用的位置
当未指定完整路径时,Windows 应用程序依赖于标准的 DLL 搜索顺序。此行为还取决于是否启用了“安全 DLL 搜索模式”。
启用安全搜索后,系统会优先选择更可靠的路线。通常的顺序是:
- 应用程序加载目录.
- 系统目录(通常为 C:\Windows\System32)。
- 16 位系统目录。
- Windows目录。
- 用户当前工作目录。
- 环境变量中定义的目录 PATH.
如果安全搜索被禁用,用户的当前目录在搜索层级中会向上移动,并优先于某些系统路径进行检查。这种细微的改变足以让攻击者放置在工作目录中的 DLL 文件取代合法的 DLL 文件。
假设某个应用程序需要位于C:\Windows\System32 目录下的特定 DLL 文件,但其代码并未明确指定该路径。系统会首先查找应用程序所在的目录,然后(取决于配置)查找当前目录或其他目录,最后才找到 System32 目录。如果攻击者在此过程中留下了一个修改过的副本,应用程序就会打开它,误以为它是原始文件。
这被称为DLL 搜索顺序劫持。攻击方式很简单:将恶意 DLL 放置在 Windows 扫描顺序优先于合法目录的目录中。实现这一目标的方法多种多样:社会工程、网络钓鱼、写入共享路径、通过供应商部署等等。
DLL劫持的常见变体
基本概念(将恶意DLL注入到正常的程序加载过程中)衍生出多种技术变体。它们的目标都相同,但区别在于将库插入进程的具体方式。
其中一个漏洞利用了KnownDLLs,这是一个通过 Windows 注册表管理的已知系统 DLL 列表。当应用程序请求这些库时,加载器首先会查询这个特殊的列表。攻击者可以通过操纵相关的注册表项来绕过 KnownDLLs 筛选器,并强制系统将名称解析为他们自己恶意版本的 DLL。
另一种常见的技术是DLL 侧加载,它利用了 WinSxS 目录(C:\Windows\WinSxS)。该目录包含同一库的多个版本,以防止应用程序之间发生冲突。程序会查询一个“清单”文件,该文件指示了它可以使用的库版本。攻击者可以通过篡改 DLL 的元数据,使其看起来像是清单中指定的有效版本,从而将伪造的 DLL 插入到该目录中,进而导致程序加载其伪造的版本。
还有一种叫做“幽灵DLL劫持”的攻击方式,它利用一些老旧或很少使用的库,这些库即使不再必要,某些应用程序仍然会在启动时尝试加载它们。如果攻击者创建一个具有该“被遗忘的”名称的DLL文件,并将其放置在正确的路径中,他们就可以在无人察觉的情况下执行恶意代码,因为原始文件通常根本不存在。
在所有情况下,目标都是一样的:将伪造的 DLL 文件放置在正确的位置、正确的时间,并使用目标进程会查找的名称,以便操作系统在合法进程中加载并执行攻击者的代码。
DLL注入:攻击者强制将DLL加载到内存中。
除了被动劫持搜索顺序之外,还有主动向进程注入 DLL 的方法。在这种情况下,攻击者不会等待程序执行错误的操作,而是利用 Windows API 强制进程加载其库。
这个想法简单却威力强大:通过将一个 DLL 文件注入到正在运行的进程中,恶意软件可以继承该进程的权限并访问其内存空间。之后,它可以记录键盘输入、截取屏幕截图、窃取数据、拦截对安全功能的调用、安装更多恶意软件或修改应用程序的行为。
注入通常涉及几个阶段:准备 DLL(使用DllMain在加载时执行有效载荷)、在目标进程中预留内存以存储该 DLL 的路径、使用WriteProcessMemory写入该路径,最后创建一个远程线程,该线程在目标进程中调用LoadLibraryA。
注入的 DLL 会在该进程的上下文中执行,并使用其安全令牌和信任级别。这使得恶意代码能够绕过基于文件的安全控制(因为恶意代码仅存在于内存中),或者从其自身进程中禁用保护解决方案。
编写恶意 EXE 文件和 DLL 文件之间的区别
当恶意软件开发者决定创建可执行文件(.exe)或 DLL 文件时,入口点和执行模型会发生显著变化。在典型的 EXE 文件中,系统会在启动进程时调用主函数(或 WinMain);而在 DLL 文件中,流程并非如此。
动态库使用一个名为DllMain的特殊函数,它是 DLL 加载、卸载或在其映射进程中创建/销毁线程时的入口点。加载器不会将 DLL 作为独立程序“启动”,而只是在各种事件发生时调用 DllMain。
许多合法的 DLL会导出各种函数供其他模块使用。相比之下,专门用于注入的恶意 DLL 可能只会实现包含必要代码的 DllMain 函数,而不会导出任何函数,因此它会在加载后立即执行,无需任何后续的显式调用。
这意味着攻击者必须谨慎地编译和链接他们的 DLL:添加哪些依赖项,使用哪些运行时选项,以及如何管理不同 Windows 版本之间或 32 位和 64 位架构之间的兼容性。
恶意DLL的典型开发环境
要编译测试 DLL 或实际的恶意载荷,通常会使用Windows 上的 Visual Studio(包括Visual Basic 项目)或 Linux 上的跨平台工具,例如 mingw-w64。在前一种情况下,只需创建一个 C/C++ 项目并将其配置为动态库,IDE 就会自动生成必要的结构。
在构建选项中,通常会调整代码生成部分。例如,您可以在开发过程中选择多线程调试 (/MTd)以包含调试信息,然后在最终版本中切换到多线程 (/MT),将运行时库集成到二进制文件本身,这样无需外部依赖项即可更轻松地在更多机器上运行。
在 Linux 系统中,可以使用x86_64-w64-mingw32-gcc编译器生成 Windows 可执行文件和 DLL 文件。通常会添加优化标志,例如 -O2,使用 -s 减小文件大小,以及对函数和数据进行分段(分别使用 -ffunction-sections 和 -fdata-sections),以在链接过程中消除无用代码。
诸如`-static-libstdc++` 和 `-static-libgcc`之类的选项也常用于静态链接 C 和 C++ 标准库,从而使生成的二进制文件更加独立。其他参数,例如 `-fno-exceptions`、`-fmerge-all-constants` 和 `-fpermissive`,则有助于微调编译器行为并减少不必要的代码。
远程 DLL 注入:技术分步指南
一种经典的攻击手段是远程DLL注入,攻击者控制的进程会将一个库注入到另一个合法进程中。通常,该攻击流程会调用几个关键的Windows API。
首先,恶意进程可以启用SE_DEBUG_PRIVILEGE调试权限,从而获得操控其他进程的权限。然后,它使用OpenProcess 函数打开目标进程的句柄,并请求诸如 PROCESS_CREATE_THREAD 或 PROCESS_VM_WRITE 之类的权限,以便写入目标进程的内存并创建线程。
接下来,攻击者使用VirtualAllocEx在目标进程中分配内存,并将要注入的 DLL 的完整路径存储在该内存中。然后,攻击者使用WriteProcessMemory将此路径写入新分配的内存区域,这样目标进程的地址空间中就拥有了一个包含恶意 DLL 路径的缓冲区。
要加载 DLL,必须在当前进程内调用LoadLibrary 函数。该函数位于 kernel32.dll 中,通常在同一台机器上的所有进程中都映射到相同的地址。攻击者使用GetModuleHandle获取模块句柄,然后使用GetProcAddress定位 LoadLibrary 的确切地址。
最后,它使用CreateRemoteThread创建一个远程线程,并将 LoadLibrary 地址指定为入口点,并将指向包含 DLL 路径的缓冲区指针作为参数。当该线程在目标进程中启动时,它会使用该路径执行 LoadLibrary,将恶意 DLL 加载到其内存空间并触发其 DllMain 函数。
其他工艺注入技术及其危险性
远程 DLL 注入只是问题的一部分。进程注入涵盖多种变体,但它们的目标都相同:使另一个进程在其自身上下文中执行任意代码。
其中最常见的是直接代码注入,它不加载 DLL,而是将原始 shellcode 写入目标进程的内存。流程类似:启动进程,使用 VirtualAllocEx 分配内存,使用 WriteProcessMemory 写入有效载荷,然后使用 CreateRemoteThread 或其他机制触发其执行。
另一种变种是线程劫持,它不创建新线程,而是接管现有线程。攻击者使用 `SuspendThread` 暂停线程,使用 `GetThreadContext` 读取线程上下文,修改指令指针使其指向自己的代码,然后使用 `ResumeThread` 恢复线程。这样,恶意代码就能伪装成合法线程执行。
异步过程调用 (APC) 注入使用QueueUserAPC 将一个例程放入处于等待状态的线程的队列中。当该线程处理其 APC 队列时,它会执行注入的代码,而不会明显地创建新的远程线程。
反射式 DLL 注入尤其隐蔽,因为 DLL 本身包含一个内置加载器,能够在不写入磁盘或使用标准 LoadLibrary 机制的情况下将自身映射到内存。这使得基于文件系统活动和模块加载事件的检测变得极其困难。
最后,所谓的进程空心化会创建一个处于挂起状态的合法进程,擦除其原始内存映像(使用诸如 NtUnmapViewOfSection 之类的函数),分配新的内存,将恶意 PE 代码写入其中,调整进程头和重定位信息,使用 SetThreadContext 修正主线程上下文,然后恢复执行。对系统而言,看起来合法的应用程序仍在运行,但实际上,其内部已被替换为攻击者的代码。
Windows 中的进程内存基础知识
要理解这些技术为何有效,必须了解Windows 中进程虚拟内存的结构。每个进程都有自己独立的虚拟地址空间,32 位地址空间的范围大致从 0 到 0x7FFFFFFF,而 64 位地址空间的范围则要大得多。
这片空间被划分为不同的区域:程序的可执行代码、已加载的 DLL、线程栈、堆以及其他保留区域。系统的内存管理器负责通过分页机制将这些虚拟地址映射到物理内存帧。
每个区域都有相关的访问保护机制,定义了可以对其执行的操作:PAGE_READ、PAGE_WRITE、PAGE_EXECUTE,以及 PAGE_EXECUTE_READWRITE 等组合。如果进程尝试未经授权的访问,Windows 将生成访问冲突。
注入攻击技术利用这些保护机制,通过请求具有特定权限的内存、使用VirtualProtect 或 VirtualProtectEx修改属性、使用 ReadProcessMemory 从其他进程读取数据或使用 VirtualFreeEx 释放内存区域等方式来攻击。许多 EDR 检测都依赖于观察这些进程间调用中的可疑字符串。
总而言之,通过 API 管理的隔离内存模型使得一个进程只要获得适当的权限,就很容易“触及”另一个进程的内存,恶意软件很容易利用这一点,同时试图躲避防御工具的侦测。
使用 AppInit_DLL 实现持久化及其相关风险
除了单次注入之外,一些恶意软件还会滥用 Windows 机制,使其在每次启动或运行特定应用程序时都能保持持久存在。一个典型的例子就是利用AppInit_DLLs注册表项。
通过修改此密钥,攻击者可以指示几乎所有使用 user32.dll 的进程加载特定的 DLL 文件——也就是说,几乎所有带有图形界面的应用程序都会加载该 DLL 文件。结果是,每次程序打开时,恶意 DLL 都会在应用程序开始正常执行之前自动注入。
在实际样本中观察到的一种典型模式是,恶意可执行文件会创建另一个文件(例如,一个新的 .exe 文件),并使用Windows 任务计划程序安排其定期执行。该任务会在每次系统启动时运行,第二个二进制文件会将恶意 DLL 写入磁盘,并将 AppInit_DLLs 配置为指向该文件。
从那时起,每个图形进程都会携带该 DLL 文件,它可以检查正在加载的应用程序,并决定是采取行动还是悄悄地从内存中移除自身以避免被发现。例如,它可能会忽略像计算器这样的进程,但会留在浏览器或电子邮件客户端中,以窃取凭据和敏感信息。
这种持久性会导致恶意代码在用户与应用程序交互之前很久就执行,因此很难乍一看就检测到,并且允许攻击者对用户环境进行相当深入的控制。
检测 DLL 劫持和注入
检测这些技术并非易事,因为在很多情况下,恶意代码仅存在于内存中,并在合法进程内执行。然而,多种方法结合使用,可以大大提高捕获异常行为的几率。
最直接的方法之一是使用 Process Monitor 或 Process Explorer 等工具分析 DLL 加载活动。通过筛选与以 .dll 结尾的文件相关的操作,并查找返回“文件未找到”等结果的操作,您可以查看进程何时尝试在预期系统目录之外查找库文件。
通过对加载路径应用额外的筛选条件(例如,使用应用程序自身的目录而不是 System32 目录),您可以识别出在合法路径之外加载的外部 DLL 文件。这份文件列表是检查库文件是否被篡改的良好起点。
另一方面,许多现代 EDR 解决方案会监控可疑的 API 模式:诸如 OpenProcess → VirtualAllocEx → WriteProcessMemory → CreateRemoteThread 之类的序列,或者对 QueueUserAPC 和 SetThreadContext 的大量调用会触发行为规则。
扫描还会分析每个进程内的可执行内存区域,查找具有执行权限但内容与原始二进制文件预期代码不匹配的页面。内存扫描器可以检测到异常跳转、奇怪的重定位或不应该存在的 shellcode 块。
在取证领域,检查内存转储并将已加载 DLL 的数字签名与其磁盘版本进行比较,可以提供清晰的篡改线索。如果内存中的库与其已签名的磁盘版本不同,则表明可能存在恶意注入或修补。
预防和加强针对 DLL 注入的措施
完全屏蔽系统免受 DLL 注入和库劫持攻击非常复杂,但可以设置屏障,使攻击者的工作更加困难,并减少即使他们成功入侵所造成的影响。
首先,软件开发人员必须重视这个问题:应用程序在加载关键 DLL 文件时必须指定绝对路径,避免依赖系统的搜索顺序来确定文件位置。此外,建议在接受库文件之前验证其数字签名。
在系统层面,AppLocker 或 Windows Defender 应用程序控制 (WDAC)等工具允许您限制哪些可执行文件和 DLL 可以加载。结合代码完整性策略,它们可以阻止未签名模块或具有不受信任签名的模块,从而防止许多劫持尝试。
我们绝不能忘记内存保护措施,例如 DEP(数据执行保护),它可以防止从标记为仅数据的区域执行代码;或者 ASLR(地址空间布局随机化),它可以使模块的确切地址难以确定,并降低某些漏洞利用的可靠性。
控制流保护 (CFG)等其他缓解措施可确保间接代码调用不会跳转到任意位置,而受保护的进程机制 (PPL) 可限制哪些进程可以向其他进程打开处理程序,从而加强跨进程注入的攻击面。
最后,在组织层面,必须保持防病毒软件和 EDR 更新,定期检查可执行文件和 DLL 所在目录的写入权限,并培训员工最大限度地减少允许第一个恶意 DLL 植入系统的初始途径(网络钓鱼、附件、从可疑来源下载)。
DLL注入、库劫持和进程注入等技术,总体而言,构成了一套老练但极其有效的攻击手段,攻击者能够利用Windows模块加载机制的漏洞,将代码隐藏在受信任的进程中,从而提升权限并绕过传统的安全控制。深入了解DLL的工作原理、应用程序如何查找DLL、用于操作内存和进程的API以及可用的预防和监控机制,对于营造一个截然不同的环境至关重要:一个被篡改的DLL就能造成肆意破坏的环境,还是任何渗透尝试都会留下明显的痕迹,以便防御系统能够及时发现并阻止攻击。