dll加载链逆向与实现完美dll劫持

admin 2024年2月15日20:12:32评论45 views字数 29123阅读97分4秒阅读模式

dll加载链逆向与实现完美dll劫持

DLL 劫持是一种技术,它通过欺骗第三方代码加载错误的库 (DLL) 来将第三方代码注入到合法进程 (EXE) 中。发生这种情况的最常见方式是将相似的 DLL 放在搜索顺序中高于预期 DLL 的位置,从而使 Windows 库加载程序首先选择 DLL。

虽然 DLL 劫持主要是一种决定性技术,但它一直有一个巨大的缺点,即一旦加载到进程中,它就会执行我们的第三方代码。它被称为 Loader Lock,当我们的第三方代码运行时,它受到所有严格的限制。其中包括创建进程、执行网络 I/O、调用注册表函数、创建图形窗口、加载其他库等等。尝试在 Loader Lock 下执行任何这些操作都可能会使应用程序崩溃或挂起

到目前为止,只有令人满意的(但需要更多的东西)、即将打破或有时有点过度设计的这个问题的解决方案已经存在。因此,今天,我们正在对 Windows 库加载器进行 100% 的原始研究逆向工程,不仅要干净利落地解决加载程序锁定问题,而且最终要彻底禁用它。此外,提出了一些稳定的缓解和检测机制,防御者可以使用这些机制来帮助防止DLL劫持。

关于DllMain

DllMain是 Windows 下 DLL 的初始化函数。每当加载 DLL 时,都会调用其中的代码(例如我们的第三方代码)并执行其中的代码。在 Loader Lock 下运行,如前所述,它对可以安全地执行的操作施加了一些限制。DllMainDllMainDllmain

dll加载链逆向与实现完美dll劫持

具体来说,Microsoft 只是想让我们意识到一个关于执行以下任何操作的小警告DllMain

切勿从 DllMain 中执行以下任务:

  • 调用 LoadLibrary 或 LoadLibraryEx(直接或间接)。这可能会导致死锁或崩溃。

  • 调用 GetStringTypeA、GetStringTypeEx 或 GetStringTypeW(直接或间接)。这可能会导致死锁或崩溃。

  • 与其他线程同步。这可能会导致死锁。

  • 获取由等待获取加载程序锁的代码所拥有的同步对象。这可能会导致死锁。

  • 使用 CoInitializeEx 初始化 COM 线程。在某些情况下,此函数可以调用 LoadLibraryEx。

  • 调用注册表函数。

  • 调用 CreateProcess。创建进程可以加载另一个 DLL。

  • 调用 ExitThread。在 DLL 分离期间退出线程可能会导致再次获取加载程序锁,从而导致死锁或崩溃。

  • 调用 CreateThread。如果不与其他线程同步,则可以创建线程,但这是有风险的。

  • 调用 ShGetFolterPathW。调用 shell/已知文件夹 API 可能会导致线程同步,从而导致死锁。

  • 创建命名管道或其他命名对象(仅限 Windows 2000)。在 Windows 2000 中,命名对象由终端服务 DLL 提供。如果未初始化此 DLL,则对 DLL 的调用可能会导致进程崩溃。

  • 使用动态 C 运行时 (CRT) 中的内存管理功能。如果未初始化 CRT DLL,则对这些函数的调用可能会导致进程崩溃。

  • 调用 User32.dll 或 Gdi32.dll 中的函数。某些函数加载另一个 DLL,该 DLL 可能未初始化。

  • 使用托管代码。

正如 Microsoft 所指出的,这些是可以安全地完成的“最佳实践”,而不会发生潜在的坏事和意外的副作用。啊,是的,这些限制造成了如此多的痛苦!请为所有 Win32 开发人员默哀片刻。DllMain

我们在哪里

我研究的出发点主要是安全专家 Nick Landers (@monoxgas) 在 NetSPI 上发表的一篇名为“自适应 DLL 劫持”的信息性文章。这是一项非凡的研究,我过去曾使用过一些由此产生的技术和工具(例如Koppeling)。与所有出色的研究一样,它必须进一步创新,这正是我们今天正在做的事情!

目前在 Internet 上执行通用 DLL 劫持(仅涉及)的项目都要求您执行以下两个有问题的操作之一:DllMain

  1. 更改内存保护(使用VirtualProtect)

  2. 修改指针

第一点不太理想,因为反恶意软件解决方案会标记操作。特别是那些创建读-写-执行内存或从读-写➜读-执行转换内存的。这是有充分理由的,因为更改可执行内存保护表明使用自我修改的代码技术,这可能是绕过静态反恶意软件检测的最简单方法。启用了任意代码防护 (ACG) 的进程会完全阻止可执行内存的创建或修改。VirtualProtect

我见过一些案例,其中使用 Microsoft Detours 的 API 调用检测被用作一种技术。虽然它可能有效,但它会导致 DLL 大小大幅增加,反恶意软件解决方案会毫不犹豫地将其标记下来。此外,它不兼容 ACG,因为它更改了可执行内存保护。

第二点也不太理想,因为指针是几乎所有下一代漏洞利用缓解措施的目标。例如,在释放 Locker Lock 后,堆栈上的函数返回地址被修改以获取代码执行。该技术运行良好,但由于即将推出的称为英特尔控制流执行技术 (CET) 的漏洞利用缓解措施而中断。CET 交叉引用堆栈上的函数返回地址,并在 CPU 硬件中锁定了“影子堆栈”,以确保它们有效且未被篡改;否则,强行终止违规进程。总有一天,所有指针都经过 1:1 身份验证,因此最好通过完全避免指针修改来使我们的技术面向未来。

我还注意到,一些现有的技术虽然用途广泛,但有点长和复杂,或者是为动态或静态负载而设计的。一些方法还需要具有稳定的过程连续性。

避免这些有问题的操作是我们将在这里探索的新技术的要求。

安全研究人员的心态

当你探索未知的领域时,很容易变得困惑,放弃得太快。这就是为什么提醒自己我们必须做什么很重要,这样创造力才能站稳脚跟。对我来说,在研究这个问题之前,我刚刚利用了一个内存损坏问题(例如缓冲区溢出),我发现的错误允许不受信任的数据控制指令的目的地(即任意调用),从而最终让位于远程计算机上的任意代码执行。call

在 DLL 劫持的上下文中,我们被授予访问所有三个基本基元的权限,包括任意读取、写入和调用程序(虚拟)内存中的任何位置 (!)。我们还可以轻松访问Windows库中存在的大量奇怪的机器(许多很多行代码),因为我们是编写代码的人(即使我们的代码在Loader Lock in下运行)。此外,我们可以通过使用系统调用与内核通信,与被劫持进程的虚拟内存空间之外的奇怪机器进行交互。人们很容易将这些奢侈品视为理所当然,直到您处于更受限制的攻击场景中。DllMain

所有这一切都是说,不存在很多机制可以让我们在 Loader Lock 发布后干净地(例如,在不更改内存保护的情况下)重定向代码执行(甚至找出如何完全禁用它)的可能性基本上为零。作为研究人员,我们可以自信地探索,知道我们会找到我们想要的东西。这就是我开始搜索的心态。DllMain

dll加载链逆向与实现完美dll劫持
Info Loader 锁定不是安全边界,只是某些编程用例和 DLL 劫持的麻烦。然而,这并不意味着一些相同的思维过程不能适用。

这里的“锁”是指互斥锁(互斥的缩写),是并发的概念。如果你学的是计算机科学,那么你很有可能了解它。

我们的目标

默认情况下,我们将在 Windows 内置的程序上尝试我们的 DLL 劫持技术:C:Program FilesWindows DefenderOfflineOfflineScannerShell.exe

dll加载链逆向与实现完美dll劫持

尝试启动此程序(只需双击)将产生此错误,清楚地表明它容易受到DLL劫持:

dll加载链逆向与实现完美dll劫持

发生这种情况的原因是位于 中,距离程序的当前文件夹有一个目录。因此,正确运行此程序需要首先将当前工作目录(CWD)设置为(使用CMD最容易完成)。这会导致 real 在搜索路径中,从而成功运行:mpclient.dllC:Program FilesWindows DefenderOfflineC:Program FilesWindows Defendermpclient.dllOfflineScannerShell.exe

C:>cd C:Program FilesWindows Defender
C:Program FilesWindows Defender>OfflineOfflineScannerShell.exe
C:Program FilesWindows Defender>echo %ERRORLEVEL%
0

但是,如果我们将 CWD 设置为其他任何地方,例如我们的用户配置文件 (),那么当我们查找时,我们可以使它将加载我们的副本 !C:Users<YOUR_USERNAME>OfflineScannerShell.exempclient.dllC:Users<YOUR_USERNAME>mpclient.dll

全局环境变量中包含的任何路径(此处使用 CMD 打印)也有效:PATH

C:Usersuser>echo %PATH%
C:Windowssystem32;C:Windows;C:WindowsSystem32Wbem;C:WindowsSystem32WindowsPowerShellv1.0;C:WindowsSystem32OpenSSH;C:Users<YOUR_USERNAME>AppDataLocalMicrosoftWindowsApps

C:Users<YOUR_USERNAME>AppDataLocalMicrosoftWindowsApps是另一个完美的用户可写位置,默认情况下存在于 Windows 上,如果您不想设置当前工作目录,它(或任何其他程序)将从中加载 DLL。OfflineScannerShell.exe

正如我们稍后将发现的那样,Windows及更高版本中还有大量其他潜在的DLL劫持目标,我们可以在这些目标上使用我们的新技术,但我就是喜欢这个。

我们的有效载荷

对于我们的示例有效负载,我们将通过执行以下操作来启动 Calculator 应用程序:

ShellExecute(NULL, L "open", L "calc.exe", NULL, NULL, SW_SHOW);

但是,重要的是要注意,实际上,您将继续耗尽合法(但被劫持)的进程;否则,这将破坏DLL劫持的目的(在大多数情况下)。对于红队,在合法进程中生成一个反向 shell(例如使用 Metasploit 或 Colbalt Strike),同时让程序正常运行(没有迹象表明发生了任何异常情况)可能是理想的最终有效载荷。

为什么?好吧,作为NTDLL中可能出错的任何事情的试金石,效果非常好。这是因为众所周知,一个 API 调用与之交互的大量 Windows 子系统。从库加载程序到 COM/COM+ 基础结构,使用 APC、RPC、WinRT 存储调用、CRT 函数、注册表函数,它甚至创建了一个全新的线程来启动一个应用程序 ()!可能是整个 Windows API 中最臃肿和最复杂的 API 调用(当然是在之后)。因此,我们理所当然地可以通过调用技术来验证该技术在实践中的成功。ShellExecuteShellExecutecalc.exeShellExecuteShellExecuteEx

与大多数涉及的事情一样,尝试调用(尤其是)而不做任何其他事情都会失败,并在 (??) 处出现不祥的死锁,导致程序无限期挂起:DllMainShellExecuteShellExecutentdll!NtAlpcSendWaitReceivePort

dll加载链逆向与实现完美dll劫持

搜索该函数(或其任何邻居)几乎不会产生任何结果,因为它完全没有记录!当这种情况发生时,我喜欢它。

其他时候,您可能会因异常而崩溃,因为内部函数想要在调用堆栈 (???) 的几英里深处引发一个不错的 NTSTATUS(在本例中为 )。试着搞砸,你可能会面临一个严肃的内存访问冲突。就像一盒巧克力一样,你永远无法真正知道你会得到什么。ntdll!TppRaiseInvalidParameterSTATUS_INVALID_PARAMETER

让我们看看我们是否能改变这一点!

完美的人选

OfflineScannerShell.exe就DLL劫持而言,我称之为“最坏情况”(至少使用现有技术)。这使得它非常适合确保我们的新技术能够普遍发挥作用。最坏情况的发生归结为以下几点:OfflineScannerShell.exe

  • 不会调用可劫持 DLL 的导出,因此必须使用重定向代码执行DllMain

    • 很多时候,不可能满足这些先决条件

    • 许多具有可劫持 DLL 的程序会提前退出,除非有非常具体的前提条件

    • 我通过在导出的每个函数上设置断点来验证这一点,然后运行程序以检查断点命中OfflineScannerShell.exeMpClient.dll

  • 程序在启动后立即退出(不保持打开、空闲或等待)

  • 可劫持的 DLL 是静态加载的(不是通过在程序运行时调用来动态加载的)LoadLibrary

    • 一般来说,因为你对库加载器内部有更深入的了解(该过程仍在启动)

基本上就是这样。如果你的目标程序在其生命周期的某个时刻调用了可劫持 DLL 的导出,那么你就是黄金,因为你可以从那里重定向代码执行,而不必担心 和 和 根本。DllMainLoader Lock这有时称为 DLL 代理。但是,在我看到的大多数情况下,当DLL被劫持时,它通常是一个非常晦涩的库,在应用程序代码中非常深入地调用了几次,除非在完全正确的环境中调用程序,否则可能无法轻松(如果有的话)到达。其他任何事情,它都会立即退出,因为,例如,您正在运行一个与可劫持的 DLL 链接的服务可执行文件。但是,一旦您运行服务可执行文件,它就会看到它没有正确启动(作为 Windows 服务)并立即关闭。这使得本应是简单的劫持过程变得复杂,考虑到我们已经在应用程序启动时被授予代码执行权,就在臭名昭著的 Loader Lock 下。DllMain

据我所知,从 DLL 劫持的角度来看,唯一有利的事情是它与 C 运行时 (CRT) 链接;换句话说,它不是一个纯粹的 Windows API (Win32) 程序。但是,Windows 中的绝大多数程序都与 CRT 链接,因此这不是一个独特的优势。为什么这是有利的,我们稍后会介绍。OfflineScannerShell.exe

 新技术

竞速主线

此技术建立在 NetSPI 上发布的“自适应 DLL 劫持”的见解之上。

我对这项技术的最初扩展无法实现我们目标的 100% 成功率。但是,它提供了很好的学习体验,因此我将其包括在内。在本节的最后,我暗示了我们对这项技术的扩展略有不同的方法,可以实现 100% 的成功率(更多即将推出)。

如果您只想要现在最好的闪亮新技术,请随时跳到下一节。

初始实验

正如 Microsoft 在前面提到的“最佳实践”文档中所述,从“可以工作”调用:CreateThreadDllMain

// DllMain boilerplate code (required in every DLL)
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// Create a thread
// Thread runs our "CustomPayloadFunction" (not shown here)
DWORD threadId;
HANDLE newThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)CustomPayloadFunction, NULL, 0, &threadId);
}
return TRUE;
}

确实如此!但是有一个问题,因为调用创建的线程将等待(以多种方式)直到我们离开开始执行。然后,要做到这一点,需要我们调用 ,允许程序退出(此后不久就释放了 Loader Lock 和 friends),并希望在主线程退出程序之前创建线程。CreateThreadDllMainCreateThreadDllMain

创建线程是一项相对昂贵的操作,因此,如果我们的目标程序退出得相当快,我们可能无法赢得这场比赛。也许我们可以以某种方式提高成功的机会......

提高我们的赔率

不知何故,就像调用将我们新排队的线程的优先级提高到最高级别 () 同时将主线程的优先级降低到尽可能低的水平(;优先级比甚至一级)!SetThreadPriorityTHREAD_PRIORITY_TIME_CRITICALTHREAD_PRIORITY_IDLETHREAD_PRIORITY_LOWEST

扩展我们之前的代码,我们可以在以下代码之后添加以下内容:CreateThread

SetThreadPriority(newThread, THREAD_PRIORITY_TIME_CRITICAL);
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_IDLE);
// Then return from DllMain and cross our fingers...

对于 来说,设置线程优先级并不是必需的,因为在程序退出之前已经做了足够的工作,以便为新线程的生成腾出时间。但是,它确实在一定程度上有助于提高我仅用于静态加载 DLL 的简单测试台的获胜率。因此,我们将把这个实验算作一个小小的成功。OfflineScannerShell.exe

停止主线程

现在我们已经到达了新线程,我们需要在主线程退出程序之前快速停止它。从我们的新线程中暂停我们的主线程是实现这一目标的最明显方法,因此我们将这样做。为此,需要所需线程的句柄。CustomPayloadFunctionSuspendThreadSuspendThread

很简单,稍微修改一下我们之前的,我们首先使用 GetCurrentThread 获取当前(主)线程句柄。接下来,我们将这个线程句柄作为参数传递,如下所示:CreateThreadCustomPayloadFunction

// Pass result of GetCurrentThread() as an argument to CustomPayloadFunction
HANDLE newThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)CustomPayloadFunction, GetCurrentThread(), 0, &threadId);

然后继续将其挂起(我们的新线程):CustomPayloadFunction

VOID CustomPayloadFunction(HANDLE mainThread) {
SuspendThread(mainThread);
...
}

但是,有一个偷偷摸摸的错误。你能发现它吗?

这是我多年前犯的一个错误。但是,当时我只是一个没有WinDbg经验的C和Win32程序员,所以当时我无法弄清楚。

该错误源于返回句柄的事实;它返回一个句柄。只是一个存根,(在 x86-64 上)总是返回常量:GetCurrentThreadGetCurrentThread0xFFFFFFFFFFFFFFFE

dll加载链逆向与实现完美dll劫持

因此,将该值传递给我们的新线程将导致它引用它被传递到的线程,而不是我们调用的线程。该错误非常微妙,如果您熟悉 Win32 编程或已彻底阅读文档(而不是像我一样让 Visual Studio Intellisense 指导您),则可能会立即将其作为问题跳到您面前。实现我们想要的正确方法是:GetCurrentThread

HANDLE mainThread = OpenThread(THREAD_SUSPEND_RESUME, FALSE, GetCurrentThreadId());

我们从当前线程 ID 创建主线程的真实句柄,然后可以将其作为参数正确地传递给我们的新线程。只给我们的句柄提供所需的最低权限,我们的用例非常完美。THREAD_SUSPEND_RESUME

在正常情况下,将新线程的 ID 传递给主线程,然后在新线程上从它创建句柄可能会产生更清晰的代码。但是,在我们独特的情况下,我们希望尽快从新线程中挂起主线程,因此提前打开手柄是更好的选择。我们只需要格外小心,不要忘记从我们的新线程中获取,这样我们就不会泄漏资源。Windows 还限制了单个进程可以拥有的句柄数,因此,如果攻击者可以泄漏大量句柄,则可以有效地对应用程序进行 DoS。无论如何,这不是编程课程,但了解最佳实践总是好的(因为具有讽刺意味的是,我们继续对所有这些实践进行蒸煮)!CloseHandle

SuspendThread 的问题...

使用这种技术要克服的最后一个挑战是 Microsoft 在他们的 SuspendThread 文档中为我们总结的:

此函数主要设计用于调试器。它不用于线程同步。如果调用线程尝试获取挂起线程拥有的同步对象,则在拥有同步对象(如互斥锁或关键部分)的线程上调用 SuspendThread 可能会导致死锁。若要避免这种情况,应用程序中不是调试器的线程应向其他线程发出挂起自身的信号。目标线程必须设计为监视此信号并做出适当的响应。

这带来了比赛方法的最大问题。在 中,此问题每十次左右执行一次就会浮出水面,因为主线程在执行堆内存分配/释放时将挂起。在 Windows 中,每个进程都有一个系统提供的默认堆(您可以使用函数获取它)。此堆配置了(序列化是指互斥)unset,这意味着对堆分配和空闲函数的调用会导致堆被锁定,然后解锁。否则,对于未序列化的堆(设置),程序员将负责确保跨线程的安全访问。我们可以通过从新线程调用来一次性删除此锁。但是,这破坏了线程安全保证。它可能会导致主线程崩溃或在恢复后执行一些意外操作。OfflineScannerShell.exeGetProcessHeapHEAP_NO_SERIALIZEHEAP_NO_SERIALIZEHeapUnlock(GetProcessHeap())

例如,在我们的测试中,我们使用 running 作为从新线程运行的最终有效负载。好吧,(以及其他类似的复杂 Win32 函数)必须对进程堆进行分配才能工作,如果我们不解锁堆,就会发生死锁。在 中,我还没有看到实际导致崩溃的情况,但是,一旦我们(或等效的)在新线程中恢复主线程,发生崩溃的几率就不为零。ShellExecutecalc.exeShellExecuteOfflineScannerShell.exeHeapUnlockHeapUnlock(GetProcessHeap())HeapAllocmalloc

dll加载链逆向与实现完美dll劫持

dll加载链逆向与实现完美dll劫持

发生死锁的原因是,当新线程尝试获取堆锁时,挂起的主线程正在持有堆锁(即关键部分)。任何一方都无法取得进展,因此该计划无限期地挂起。

不同的方法?

在这种技术的当前状态下,假设您希望保持主机进程运行到其自然结束(我们确实这样做了),则此解决方案最多只能达到 99% 的有效性。这是一个有趣的实验,但我们可以做得更好。

从本质上讲,当我们将主线程从新线程中挂起时,我们需要能够控制主线程的位置。我能想到的最干净的方法是使用锁来发挥我们的优势。我们可以获取一些锁(在这里和我在一起),这将导致主线程在代码中的可预测点停止,因为它正在等待获取相同的锁。当我们的新线程启动时,我们运行有效负载,然后释放该锁,以便程序可以继续像往常一样自由运行(并确保我们不会退出得太快)。使用这种方法,我们甚至不必做任何线程悬挂,因为锁为我们完成了所有工作!我还没有尝试过,因为我只是在写这篇文章时才想出这个想法,但这听起来像是一个成功的策略。DllMain

下一篇文章将对此进行更多介绍!

检测启发式方法

尽管如此,调用方式(以及其他一些启发式方法)仍可用作反恶意软件检测的签名,因此对于红队,该技术仍有待改进。如果防御者想将其用作检测DLL劫持的启发式方法,那么我建议您在之前将最初调用我们的DLL的位置挂钩/发出信号。您可以从前面在“我们的有效负载”部分中显示的图像中查看此调用堆栈的外观。如果看到任何潜在的可疑 Windows API 调用,例如在该时间间隔内进行,则可能是 DLL 劫持的症状。必须这样做,而不是在调用某些可疑的 Windows API 函数时简单地分析调用堆栈,因为调用堆栈很容易被临时欺骗。即使使用英特尔 CET,调用堆栈仍然可以暂时伪造(例如,在调用之前),然后将其更改回以通过函数 () 返回的返回地址完整性检查。CreateThreadDllMainntdll!LdrpCallInitRoutine<DLL_NAME>!dllmain_dispatch<DLL_NAME>!DllMainCreateThreadCreateThreadDllMain

在出口处逃生

dll加载链逆向与实现完美dll劫持

在标准 C 中,存在一个名为函数的函数,其目的是(毫不奇怪)在程序退出时运行给定的函数。因此,如果我们简单地使用 from 设置一个出口陷阱,那么当程序退出时,我们可以逃脱 Loader Lock 的炽热火焰:atexitatexitDllMain

// DllMain boilerplate code (required in every DLL)
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// CustomPayloadFunction will be called at program exit
atexit(CustomPayloadFunction);
}
return TRUE;
}

因此,我们开始(如图所示),经过数小时的调试和挠头,我们接下来发现的内容可能会让您感到震惊:CustomPayloadFunctionpayload

dll加载链逆向与实现完美dll劫持

该处理程序也在 Loader Lock 下运行!!-_-atexit

意识到这一点也需要做很多工作,因为我需要一种直接的方法来检查 Loader Lock 是否存在。当时我检查 Loader Lock 是否存在的唯一(糟糕的)方法是做一些我认为在 Loader Lock 下一定不可能做的事情。如果这些事情成功了,我认为我们必须摆脱 Loader Lock(提示:这不起作用)。

直到Raymond Chen(Microsoft的资深Windows内部专家)在Old New Thing博客上偶然发现了这个超级有用的信息!critsec ntdll!LdrpLoaderLock

考虑到加载程序锁定问题在Windows API(Win32)编程中很常见,我认为这些信息应该在官方Microsoft文档中突出显示(也许在“调试”部分),而不是只存在于几篇旧的博客文章中,分散在各种问题跟踪器中,现在也在这里。还值得注意的是,此锁在 的输出中无处可寻。该命令列出了一些锁,但无论出于何种原因,(即使锁定时)不包括在内。因此,如果不在互联网上搜索、搜索调试符号名称或在 NTDLL 关键部分函数上设置断点,就没有简单的方法可以找到这一点(尽管当时我不知道 Loader Lock 是如何实现的)。!locks -vntdll!LdrpLoaderLock

0:000> !locks -v
CritSec ntdll!RtlpProcessHeapsListLock+0 at 00007ff94e17ace0
LockCount NOT LOCKED
RecursionCount 0
OwningThread 0
EntryCount 0
ContentionCount 0
CritSec +13d202c0 at 0000024a13d202c0
LockCount NOT LOCKED
RecursionCount 0
OwningThread 0
EntryCount 0
ContentionCount 0
... *snip* More unnamed (i.e. no debug symbols available) locks *snip* ...
CritSec SHELL32!g_lockObject+0 at 00007ff94d3684b0
LockCount NOT LOCKED
RecursionCount 0
OwningThread 0
EntryCount 0
ContentionCount 0

无论如何,使用这个神话般的 WinDbg 命令,我们可以立即知道 Loader Lock 是否是 or 在这种情况下,它肯定被锁定了:!critsec ntdll!LdrpLoaderLock*** LockedNOT LOCKED

0:000> !critsec ntdll!LdrpLoaderLock
CritSec ntdll!LdrpLoaderLock+0 at 00007ffb30af65c8
WaiterWoken No
LockCount 0
RecursionCount 1
OwningThread 26e0
EntryCount 0
ContentionCount 0
*** Locked

所以,我想这种技术是不可行的,哦,好吧,我们试过了......

或者是吗?如果我告诉你(在 Windows 上)实际上有两种类型的(未记录的实现细节)怎么办!嗯,这正是我通过一些逆向工程发现的。最好的部分是什么?其中一个的处理程序不在 Loader Lock 下运行:atexit

dll加载链逆向与实现完美dll劫持

_onexit是标准 C 直接传递到 Microsoft 的扩展;这些函数是等效的atexit

请注意函数中的两条指令。第一个是 to(CRT 是 C 运行时),第二个是 to 。调用哪一个取决于一个(比较)后跟一个(如果不相等则跳转)指令。具体来说,如果 address ,那么我们将跳转到 的调用 ,否则,将被调用。call_onexit_crt_atexit_register_onexit_functioncmpjne0x00007ff943783058 != 0xFFFFFFFFFFFFFFFF_register_onexit_function_crt_atexit

通过实验,我了解到,所有这些都在测试对 / 的调用是否来自 EXE 或 DLL。如果从 EXE 运行,则该地址的值将等于 ,而在 DLL 中,它是其他值。为什么会这样——我真的不知道,但是,它就是这样。atexit_onexit0xFFFFFFFFFFFFFFFF

因此,我们已经确定了 / 从 DLL 调用,而将从 EXE 调用。您现在可能已经猜到了,但是我们要调用的那个 - 其处理程序运行无 Loader Lock 的那个 - 是 !atexit_onexit_register_onexit_function_crt_atexit_crt_atexit

dll加载链逆向与实现完美dll劫持CRT 复习
C 运行时 (CRT) 提供了许多基本的应用程序功能,它使程序员能够访问 C(有时是 C++)标准定义的函数。内存分配函数,如 and、字符串比较、// 文件访问操作等等都是标准的 C 函数!遵守此标准,开发人员可以(理论上)编写一个适用于所有平台的 C/C++ 程序,而无需提取成本。
mallocfreestrcmpfopenfreadfwrite

让我们开始编写代码并执行操作:

#include <process.h> // For CRT atexit functions
// DllMain boilerplate code (required in every DLL)
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// CustomPayloadFunction will be called at program exit
_crt_atexit(CustomPayloadFunction);
_crt_at_quick_exit(CustomPayloadFunction);
}
return TRUE;
}

试试看,然后...这是行不通的。OfflineScannerShell.exe但是等等,它确实可以在我设置的简单测试台上工作,我正在构建(使用 Visual Studio)示例目标 EXE 和劫持 DLL(静态加载)?

以下是在我们的测试平台中,当调用发出的 / 处理程序在程序退出时运行时,调用堆栈的样子,也证明了 Loader Lock 不再存在:atexit_onexit_crt_atexit

dll加载链逆向与实现完美dll劫持

ConsoleApplication2是我们的示例目标 EXE,也是我们的示例劫持 DLLDll2

我已经有了怀疑,WinDbg 中的快速浏览为我指明了正确的方向。问题在于,我们的劫持DLL链接到完全不同的CRT,这些CRT不共享相同的状态。与 OG 链接(这个东西在 Windows 中具有向后兼容性很久了),我们的 DLL 链接到较新的通用 CRT (UCRT),仅从 Windows 10 开始作为内置系统库提供。这是在 Visual Studio 2022 上。但是,请注意,默认情况下,旧版本的 Visual Studio 仍链接到 Visual C++ () CRT。您可能熟悉安装后者的程序:OfflineScannerShell.exeOfflineScannerShell.exemsvcrt.dllvcruntime

dll加载链逆向与实现完美dll劫持

dll加载链逆向与实现完美dll劫持

msvcrt.dll是 Windows 中最古老的 C 运行时。自 Windows 95 以来,它一直作为内置系统库存在,并且仍然存在于现代 Windows 安装中。C:WindowsSystem32就符合 C 标准而言,它提供了一个非常糟糕的 CRT。事实上,它是如此的破碎,以至于Microsoft很久以前就取消了开发人员使用 Visual Studio 链接到它的能力。但是,Microsoft 了解自己的错误,因此,对于 Windows 附带的许多程序,仍然链接到它(除非它是没有 CRT 的纯 Win32 应用程序或使用 Windows 10 发布的较新的 UCRT)。所有这些都符合Microsoft作为向后兼容性之王的无可争议的声誉。这就是它的精髓。查看完整的背景故事,风险自负。

回到工作出来,使用标准/方法定位并调用 in works:DllMainGetModuleHandleGetProcAddressatexitmsvcrt.dll

msvcrtHandle = GetModuleHandle(L"msvcrt");
if (msvcrtHandle == NULL)
return;
FARPROC msvcrtAtexitAddress = GetProcAddress(msvcrtHandle, "atexit");
// Prototype function with one argument
// Argument: A function pointer (CustomPayloadFunction) whose return type is irrelevant (`void`) and has no arguments (another `void`)
// Both of these functions use the standard C calling convention known as "cdecl"
typedef int(__cdecl* msvcrtAtexitType)(void (__cdecl*)(void));
// Cast msvcrtAtexitAddress as a type of msvcrtAtexitType so we can call it as prototyped above
msvcrtAtexitType msvcrtAtexit = (msvcrtAtexitType)(msvcrtAtexitAddress);
// Call MSVCRT atexit!
msvcrtAtexit(CustomPayloadFunction);

但是,它很长,反恶意软件解决方案往往不喜欢这些功能。我们能想出更简洁的东西吗?为什么是,但我们必须退出 Visual Studio 并使用特定版本的 Windows 驱动程序工具包 (WDK) 进行编译。使用 WDK(尽管它的名字,也可以编译常规用户模式程序),我们可以直接链接到 !使用 MinGW 进行交叉编译也可能有效。这会将我们的内容(在样板之后)转换回一行代码:msvcrt.dllDllMain

atexit(CustomPayloadFunction);

现在很干净了。

检测启发式方法

由于仅包含一行代码,因此该技术不会留下太多检测方式。完全在进程内工作,这意味着内核回调不会找到任何东西。我也没有立即意识到任何安全产品挂钩用户模式调用 / 之外的任何内容(或者至少肯定不是 CRT DLL)。用户模式挂钩存在于程序的(虚拟)内存中,这使得绕过它们始终成为可能。这与内核回调不同,在内核回调中,用户模式程序没有接触内核的权限(这是一个硬安全边界,需要权限提升攻击)。因此,就常见的运行时指标而言,这种技术是回避的。atexitntdll.dllkernel32.dll

首先,进行静态分析以检测内部的调用(或调用)可能会起作用。然而,它只会开始一场猫捉老鼠的游戏,而且很容易绕过。例如,攻击者可以调用其 DLL 代码中的任意位置(在未使用的代码中,在 之外),在代码中发出唯一标识符(例如,使用汇编指令),然后使用鸡蛋猎人(通常用于漏洞利用开发,但也可以用于在此上下文中逃避检测)搜索该标识符,地址紧随其后。只需一点点组装来动态化该地址,可能存储在寄存器中(例如),就只需要这样。atexit_onexitDllMainatexitDllMaindbatexitcallcall rax

当然,它本身并没有什么可疑之处(例如,在二进制文件的导入地址表中),这与明显的反例不同。atexitCreateRemoteThread

可以创建一个启发式方法,用于检测进程是否在处理程序中花费了异常长时间。如果启发式检测到一个良性应用程序在处理程序中花费了过多的时间,我们称之为奖励,因为这听起来对我来说可能是一个错误。这可以与检测 CRT 表中指向 DLL 代码的条目结合使用。特别是,如果在执行 DLL 的处理程序期间使用敏感的 Win32 函数(通过内核回调检测此函数)。atexitatexit_onexit_table_tatexit

一个有趣的发现是,它可以作为一种自然的方法,确保攻击者的真实有效载荷永远不会在恶意软件分析沙箱下执行atexit 如果沙盒未在程序 (EXE) 中运行示例 DLL,则该程序 (EXE) 与 DLL 的目标程序(例如,MSVCRT for )链接。恶意软件分析服务(如混合分析)应确保 DLL 示例至少在 UCRT 和 MSVCRT 环境中运行,以捕获此沙盒规避技巧。OfflineScannerShell.exe

虽然可以进一步改进对这种特定技术的识别,但我认为最好将精力用于检测更广泛的 DLL 劫持类别,我们将在后面讨论。

获得Microsoft®批准?

通过设置 断点 ,我已经能够发现 Microsoft 自己在 Loader Lock 下调用 EXE 通常使用的相同 CRT 的情况:msvcrt!atexitatexit

dll加载链逆向与实现完美dll劫持

所以你有它,它本质上是......

dll加载链逆向与实现完美dll劫持Microsoft 批准
我们已经在生产中这样做了。😎

好了,好了,虽然这里存在 Loader Lock,但从 CRT DLL 代码调用 CRT 与调用它的任何其他 DLL 之间仍然存在明显差异。有人可能会调用我们的 DLL,导致我们的处理程序从内存中消失,而它仍然被 CRT 引用。然后,此悬空指针将导致退出时崩溃。atexitFreeLibraryatexit

然而,这是一个比人们想象的更小的问题。事实证明,对于静态加载的 DLL,即使多次显式释放(在测试中确认),也只会递减库的引用计数,而不会实际将其从内存中卸载。开发人员可以通过调用(NTDLL 导出)来触发实际的库资源清理,但可能性很小。但是,为了解决这个问题,我们可以在DLL上调用(也是NTDLL导出),因为如果库的引用计数不为零,加载程序将永远不会卸载库。调用还可以避免对动态加载(使用)库进行库资源清理。总而言之,只要你投入一个(并且你的进程有一个CRT;否则,根本没有效果),这种技术就可以保证100%安全™FreeLibraryLdrUnloadDllLdrAddRefDllLdrAddRefDllFreeLibraryLoadLibraryLdrAddRefDll

解锁装载机锁

好吧,这一切都很棒。但是,如果我们想直接从运行我们的最终有效载荷怎么办?不推迟到以后,我说的是在.在这一点上,我们可以做我们想做的一切,如果我们愿意的话,它仍然在调用堆栈中。好吧,对 Windows 加载程序(包含在 中)进行逆向工程需要一个小壮举,但在 WinDbg 中几个小时后,我想通了。DllMainDllMainDllMainntdll.dll

从我们以前的技术中所做的研究中,我们已经知道,如果我们想更改 Loader Lock 的状态,我们将不得不修改 .但是,如果不知道符号的位置,我们就无法做到这一点,我们在调试器(Microsoft 的调试符号会自动下载的位置)之外不会知道该位置。从技术上讲,可以提前下载当前 Microsoft 二进制文件的调试符号,让我们的进程加载它们,然后在我们的进程中查找它们的位置。然而,这很复杂,对我来说不是一个站得住脚的解决方案。ntdll!LdrpLoaderLockntdll!LdrpLoaderLock

在互联网上搜索有关 Loader Lock 关键部分的信息时,我发现了一个名为 LdrUnlockLoaderLock 的有前途的函数的 ReactOS 源代码。ReactOS是通过逆向工程Microsoft Windows从头开始构建的Windows的开源重新实现 - 因此不用说,他们的工作是无价的。

检查(是随 Visual Studio 一起安装的工具),我能够确认这是一个导出,这意味着我们可以使用静态链接轻松获取其位置,然后可能调用它来解锁加载器!dumpbin.exe /exports C:WindowsSystem32ntdll.dlldumpbin.exeLdrUnlockLoaderLockntdll.dllGetProcAddress

从 ReactOS 源代码中看一下函数签名,它似乎需要一个参数:LdrUnlockLoaderLockCookie

NTSTATUS NTAPI LdrUnlockLoaderLock ( IN ULONG Flags,
IN ULONG Cookie OPTIONAL
)

如果我们不提供 ,那么它会提前返回:Cookie

/* If we don't have a cookie, just return */
if (!Cookie) return STATUS_SUCCESS;

(只是一个未存储的幻数)是根据线程 ID(由 TEB 检索或直接从 TEB 检索)计算的,这意味着理论上我们可以轻松地自己创建一个有效的 cookie 值......CookieGetCurrentThreadId

不幸的是,事实并非如此,因为根据我的分析,它看起来像一个Microsoft 员工故意(但如此微妙地)破坏了LdrUnlockLoaderLock使任何标准的 4 十六进制数字(例如 0xffff)线程 ID 无法通过验证步骤。该分析非常深入,因此我将将其保留在 GitHub 存储库中,供任何想要自己验证我的结论的人使用。请注意,ReactOS 以 Windows Server 2003 为目标,但是,在较新版本的 Windows 中,代码已明显更改。LdrUnlockLoaderLock

Microsoft的一位开发人员可能破坏了它,因为它很容易作为NTDLL的导出访问,并且一些菜鸟编码人员滥用了它。无论如何,这是有道理的;作为设置断点,我看到它在整个目标应用程序的执行过程中从未被调用过。但是,我们的应用程序调用的,以及在 的反汇编中调用的,是一个名为:LdrUnlockLoaderLockLdrUnlockLoaderLockLdrpReleaseLoaderLock

看一眼这个家伙的代码,我有一种感觉,我们会相处得很好!

lea     rcx, [ntdll!LdrpLoaderLock (7ff94e1765c8)]
call ntdll!RtlLeaveCriticalSection (7ff94e03f230)

LdrpReleaseLoaderLock但是,NTDLL不会导出,因此要获得它,我们将不得不搜索已知调用的导出函数的反汇编,然后从那里提取其地址。使用 # WinDbg 命令,我们可以在 NTDLL 的反汇编中搜索模式:LdrpReleaseLoaderLock

0:000> # "call    ntdll!LdrpReleaseLoaderLock" <NTDLL_ADDRESS> L9999999
ntdll!LdrpDecrementModuleLoadCountEx+0x79:
00007ff9'4e01fd11 e84ee90200 call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!LdrShutdownThread+0x201:
00007ff9'4e027651 e80e700200 call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!LdrpInitializeThread+0x213:
00007ff9'4e02794 b e8146d0200 call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!LdrpPrepareModuleForExecution+0xc9:
00007ff9'4e04d951 e80e0d0000 call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!LdrEnumerateLoadedModules+0x85:
00007ff9`4e06d955 e80a0dfeff call ntdll!LdrpReleaseLoaderLock (00007ff9`4e04e664)
ntdll!LdrUnlockLoaderLock+0x63:
00007ff9`4e08e023 e83c06fcff call ntdll!LdrpReleaseLoaderLock (00007ff9`4e04e664)
ntdll!LdrUnlockLoaderLock+0x71:
00007ff9`4e08e031 e82e06fcff call ntdll!LdrpReleaseLoaderLock (00007ff9`4e04e664)
ntdll!LdrShutdownThread$fin$2+0x10:
00007ff9'4e0b4ac7 e8989bf9ff call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!LdrpInitializeThread$fin$2+0x10:
00007ff9'4e0b4b2f e8309bf9ff call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!LdrEnumerateLoadedModules$fin$0+0x10:
00007ff9'4e0b59f5 e86a8cf9ff call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!RtlExitUserProcess+0x5f3c1:
00007ff9'4e0ccda1 e8be18f8ff call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!LdrpInitializeImportRedirection+0x46d72:
00007ff9'4e0d8976 e8e95cf7ff call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!LdrInitShimEngineDynamic+0xde:
00007ff9`4e0e068e e8d1dff6ff call ntdll!LdrpReleaseLoaderLock (00007ff9`4e04e664)
ntdll!LdrpInitializeProcess+0x1f6e:
00007ff9'4e0e3e2e e831a8f6ff call ntdll!LdrpReleaseLoaderLock (00007ff9'4e04e664)
ntdll!LdrpCompleteProcessCloning+0x93:
00007ff9`4e0e4bfb e8649af6ff call ntdll!LdrpReleaseLoaderLock (00007ff9`4e04e664)

正如你所看到的,有许多潜在的起点可以定位。但是,我们已经知道是导出的,这似乎是最直接的方法,因此我们将从那里进行搜索。这个代码没什么特别的;它只是搜索正确的调用操作码,执行一些额外的验证,提取(rel32编码的)地址来执行指令,然后对函数进行原型设计,以便我们可以调用它。我采取了进一步的步骤,从中提取关键部分的地址,以便我们也可以在返回之前重新锁定它(使用),以增加安全性。请随时查看 GitHub 存储库上的完整代码!现在,我们检查并...ntdll!LdrpReleaseLoaderLockntdll!LdrUnlockLoaderLockcallLdrpReleaseLoaderLockntdll!LdrpLoaderLockLdrpReleaseLoaderLockEnterCriticalSectionDllMainDllMain

0:000> !critsec ntdll!LdrpLoaderLock
CritSec ntdll!LdrpLoaderLock+0 at 00007ff94e1765c8
LockCount NOT LOCKED
RecursionCount 0
OwningThread 0
EntryCount 0
ContentionCount 0

现在我们已经解锁了 Loader Lock,让我们敢叫开口吧!和。。它不起作用 - 还没有。但是,我们已经取得了宝贵的进展!回想一下我们的有效载荷部分,我们最初是死锁的:DllMainShellExecutecalc.exentdll!NtAlpcSendWaitReceivePort

dll加载链逆向与实现完美dll劫持

随着 Loader Lock 的发布,我们现在超越了这一点!将我们引向下一个障碍:

dll加载链逆向与实现完美dll劫持

dll加载链逆向与实现完美dll劫持

还记得如何产生一个新线程吗?好吧,该线程已成功生成,但它的加载器正在尝试加载更多库,类似于主程序首次启动时的方式。ShellExecute

解决这个问题是一项非常反复试验的任务;每次程序挂起时,我都会做一些事情,让它走得更远一点,然后冲洗并重复。

但是,对于生成新线程,它基本上可以归结为两件事:

  • Win32 事件

    • 使用 SetEvent 向他们发出信号

    • 如果它们没有发出信号,那么新线程将永远挂在他们身上NtWaitForSingleObject

  • 装载机工作锁:ntdll!LdrpWorkInProgress

    • 将其设置为 0 () 允许由当前线程生成的线程执行加载器工作,同时仍然阻止加载器工作发生在我们程序中的任何其他线程上(这对于防止死锁/崩溃很重要)FALSE

    • 这不是一个关键的部分或事件;只是 记忆中的 1 或 0ntdll.dll

    • 对于由当前线程直接/间接启动的每种加载器工作,它似乎都位于锁层次结构的顶部!

我们可以使用以下命令列出 WinDbg 中的所有 Win32 事件:

0:000> !handle 0 8 Event
Handle 4
Object Specific Information
Event Type Manual Reset
Event is Waiting
Handle c
Object Specific Information
Event Type Auto Reset
Event is Waiting
Handle 3c
Object Specific Information
Event Type Auto Reset
Event is Set
Handle 40
Object Specific Information
Event Type Auto Reset
Event is Waiting
Handle b0
Object Specific Information
Event Type Auto Reset
Event is Waiting
... *snip* More events *snip* ...
13 handles of type Event

我们设置了必要的事件(这些标识符似乎永远不会改变)......

SetEvent((HANDLE)0x40);
SetEvent((HANDLE)0x4);

在生成自己的新线程之前,在当前线程中预加载库加载(我们将讨论这个)......ShellExecute

LoadLibrary(L"SHCORE");
LoadLibrary(L"msvcrt");
LoadLibrary(L"combase");
LoadLibrary(L"RPCRT4");
LoadLibrary(L"bcryptPrimitives");
LoadLibrary(L"shlwapi");
LoadLibrary(L"windows.storage.dll"); // Need DLL extension for this one because it contains a dot in the name
LoadLibrary(L"Wldp");
LoadLibrary(L"advapi32");
LoadLibrary(L"sechost");

找到并翻转状态,以便加载器工作可以在 ...ntdll!LdrpWorkInProgressShellExecute

PBOOL LdrpWorkInProgress = getLdrpWorkInProgressAddress();
*LdrpWorkInProgress = FALSE;

像 ,我们使用 NTDLL 导出函数,在本例中 ,作为定位的起点。ntdll!LdrpLoaderLockRtlExitUserProcessntdll!LdrpWorkInProgress

我们去一个 ...ShellExecute

任务完成!

它有效!我们的 pops(所有线程都成功启动,结果实际上又生成了一个线程),然后我们清理一下,然后再返回以避免以后崩溃/死锁。我已经通过手动单步执行确认,我们的目标工作正常,直到其自然结束,退出代码为 0(成功)!calc.exeShellExecuteShellExecuteDllMainOfflineScannerShell.exe

下面是完全解锁库加载器的高级概述,正如我们在代码中实现的那样:

#define RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN
VOID LdrFullUnlock(VOID) {
// Fully unlock the Windows library loader
//
// Initialization
//
const PCRITICAL_SECTION LdrpLoaderLock = getLdrpLoaderLockAddress();
const HANDLE events[] = {(HANDLE)0x4, (HANDLE)0x40};
const SIZE_T eventsCount = sizeof(events) / sizeof(events[0]);
const PBOOL LdrpWorkInProgress = getLdrpWorkInProgressAddress();
//
// Preparation
//
LeaveCriticalSection(LdrpLoaderLock);
// Preparation steps past this point are necessary if you will be creating new threads
// And other scenarios, generally I notice it's necessary whenever a payload indirectly calls: __delayLoadHelper2
#ifdef RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN
preloadLibrariesForCurrentThread();
#endif
modifyLdrEvents(TRUE, events, eventsCount);
// This is so we don't hang in ntdll!ldrpDrainWorkQueue of the new thread (launched by ShellExecute) when it's loading more libraries
// ntdll!LdrpWorkInProgress must be TRUE while libraries are being loaded in the current thread
// ntdll!LdrpWorkInProgress must be FALSE while libraries are loading in the newly spawned thread
// For this reason, we must preload the libraries ShellExecute will load in the current thread before spawning a new thread
*LdrpWorkInProgress = FALSE;
//
// Run our payload!
//
#ifdef RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN
// Libraries loaded by API call(s) on the current thread must be preloaded
payload();
#else
DWORD payloadThreadId;
HANDLE payloadThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)payload, NULL, 0, &payloadThreadId);
if (payloadThread)
WaitForSingleObject(payloadThread, INFINITE);
#endif
//
// Cleanup
//
// Must set ntdll!LdrpWorkInProgress back to TRUE otherwise we crash/deadlock in NTDLL library loader code sometime after returning from DllMain
// The crash/deadlock occurs to due to concurrent operations happening in other threads
// The problem arises due to ntdll!TppWorkerThread threads by default (https://devblogs.microsoft.com/oldnewthing/20191115-00/?p=103102)
*LdrpWorkInProgress = TRUE;
// Reset these events to how they were to be safe (although it doesn't appear to be necessary at least in our case)
modifyLdrEvents(FALSE, events, eventsCount);
// Reacquire loader lock to be safe (although it doesn't appear to be necessary at least in our case)
// Don't use the ntdll!LdrLockLoaderLock function to do this because it has the side effect of increasing ntdll!LdrpLoaderLockAcquisitionCount which we probably don't want
EnterCriticalSection(LdrpLoaderLock);
}

经过反复测试,它取得了令人印象深刻的 100% 成功率!它每次都有效。

如果我们想调用(或任何其他 API 调用)而不必为当前线程预加载库,还需要完成更多的加载器逆向工程工作。为了弄清楚这一点,我建议在函数 、 、 和 上设置断点。设置读/写观察点并搜索(像我们之前一样使用命令)NTDLL的反汇编以引用加载程序状态变量,例如也可能有所帮助。基本上,找到一些可以在调用之前设置的NTDLL状态,该状态将在启动的第一个线程中触发。这是关于需要发生什么的理论,但它很可能比这更微妙(涉及一些被忽视的控制流;那时可能甚至不需要触摸)。一定有办法做到这一点。但是,这需要一些研究。随意自己试一试!ShellExecuteNtSetEventNtResetEventRtlEnterCriticalSectionRtlLeaveCriticalSectionNtWaitForSingleObject#ntdll!LdrpWorkInProgressShellExecutentdll!LdrpWorkInProgressFALSEShellExecutentdll!LdrpWorkInProgress

或者,我们可以通过设置为 ,调用(这永远不会加载其他库),等待 with 的新线程,然后从我们的新线程调用(或我们想要的任何有效负载)来完全解决这个小的不便 - 不需要“库预加载”。这种解决方法是我在实践中使用此技术的建议。然而,在这个演示中,我想通过直接从 !ntdll!LdrpWorkInProgressFALSECreateThreadDllMainWaitForSingleObject(payloadThread, INFINITE)ShellExecuteShellExecuteDllMain

为了验证我们的石蕊测试在现实中是否成立,我还尝试了从(未定义)执行许多其他复杂的操作,这些操作在解锁加载器之前失败了。这包括使用 WinHTTP 成功下载文件;当我们调用时死锁的显着改进,因为在加载时内部调用。到目前为止,我尝试的所有方法都完美无缺!ShellExecuteDllMainRUN_PAYLOAD_DIRECTLY_FROM_DLLMAINWinHttpOpenWINHTTP_DLL::Startup__delayLoadHelper2ws2_32.dll

安全!

安全,安全,安全,从安全呼叫,好吧,我们来谈谈安全!在这种技术中,最明显的不安全的事情是直接与NTDLL交互。在 Windows 中,NTDLL 中的任何内容都可能因 Windows 版本而异。Microsoft通过稳定的 KERNEL32 API 公开了许多 NTDLL 函数,可以依赖这些函数来保持不变。话虽如此,我试图针对NTDLL中可能基本未被触及的部分,以减少以这种方式发生破损的机会。例如,我使用简单且较小的 NTDLL 导出(如 和)作为起点,用于查找我们完成此工作所需的一些 NTDLL 内部。ShellExecuteDllMainLdrUnlockLoaderLockRtlExitUserProcess

让我们假设我们所依赖的实现细节是成熟的,这使得它们可能保持不变。此外,我们已经有了我们需要的NTDLL内部的地址(也许我们可以在进程中查找调试符号)。那么它有多安全呢?

一些 Windows 技术专家可能会说我们正在做的事情违反了锁层次结构。因此,即使它永远不会成为我们进程中的问题,一些远程进程也可能合法地在我们的进程中生成一个线程,并与加载器执行一些未指定的并发操作,从而导致死锁/崩溃。我已经尽我所能维护了 Loader Lock 层次结构,因为我们无法访问任何内部 Microsoft 文档。为了帮助我们与遵守锁层次结构的目标保持一致,我们通过以与锁定相反的顺序解锁来避免锁顺序反转问题(这也为 中的事件实现)。modifyLdrEvents

NT 内核将在进程中生成线程的一个众所周知的情况是用于处理事件。但是,我认为这只能发生在控制台子系统程序上,而 Windows 子系统 (GUI) 程序。即便如此,只要我们不违反锁层次结构,我们就应该没问题。Ctrl+COfflineScannerShell.exe

充其量,我们正在做的是优先级倒置,虽然从性能的角度来看,这不是一个好的做法,因为会导致高优先级任务等待低优先级的任务,但仍然不是死锁/崩溃。在最坏的情况下,我们违反了锁层次结构,这意味着可能会发生坏事。

如果你正在编写一个真正的生产应用程序,那么不言而喻:不要在家里尝试这个。这项研究的重点只是为了证明完全解锁装载机在技术上是可行的(最重要的是,这是相当史诗般的)。如果您在生产中将这台 Loader Lock Rube Goldberg 机器部署到数百万用户,那就靠您了!话又说回来,技术上的可能性是最好的。;)

dll加载链逆向与实现完美dll劫持

不过,如果你是一个编写生产级软件的开发人员,请不要这样做。即使你认为它对你来说足够稳定,并且不希望听从 Microsoft 的准则,在非 Microsoft 的 Windows 实现上,以相同的方式搜索汇编代码以查找 NTDLL 内部也会失败。

顺便说一句,Wine 的原生 API 函数实现(在 Wine 源代码树中)如下所示:LdrUnlockLoaderLockdlls/ntdll/loader.c

NTSTATUS WINAPI LdrUnlockLoaderLock( ULONG flags, ULONG_PTR magic )
{
if (magic)
{
if (magic != GetCurrentThreadId()) return STATUS_INVALID_PARAMETER_2;
RtlLeaveCriticalSection( &loader_section );
}
return STATUS_SUCCESS;
}

完全不间断,无需基于线程 ID 的不必要计算来创建 / 值。因此,至少在 Windows 的免费实现上,无需搜索汇编代码即可轻松安全地释放 Loader Lock。请注意,该参数用于控制是否应返回错误或作为异常引发错误,目前尚未在 Wine 上实现。对于任何有兴趣帮助 Wine 的人来说,这都是一项出色的初学者友好贡献!Cookiemagicflags

Loader Lock 仅在 Windows 上有问题吗?

tl;是的博士。

构造函数(在 C 或 C++ 中)最接近于非 Windows 平台(如 Mac 和 Linux)(尽管 Windows 也有这个,我已经确认它在它之前不久就在 Loader Lock 下运行)。与库一样,构造函数在加载库时运行(对于卸载,还有析构函数)。在带有 GCC 编译器的 Linux 上,任何函数都可以标记为在加载时按原样运行。DllMaindllmain_dispatchDllMainDllMain__attribute__((constructor))DllMain

就像 Windows 一样,Linux(使用 glibc)当然也有一个“加载器锁”(或互斥锁),可以确保跨线程竞争条件的安全性(我已经阅读了源代码)。

那么,为什么如果您在 Google 上搜索与加载程序锁定相关的问题,您只会在 Windows 方面出现问题。那么为什么只有Microsoft,而不是GNU,有这么长的清单,在加载器锁定下你不应该做的事情。

调查 Windows 和 Linux (glibc) 加载器之间的架构差异是我打算在另一篇文章中做的事情(这篇文章已经持续了足够长的时间)。虽然它比我刚才说的更微妙。

dll加载链逆向与实现完美dll劫持技术性吹毛求疵
在 Windows 世界中,“互斥锁”是指进程间锁,而关键部分是指进程内锁。但是,这些术语在本文中可以互换使用。

缓解和检测

首先防止加载相似的 DLL 将始终是我们防止 DLL 劫持的最有力的防范措施。因为一旦攻击者在系统上运行了代码,我们就只能实施反应性措施,此时,从学术角度来看,它几乎总是游戏结束(即它变成了无限的猫捉老鼠游戏)。

幸运的是,有一种万无一失的方法可以检测 Windows 中内置的静态加载库的 DLL 劫持。检查相关 DLL 的导出,查找与已签名的 Microsoft DLL 中存在的名称重复的符号名称(例如,至少随 Windows 一起提供)。如果 Microsoft 未签名的 DLL 导出许多与 Microsoft 签名的 DLL 相同的符号名称,则其意图很可能是劫持。这之所以有效,是因为如果 Windows 库加载程序发现 DLL 缺少 EXE 所需的导出,它将提前(在执行任何 s 之前)进行救助:DllMain

dll加载链逆向与实现完美dll劫持

这可以与其他检测因素结合使用,例如磁盘上的 DLL 是否也与它从中复制导出的 DLL 共享相同的文件名,或者它是否存在于正在运行的程序的全局环境变量或当前工作目录 (CWD) 中,以形成至少内置库的 DLL 劫持的可靠启发式方法。PATH

默认情况下,最好密切关注用户可写的目录,例如(如所示)。如果 CWD 是用户可写的,则 CWD 也是如此。如果 CWD 是从父进程继承的,则尤其如此。PATHC:Users<YOUR_USERNAME>AppDataLocalMicrosoftWindowsAppscmd.exe从用户可写或 CWD(它们在搜索顺序中排在最后)加载的库应始终受到额外的审查。PATH这也适用于用户可写的程序目录,如果该程序看起来是从用户可写的位置复制的。

检查导出不适用于动态加载的 DLL(通过 加载)。虽然,以这种方式加载 DLL 并不常见。LoadLibrary

为了防止这种情况,可以检测到 Microsoft 程序加载了未由 Microsoft 签名的 DLL。事实上,此缓解措施已存在于 Windows 中!下面介绍如何使用 Set-ProcessMitigation PowerShell cmdlet 有效地修补目标以防范 DLL 劫持:OfflineScannerShell.exe

Set-ProcessMitigation -Enable MicrosoftSignedOnly -Name OfflineScannerShell.exe

现在,当我们尝试劫持任何名为 的程序时,我们将收到一个错误,通知我们非 Microsoft 签名的 DLL 已被阻止:OfflineScannerShell.exe

dll加载链逆向与实现完美dll劫持

因此,只需将该注册表值投入到组织中的所有系统中,就这样,您将轻松挫败任何劫持企图!OfflineScannerShell.exe

结束语

我们已经成功地在以前的研究基础上进行了创新,发现了一些新颖的干净和通用的DLL劫持技术!我们还学到了很多关于并发性、Windows 库加载器、WinDbg 的知识,并揭开了 Loader Lock 内部工作的神秘面纱。如果运气好的话,我们的发现和缓解/检测工作将有助于推动安全行业向前发展!

仅在Windows中,就有无数机会使用DLL劫持(使用我们的新技术变得更好)将隐蔽的代码注入到Microsoft签名的程序中。事实上,安全专家 Wietze Beukema (@Wietze) 已经通过他的项目 HijackLibs包括 Sigma 检测)编制了一份包含数百个此类程序的列表!

我们新的 DLL 劫持方法还有助于简化特权应用程序意外加载攻击者控制的 DLL 的权限升级漏洞。这通常发生在特权应用程序缺少 DLL 时,这可能会导致它从用户可写路径加载。

今天,我们只发现了干净和通用的DLL劫持技术的冰山一角。还有很多东西有待发现 - 我有一份笔记,里面充满了其他有前途的功能,内置在NTDLL和CRT等地方,这些地方可能存在更多可能更优越的技术(这是一个需要进一步研究的领域)。

很抱歉两个月没有发表文章。展望未来,我致力于发表更多较短的文章(仍然具有相同的质量),这样我就可以定期分享新内容。这篇文章大约有 9000 字长,所以研究然后每篇文章最多写 1000 字应该能让我到达那里。更多好东西即将到来!

请参阅我们在 LdrLockLiberator GitHub 存储库上讨论的所有内容的完整开源代码

原文英文地址:https://elliotonsecurity.com/perfect-dll-hijacking/

dll加载链逆向与实现完美dll劫持

dll加载链逆向与实现完美dll劫持

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年2月15日20:12:32
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   dll加载链逆向与实现完美dll劫持http://cn-sec.com/archives/2167768.html

发表评论

匿名网友 填写信息