导语:多年来,DLL 劫持一直是我们研究的核心事物。 在这段时间里,我们探索了一些深层次的问题,这些问题使得这项技术很难在现实世界中实际应用。 我们的实现已经扩展到包括导出表克隆、动态 IAT 补丁、堆栈遍历和运行时表重构。
多年来,DLL 劫持一直是我们研究的核心事物。 在这段时间里,我们探索了一些深层次的问题,这些问题使得这项技术很难在现实世界中实际应用。 我们的实现已经扩展到包括导出表克隆、动态 IAT 补丁、堆栈遍历和运行时表重构。 我们在我们开设的Dark Side Ops课程中详细探讨了这些技术的细节,我们想在这篇文章中分享一些知识。
如果你之前“理解” DLL 劫持的原理和技术,那你只需要在你自己的实验室里,让执行失败的劫持操作正常工作。
看看 Koppeling项目,墙裂推荐你真的应该读一读这些代码。
DLL 劫持回顾
这篇文章不会涵盖 DLL 劫持的基础知识。 我们希望你熟悉DLL 模块搜索顺序,KnownDLLs,“安全搜索”等概念。 如果你需要复习一下相关的知识,这里有一些链接:
https://resources.infosecinstitute.com/dll-hijacking-attacks-revisited/
https://pentestlab.blog/2017/03/27/dll-hijacking/
https://liberty-shell.com/sec/2019/03/12/dll-hijacking/
https://astr0baby.wordpress.com/2018/09/08/understanding-how-dll-hijacking-works/
https://www.sans.org/cyber-security-summit/archives/file/summit-archive-1493862085.pdf
https://posts.specterops.io/lateral-movement-scm-and-dll-hijacking-primer-d2f61e8ab992
此外,还有一些用于发现和利用劫持行为的工具:
https://github.com/cys3c/Siofrahttps://github.com/Cybereason/siofra
https://github.com/cyberark/DLLSpy
https://github.com/MojtabaTajik/Robber
如果你是第一次了解 DLL 劫持,那么你会看到一段相当简单的示例代码,这个示例代码很容易利用。 大概是这样:
void BeUnsafe() { HMODULE module = LoadLibrary("functions.dll"); // ... }
然后,我们只需要将一些邪恶的代码放在“ functions.dll”里,并将这个 dll 文件放到正确的路径中。 最终会触发 DllMain 函数的执行,我们可以这样写:
BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) { if (reason != DLL_PROCESS_ATTACH) return TRUE; // Do evil stuff system ("start calc.exe"); return TRUE; }
这里有几个重要的原因解释了为什么利用上面这段代码进行劫持是没有多大意义的。 对于每一个原因,我们将在下文中逐一详细说明。
· 我们没有维护源进程的稳定性。 在大多数情况下,它会因为我们的劫持而退出、崩溃或其他不当行为。 毕竟,加载这个 DLL 可能是有原因的。
· 我们没有在源进程中维护代码的执行。 作为第一个原因的扩展,我们只是在外部执行了 calc。 我们不在乎进程是否持续运行,甚至不在乎“打开 shell”之后发生了什么。
· 我们不关心加载器锁。 因为我们的入口点非常简单,所以我们不必担心在 DllMain 中执行复杂的代码时,加载器锁是锁止的(这种情况可能比较危险)。
· 我们不必担心导出名称。 因为这种劫持是由 LoadLibrary 导致的,所以我们的恶意 DLL 不需要包含任何特定的导出名称或序号。
如果你之前有过实际的劫持操作,但是劫持失败了,那么这很可能是因为上面四个原因中的某一个(或者许多个)所致。 我们在 DLL 劫持上花费了很多时间,并且我们编写了许多工具和代码片段,我们将在后面分享,这些工具和代码让我们变得更智能。
触发点执行
有两个主要的“触发点(sink)” ,DLL 执行可以从这两个触发点中产生。 不必在意我这里使用了“触发点(sink)”这个词汇,名字并不重要,但是我们需要同样的术语来保持一致。 这两个触发点都是由 ntdll.dll 中的模块加载器(LDR)提供的。 如果你对作为 DLL 加载过程的一部分而执行你的有效载荷感兴趣,那么那你需要调用 ntdll!LdrpCallInitRoutine,从而触发恶意的 DllMain(evil!DllMain) 函数的执行。
静态触发点 (IAT)
导致 DLL 初始化的最明显的原因是 DLL 包含在依赖关系图中。 具体来说,它是所需模块的导入地址表(IAT)的成员。 这很可能发生在进程初始化期间(ntdll!LdrpInitializeProcess) ,但也可能发生在动态加载中。
在这里,子系统只是计算特定加载事件所需的所有依赖项,并按照顺序进行 DLL 初始化。 但是,在将执行传递给新模块之前,将检 DLL 的导出表,以确保 DLL 提供预期的功能。 这是通过比较子模块的 EAT 并将这些地址补丁到父模块的 IAT 中来完成的。 一个典型的调用堆栈如下所示:
ntdll!LdrInitializeThunk <- 新的进程开始执行 ntdll!LdrpInitialize ntdll!_LdrpInitialize ntdll!LdrpInitializeProcess ntdll!LdrpInitializeGraphRecurse <- 建立依赖关系图 ntdll!LdrpInitializeNode ntdll!LdrpCallInitRoutine evil!DllMain <- 执行被传递给外部代码
动态触发点 (LoadLibrary)
在一个类似但明显不同的进程中,活动代码要求在没有指定所需函数的情况下初始化一个新模块。 因此,ntdll!LdrLoadDll 将忽略目标模块的导出表。 接下来是 GetProcAddress,它尝试识别运行时使用的特定函数,但并不总是这样。
依赖关系图将在其根节点上根据请求的模块进行计算,加载事件将如上所述发生。 这个调用堆栈看起来像这样:
KernelBase!LoadLibraryExW <- 要求动态模块加载 ntdll!LdrLoadDll ntdll!LdrpLoadDll ntdll!LdrpLoadDllInternal ntdll!LdrpPrepareModuleForExecution ntdll!LdrpInitializeGraphRecurse <- 建立依赖关系图 ntdll!LdrpInitializeNode ntdll!LdrpCallInitRoutine evil!DllMain <- 执行被传递给外部代码
划重点
静态触发点的劫持操作更加复杂。因为在我们控制执行之前,我们需要确保我们的导出表能够提供父模块所需的导入名称。 另外,在我们控制执行的时候,我们的 EAT 中的地址已经被补丁到父模块中了。 这会使任何只在运行时重新构建导出表的解决方案变得复杂。
函数代理
如果要在源进程中维护稳定性,我们就需要代理实际 DLL 的函数(如果有的话)。 这实际上意味着,通过某种方式,将导出表链接到实际的那个 DLL 的导出表。 制作游戏外挂的黑客已经使用这个技术很长时间了,但是就像徒步旅行者和猎人一样,这种知识传播到网络安全领域的速度很慢。 这里有一些参考链接,可以通过不同的方法处理代理:
https://itm4n.github.io/dll-proxying/
https://dl.packetstormsecurity.net/papers/win/intercept_apis_dll_redirection.pdf
https://www.shysecurity.com/post/20130111-Dll%20Proxy
https://kevinalmansa.github.io/application%20security/DLL-Proxying/
https://googleprojectzero.blogspot.com/2018/04/windows-exploitation-tricks-exploiting.html
https://www.synack.com/blog/dll-hijacking-in-2014/
下面是一些实现这些方法的项目:
https://github.com/kevinalmansa/DLL_Wrapper
https://github.com/maluramichael/dll-proxy-generator
https://github.com/oranke/proxy-dll-generator
https://github.com/mcryptzzz/ProxyDllGenerator
https://github.com/advancedmonitoring/ProxyDll
https://www.codeproject.com/Articles/1179147/ProxiFy-Automatic-Proxy-DLL-Generation
https://www.codeproject.com/Articles/16541/Create-your-Proxy-DLLs-automatically
这些技术都通过略微不同的方式实现了相同的结果。 让我们快速了解一些策略,以便更好地理解。
导出转发
PE 文件提供了将导出重定向到另一个模块的简单机制。 我们可以利用这一点,只需将我们的导出名称从真正的 DLL 导出为相同的导出名称。 你可以重命名真正的 DLL 文件,或者只使用完整路径。 大多数使用这个机制的方法是通过链接器指令实现,如下所示:
#pragma comment(linker,"/export:ReadThing=real_dll.ReadThing,@1") #pragma comment(linker,"/export:WriteThing=real_dll.WriteThing,@2") #pragma comment(linker,"/export:DeleteThing=real_dll.DeleteThing") #pragma comment(linker,"/export:DoThing=C:\\Windows\\real.dll.DoThing") // ...
非常简单,我们把工作交给了加载器子系统。 我们正在尝试劫持,这看起来可能有点明显(例如,每个导出都被转发) ,但优点在于它的简单性。 缺点之一是需要修改源代码并构建进程以准备一个 DLL 供劫持使用,我们稍后将解决这个问题。
堆栈补丁
一种同样优雅但更动态的方法是从 DllMain 向后遍历堆栈,并用一个不同的模块句柄替换上面的 LoadLibrary 调用的返回值。 因此,将来任何对查找函数的调用都将完全绕过我们。 读者此时应该不会感到惊讶,但这种技术只适用于动态触发点。 使用静态触发点,LDR 子系统已经验证了我们的导出表,并用其值对 IAT 进行了补丁,并且 LDR 子系统不关心我们必须要对模块句柄做点什么。
Preempt 在一篇关于 Vault7 技术的文章中提到了这一点,但是他们没有详细的解释技术细节。 幸运的是,我们已经疯狂地尝试了这个东西,所以我们编写了一个小的 PoC,可以很好地为任何想要使用它的人演示这个技巧。
PoC 代码:https://gist.github.com/monoxgas/b8a87bec4c4b51d8ac671c7ff245c812
运行时链接
在这里,我们创建一个空白的函数指针列表,并编译导出表来引用它们。这里会有函数名称,但函数本身不会执行到任何有用的地方。 在 DllMain 中获得控制权后,动态加载真正的 DLL,并在运行时重新映射所有函数指针。 这实质上是重新实现了导出转发……但需要编写更多的代码。 在修改源代码和构建进程方面,我们发现仍然有相同的缺点。
hijack.def EXPORTS ReadThing=ReadThing_wrapper @1 WriteThing=WriteThing_wrapper @2 DeleteThing=DeleteThing_wrapper @3
hijack.asm .code extern ProcList:QWORD ReadThing_wrapper proc jmp ProcList[0*8] ReadThing_wrapper endp WriteThing_wrapper proc jmp ProcList[1*8] WriteThing_wrapper endp DeleteThing_wrapper proc jmp ProcList[2*8] DeleteThing_wrapper endp
hijack.cpp extern "C" UINT_PTR ProcList[3] = {0}; extern "C" void ReadThing_wrapper(); extern "C" void WriteThing_wrapper(); extern "C" void DeleteThing_wrapper(); LPCSTR ImportNames[] = { "ReadThing", "WriteThing", "DeleteThing" } BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) { if (reason != DLL_PROCESS_ATTACH) return TRUE; HANDLE real_dll = LoadLibrary( "real.dll" ); for ( int i = 0; i < 3; i++ ) { ProcList[i] = GetProcAddress(real_dll , ImportNames[i]); } return TRUE; }
运行时生成
我们也可以疯狂地在运行时重新构建整个导出地址表。这种情况下,我们在编写代码时不需要知道我们要劫持哪个 DLL,这种方式相对更好一些。 我们也可以添加一个基本的函数,重新实现 Windows 的搜索顺序,尝试动态定位真正的 DLL。 这个函数还可以在当前目录中执行.old和.bak这样的基本修改,以防万一。
hijack.cpp HMODULE FindModule(HMODULE our_dll) { WCHAR our_name[MAX_PATH]; GetModuleFileName(our_dll, our_name, MAX_PATH); // Locate real DLL using our_name if (our_dll != module){ return module; } } void ProxyExports(HMODULE module) { HMODULE real_dll = FindModule(module); // Rebuild our export table with real_dll return; } BOOL WINAPI DllMain(HMODULE module, DWORD reason, LPVOID reserved) { if (reason != DLL_PROCESS_ATTACH) return TRUE; ProxyExports(module); return TRUE; }
这种策略虽然优雅,但却因其动态性而受到影响。 我们不需要再次在静态表中包含导出名称,除非我们显式地添加了静态表(静态触发点)。 此外,我们在其他模块的导入表(IAT)可能已经包含对旧导出表的引用之后接收执行(又是静态触发点)。 除非我们简单地在所有 dll 中添加预期需要的每个导出名称,否则前者(静态触发点)没有简单的修复方法来保持我们的动态性。 为了修复后者(动态触发点),我们需要迭代加载模块并补丁地址到真正的 DLL。 没有什么是代码解决不了的问题,只有令人费解的解决方案。 这个策略的大部分实现可以在下面的 Koppeling 项目中找到。
另一个需要注意的问题是,对导出表的引用和导出表内的引用都是相对虚拟地址(RVA)。 由于他们的大小(DWORD) ,我们只能将新的导出表放置在 PE 基址的 4GB 范围内,除非它能容纳旧的表。不是x86上的问题,但肯定是在x64上。 X86没有问题,但 x64肯定有问题。
划重点
在代理函数方面,导出转发是最简单的解决方案。 这是预备的(我们需要创建带有特定劫持的 DLL) ,装载其子系统负责繁重的搬运工作。 我们可以对准备过程本身做一些很好的改进,这个我们将在后面讨论。 我们喜欢运行时生成的灵活性,但它在静态触发点及其导出名称要包含在磁盘文件中的要求方面存在弱点。 归根结底,我们不妨实现导出转发的自动化。
加载器锁
LDR 子系统保存了进程加载的单个模块列表。 为了解决任何线程共享问题,我们实现了一个“加载器锁” ,以确保一次只有一个线程修改一个模块列表。这与劫持有很大的关系,因为我们通常在 DllMain 内部获得代码执行,当 LDR 子系统仍在处理模块列表时就会发生这种情况。 换句话说,当加载程序锁仍然被锁止时,ntdll 必须将执行传递给我们(并不理想)。 因此,微软提供了一大堆在 DllMain 中不应该做的事情。
· 调用 LoadLibrary 或 LoadLibraryEx (直接或间接地调用 )。 这可能导致死锁或崩溃
· 调用 GetStringTypeA, GetStringTypeEx, 或 GetStringTypeW (直接或间接地调用)。 这可能导致死锁或崩溃
· 与其他线程同步。这可能导致死锁
· 获取一个同步对象,该对象由等待获取加载器锁的代码拥有。 这可能导致死锁
· 使用 CoInitializeEx 初始化 COM 线程。 在一定条件下,这个函数可以调用 LoadLibraryEx
· 调用注册表函数。这些函数在 Advapi32.dll 中实现。如果 Advapi32.dll 在你的DLL之前没有初始化,DLL会访问未初始化的内存并导致进程崩溃
· 调用 CreateProcess。创建一个可以加载另一个 DLL 的进程
· 调用 ExitThread。 在 DLL 卸载期间退出线程可能导致再次获取加载器锁,从而导致死锁或崩溃
· 调用 CreateThread。 如果不与其他线程同步,创建一个线程是可以工作的,但这是有风险的
· 使用动态 C 运行时(CRT)中的内存管理函数。 如果没有初始化 CRT DLL,则调用这些函数可能导致进程崩溃
· 调用 User32.dll 或 Gdi32.dll 中的函数。有一些函数会加载另一个 DLL,这些 DLL 可能没有初始化。
· 使用托管代码
很可怕的一个列表,对吧?
然而,根据我们的经验,这个列表并没有看起来那么糟糕。 例如,在 DllMain 中调用 LoadLibrary 通常是安全的。 实际上,在静态触发点进行劫持期间,只要相同的线程仍处于初始化阶段,就不会重新获取加载器锁。 对 LdrLoadDll 的调用将简单地重新触发依赖关系图的计算和初始化。 这是否意味着微软发布上述列表是错误的? 绝对不是。 他们只是想尽可能地防止问题的发生。
真正的答案是“我能在 DllMain 里面做可疑的事情吗? ” 通常是”要看情况,但不要尝试”。 让我们来看一个 LDR 同步导致死锁的例子:
hijack.cpp DWORD ThreadFunc(PVOID param) { printf("[+] New thread started."); return 1; } BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) { if (reason != DLL_PROCESS_ATTACH) return TRUE; DWORD dwThread; HANDLE hThread = CreateThread(0, 0, ThreadFunc, 0, 0, &dwThread); // Deadlock starts here WaitForSingleObject(hThread, INFINITE); return TRUE; }
无论我们使用哪一种触发点,我们的 DllMain 都会在等待新线程完成时被卡住,要等待新线程执行完成。 你可以在线程的两个调用堆栈中看到这一点:
... ntdll!LdrpCallInitRoutine Theif!DllMain KernelBase!WaitForSingleObjectEx ntdll!NtWaitForSingleObject <- 等待线程
ThreadFunc ntdll!LdrInitializeThunk ntdll!LdrpInitialize ntdll!_LdrpInitialize ntdll!NtWaitForSingleObject <- 等待 LdrpInitCompleteEvent (也可以是 NtDelayExecution/LdrpProcessInitialized != 1)
在动态触发点中,可能会在 LdrpDrainWorkQueue 中看到死锁(因为到那时进程已经初始化)。
ThreadFunc ntdll!LdrInitializeThunk ntdll!LdrpInitialize ntdll!_LdrpInitialize ntdll!LdrpInitializeThread ntdll!LdrpDrainWorkQueue ntdll!NtDelayExecution <- 等待 LdrpWorkCompleteEvent
这个结果令人沮丧,因为启动一个新线程是避免 LDR 冲突的最简单方法。 我们可以在 DllMain 中收集执行,启动一个新线程,并且在进程初始化完成后让我们的恶意代码在那里运行。 为了避免死锁,我们可以像下面这样删除 WaitForSingleObject 的调用:
ThreadFunc BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) { if (reason != DLL_PROCESS_ATTACH) return TRUE; DWORD dwThread; HANDLE hThread = CreateThread(0, 0, ThreadFunc, 0, 0, &dwThread); // WaitForSingleObject(hThread, INFINITE); return TRUE; }
如果进程停留的时间足够长,以便我们的代码执行完成,那么这种情况就会发生,但是实际上这种情况很少发生。 最有可能的情况是,我们将把执行返回到主模块,如果我们没有正确地进行代理,它将很快退出或抛出一个错误。我们的线程将永远没有机会做任何有用的事情。
用钩子维护稳定性
幸运的是,我们确实可以保留足够长的执行时间,并实现一个钩子,这样一旦 LDR 完成,我们就可以尝试接管主模板执行。 我们放置钩子的确切位置取决于我们在执行链中所处的位置。
· 预加载:进程仍在初始化中,执行还没有移交给主模块。在这种情况下,我们可能需要挂起主模块的入口点。
· 后加载:进程已经启动了核心执行,我们可能会因为LoadLibrary调用而被加载。最优的方法是挂起调用堆栈中的最后一个函数,它是主模块的一部分。任何出现的问题/错误都可以忽略。
为了区分这两种情况,可以在堆栈中一直向后搜索。 如果我们找到了主模块的返回地址,我们可能是后加载。 否则,这个过程可能还没有开始,而切入点是我们最好的选择。 当然,我们已经建立了一个概念验证,所以你不必抓狂:
PoC 代码:https://gist.github.com/monoxgas/5027de10caad036c864efb32533202ec
划重点
加载器锁给我们带来了一些挑战,但不是太难的挑战。为任何重要代码启动一个单独的线程是最好的选择。 在需要保持进程处于活动状态以便线程可以继续运行的情况下,我们可以使用函数钩子。
Koppeling
在本文的开头,我们首先介绍了各种复杂的劫持。让我们回顾一下,并将它们与相关的解决方案结合起来:
· 源进程的稳定性:使用函数代理,避免加载器锁定
· 维护进程间的代码执行: 使用代理或函数钩子
· 加载器锁的复杂性:使用新的线程或函数钩子
· 静态导出名称:使用构建后克隆、静态定义、链接器注释等
然而,有一件事需要清楚,这里的解决方案的空间是相当大的,而且每个人都有自己的偏好。 我们当前的“最佳”实现结合了导出转发的简单性和构建后打补丁的灵活性。整个过程是这样的:
· 我们编译/收集用于劫持的 DLL 。它不需要了解任何具体的劫持细节(除非你需要添加钩子)
· 我们将引用 DLL 的导出克隆到“恶意” DLL 中。 解析“真正的” DLL 并将导出表复制到一个新的 PE 部分。 导出转发用于正确地实现代理功能
· 我们在任何劫持中使用新克隆的 DLL。 稳定性得到了保证,并且可以维护进程间的执行
我们正在发布一个项目来演示这个过程,以及一些其他的技术,例如,叫做 Koppeling 的高级劫持技术。 就像我们的 sRDI 项目一样,只要知道引用 DLL 的最终路径,就可以制作任意的 DLL 进行劫持。 如果你像我们一样喜欢 DLL 劫持,我们希望你能找到它的用处并做出贡献。
https://github.com/monoxgas/Koppeling
总结
我们团队不仅热衷于如何将一项技术武器化,而且热衷于如何以稳定和平衡的方式进行。 我们希望不惜一切代价避免对客户环境产生影响。 这种“对客户的关心”需要数小时的研究、测试和开发。 我们编写的 Slingshot 工具包与我们在这里详细介绍的技术保持无缝的集成,以确保我们团队和其他人可以充分利用劫持。 正如前面提到的,如果你渴望了解更多,我们也会在 Dark Side Ops 系列课程中更深入地探讨这些话题。
我们希望这篇文章能让你对这种经常被曲解的技术有更深刻的理解。
– Nick (@monoxgas)
本文翻译自:https://silentbreaksecurity.com/adaptive-dll-hijacking/如若转载,请注明原文地址: