0x03 利用DefineDosDevice
现在我们开始深入分析DefineDosDevice
函数,我们可以看到该函数中存在哪种脆弱性,以及如何利用这个脆弱点来实现我们的目标。
DefineDosDevice内部原理
JF在博客中逆向了BaseSrvDefineDosDevice
函数,提供了相应的伪代码(参考此处)。如果我们重复该操作,应当注意到第4步存在一些错误,应该为CsrImpersonateClient()
,而不是CsrRevertToSelf()
。无论如何,我不会复制粘贴他的代码,而是使用图表来展示整体流程。
在如上流程图中,我使用不同颜色高亮出了一些单元。其中,模拟函数为橙色,符号链接创建步骤为蓝色。最后,我用红色突出了我们需要注意的关键路径。
首先,可以看到CSRSS
服务在模拟调用者(比如RPC客户端)时,会尝试打开\??\DEVICE_NAME
,主要目的是先删除符号链接(如果已经存在)。除此之外,服务还会检查符号链接是否为“全局”链接,这里会使用一个内部函数,检查对象的“实际”路径是否以\GLOBAL??\
开头。如果满足该条件,则会在后续执行中禁用身份模拟,服务在调用NtCreateSymbolicLinkObject()
之前不会模拟客户端,这意味着符号链接会由CSRSS
服务自己创建。最后,如果该操作成功,服务会将对象标记为“永久”。
是否为漏洞?
大家可能已经意识到,这里存在某种TOCTOU(Time-of-Check Time-of-Use)漏洞。用来打开符号链接的路径以及用来创建符号链接的路径相同,都为\??\DEVICE_NAME
。然而,“打开”操作始终通过模拟用户来执行,而如果禁用模拟,那么“创建”操作可能直接由SYSTEM
来完成。前面我提到过,\??
代表用户的本地DOS设备目录,因此会根据用户的身份解析为不同的路径。因此,尽管这两种情况下都使用相同的路径,但实际上可能会指向完全不同的路径。
为了利用这种行为,我们首先必须解决一个挑战:我们需要找到一个“设备名”。当服务模拟客户端身份时,该名称会解析成我们可控制的一个“全局对象”。并且当禁用模拟时,这个“设备名”必须解析成\KnownDlls\FOO.dll
。这听上去有点棘手,但我们来逐步搞定它。
先从最简单的部分开始,我们需要确定\??\DEVICE_NAME
中的DEVICE_NAME
,以便当调用者为SYSTEM
时,这个路径可以解析为\KnownDlls\FOO.dll
。这里我们可知\??
会被解析为\GLOBAL??
。
如果检查\GLOBAL??\
目录的内容,可以看到里面有一个非常方便的对象。
在这个目录中,GLOBALROOT
对象是指向空路径的一个符号链接。这意味着类似\??\GLOBALROOT\
的路径会被翻译为\
,也就是对象管理器的根(因此称为“全局根”)。如果我们将这个原则应用于我们的“设备名”,可知当调用者为SYSTEM
时,\??\GLOBALROOT\KnownDlls\FOO.DLL
会解析为\KnownDlls\FOO.dll
。我们已经解决了部分问题!
现在我们知道,我们需要为DefineDosDevice
函数调用提供GLOBALROOT\KnownDlls\FOO.DLL
作为“设备名”(注意\??\
会自动加到这个值作为前缀)。如果我们希望CSRSS
服务禁用模拟,还知道符号链接对象必须被视为“全局”,这样其路径必须以\GLOBAL??\
开头。因此,这里的问题在于:我们如何将类似\??\GLOBALROOT\KnownDlls\FOO.DLL
的路径转换为\GLOBAL??\KnownDlls\FOO.dll
?答案其实非常简单,因为这几乎就是符号链接的定义。当服务模拟用户时,我们知道\??
指向的是这个特定用户的本地DOS设备目录。因此我们所要做的就是创建类似\??\GLOBALROOT
的符号链接,让该链接指向\GLOBAL??
,仅此而已。
总结以下,当用户而不是SYSTEM
打开路径时,映射关系如下:
\??\GLOBALROOT\KnownDlls\FOO.dll
-> \Sessions\0\DosDevices\00000000-XXXXXXXX\GLOBALROOT\KnownDlls\FOO.dll
\Sessions\0\DosDevices\00000000-XXXXXXXX\GLOBALROOT\KnownDlls\FOO.dll
-> \GLOBAL??\KnownDlls\FOO.dll
另一方面,如果SYSTEM
打开相同的路径:
\??\GLOBALROOT\KnownDlls\FOO.dll
-> \GLOBAL??\GLOBALROOT\KnownDlls\FOO.dll
\GLOBAL??\GLOBALROOT\KnownDlls\FOO.dll
-> \KnownDlls\FOO.dll
这里还有最后一个点需要考虑。在检查对象是否为“全局”对象之前,该对象首先必须存在,否则最初的“打开”操作将会失败。因此,我们需要确保在调用DefineDosDevice
之前,\GLOBAL??\KnownDlls\FOO.dll
是已经存在的一个符号链接对象。
这里还有个小问题。管理员无法在\GLOBAL??
中创建对象或者目录。这并不是一个真正的问题,我们只需要在攻击过程中,将权限临时提升为SYSTEM
即可。作为SYSTEM
,我们首先可以在\GLOBAL??\
中创建一个虚假的KnownDlls
目录,然后在其中创建一个虚拟符号链接对象,使用我们希望劫持的DLL名。
完整利用过程
前面内容较多,因此在讨论最后内容之前,我们先简单概述一下利用步骤。在利用过程中,我们假设以管理员权限来执行操作:
1、提升至SYSTEM
权限,否则我们无法在\GLOBAL??
中创建对象。
2、创建对象目录\GLOBAL??\KnownDlls
,模拟实际的\KnownDlls
目录。
3、创建符号链接\GLOBAL??\KnownDlls\FOO.dll
,其中FOO.dll
为我们希望劫持的DLL名。这里要记住一点,重要的是链接本身的名称,而不是链接指向的目标。
4、释放SYSTEM
权限,恢复到我们的管理员用户上下文。
5、在当前用户的DOS设备目录中创建一个符号链接GLOBALROOT
,指向\GLOBAL??
。不能以SYSTEM
执行这个步骤,因为我们想在自己的DOS目录中创建一个虚假的GLOBALROOT
链接。
6、这是攻击的核心步骤。调用DefineDosDevice
,使用GLOBALROOT\KnownDlls\FOO.dll
作为设备名。这个设备的目标路径为DLL的位置,后面我们再分析这一点。
在最后一个步骤中,CSRSS
服务内部的处理过程如下。首先该服务会收到GLOBALROOT\KnownDlls\FOO.dll
值,然后在前面加上\??\
,生成的设备名为\??\GLOBALROOT\KnownDlls\FOO.dll
。然后,服务会模拟客户端,尝试打开对应的符号链接对象。
\??\GLOBALROOT\KnownDlls\FOO.dll
-> \Sessions\0\DosDevices\00000000-XXXXXXXX\GLOBALROOT\KnownDlls\FOO.dll
-> \GLOBAL??\KnownDlls\FOO.dll
由于对象存在,服务会检查对象是否为全局对象。对象的“实际”路径以\GLOBAL??\
开头,因此的确会被认为是全局对象,因此会在后续执行中禁用模拟。当前链接会被删除,新创建一个链接,但此时RPC客户端没有被模拟,所以操作会在CSRSS
服务上下文中,以SYSTEM
执行:
\??\GLOBALROOT\KnownDlls\FOO.dll
-> \GLOBAL??\GLOBALROOT\KnownDlls\FOO.dll
-> \KnownDlls\FOO.dll
大功告成,现在服务会创建符号链接\KnownDlls\FOO.dll
,并且目标路径我们可控。
0x04 通过Known DLLs实现DLL劫持
现在我们已经知道如何在\KnownDlls
目录中添加任意条目,那么应该回到我们最初的问题,以及漏洞利用方面的限制。
DLL劫持目标
我们想在PPL中执行任意代码,理想情况下是使用WinTcb
签名者类型。因此我们首先需要找到合适的可执行程序。在Windows 10上,据我所知,有4个内置的程序可以以这种保护级别来执行:wininit.exe
、services.exe
、smss.exe
以及csrss.exe
。smss.exe
和csrss.exe
无法在Win32模式下执行,所以可以排除。我针对wininit.exe
做了一些测试,但发现以具有debug权限的管理员来运行这个程序不是一个好主意。实际上,这个程序很大概率会将自己标记为关键进程,意味着当程序结束时,系统很可能会出现BSOD。
这样我们只剩下一个目标:services.exe
。事实证明,这的确是最佳目标,其主函数非常容易反编译,对应的伪代码如下:
int wmain()
{
HANDLE hEvent;
hEvent = OpenEvent(SYNCHRONIZE, FALSE, L"Global\\SC_AutoStartComplete");
if (hEvent) {
CloseHandle(hEvent);
} else {
RtlSetProcessIsCritical(TRUE, NULL, FALSE);
if (NT_SUCCESS(RtlInitializeCriticalSection(&CriticalSection))
SvcctrlMain();
}
return 0;
}
该程序首先会尝试打开全局Event
对象,如果成功,则关闭句柄。这种简单的同步机制可以确保services.exe
不会被执行两次,对我们的使用场景而言也是完美的逻辑,因为我们不希望干扰服务控制管理器(services.exe
是SCM使用的镜像文件)。
现在,为了澄清services.exe
加载的DLL,我们可以使用Process Monitor,设置一些过滤器。
从输出中可知,services.exe
会加载3个DLL(都不是Known DLLs),但仅靠这些信息并不够,我们还需要找到导入了那些函数。因此我们需要观察PE的导入表。
从这里可知,dpapi.dll
只导入了一个函数:CryptResetMachineCredentials
。因此,这就是需要劫持的最简单的DLL。我们只需要记住,我们需要导出该函数,否则我们构造的DLL不会被加载。
但就这么简单吗?并非如此。在安装各种Windows并进行测试后,我发现这种行为并不一致。在某些版本的Windows 10上,由于某些原因,dpapi.dll
根本不会被加载。此外,在Windows 8.1上,services.exe
导入的DLL也完全不同。最后,我决定将这些区别考虑在内,以便开发出的工具能够使用所有最新版本的Windows(包括Server版)。
DLL文件映射
在前文中,我们了解了如何诱导CSRSS
服务在\KnownDlls
中创建任意符号链接对象,但我故意忽略了一个重要的因素:符号链接的目标路径。
符号链接实际上可以指向对象管理器中任意类型的对象,但在我们这个场景中,我们需要模仿程序库作为Known DLL加载的行为,这意味着目标必须为Section
对象,而不是DLL文件路径。
前面提到过,“Known DLLs”是存储在\KnownDlls
对象目录中的Section
对象,这也是DLL搜索顺序中的第一个位置。因此,如果程序加载名为FOO.dll
的DLL,并且Section
对象\KnownDlls\FOO.dll
存在,那么加载器就会使用这个镜像,而不是再次映射文件。在我们的场景中,我们必须手动执行该步骤。“手动”这个词有点不合适,因为如果我们以“合法方式”进行操作,就不需要自己真正去映射文件。
Section
对象可以调用NtCreateSection
来创建,这个原生API函数需要一个AllocationAttributes
参数,通常设置为SEC_COMMIT
或SEC_IMAGE
。当设置为SEC_IMAGE
,表示我们想将之前打开的文件映射为可执行镜像文件。因此,它可以被正确且自动地映射到内存中。但这意味着我们必须嵌入一个DLL,将其写入磁盘,使用CreateFile
打开,获得文件句柄,最终调用NtCreateSection
。对于PoC而言,这已经足够,但我想更进一步,找到更优雅的解决方案。
另一种方法是在内存中执行所有操作。与众所周知的Process Hollowing技术类似,我们需要创建一个具备足够内存空间的Section
对象,以存储我们的DLL镜像,然后解析NT头,识别PE中的每个section
,正确进行映射,这正是加载器做的工作。这是非常乏味的一个过程,我不想走这么远。在研究过程中,我偶尔发现@_ForrestOrr写过关于“DLL Hollowing”的一篇有趣的文章。在文章PoC中,他使用了Transactional NTFS(也称为TxF),将已有DLL文件内容替换为自己的payload,而没有真正修改磁盘上的文件。这种技术唯一的要求是我们必须具备目标文件的写权限。
在这个场景中,我们假设自己已具备管理员权限,所以条件很完美。我们可以在System
目录中打开一个DLL作为目标,将其内容替换为我们的payload DLL,最后在NtCreateSection
API调用中,使用已打开的句柄以及SEC_IMAGE
标志。但我们仍然需要目标文件的写权限,即便我们不需要修改文件本身。这是一个问题,因为系统文件的所有者为TrustedInstaller
。由于我们具备管理员权限,那么可以提升至TrustedInstaller
权限,然而这里有个更简单的解决方案。事实证明,C:\Windows\System32\
中的某些(DLL)文件所有者为SYSTEM
,因此我们只要在该目录中搜索合适的目标即可。我们还应当确保其大小足够大,可以替换成我们自己的payload。
使用SYSTEM身份
在利用过程中,我坚持一个原则:必须以除SYSTEM
之外的任何用户身份来调用DefineDosDevice
API函数,否则整个“技巧”将无法正常工作。但如果我们已经是SYSTEM
并且不具备管理员账户那该怎么办?我们可以创建一个临时的管理员账户,但这非常不优雅,更好的办法是简单模拟已存在的用户。比如,我们可以模拟LOCAL SERVICE
或者NETWORK SERVICE
,这两个用户都有自己的DOS设备目录。
假设我们具备debug
及impersonate
权限,我们可以枚举当前进程,找到以LOCAL SERVICE
权限运行的进程,复制主令牌,临时模拟该用户,就这么简单。
无论我们以SYSTEM
或者管理员来执行利用代码,这两种情况下我们都需要在两个身份之间来回移动。
0x05 总结
在本文中,我们了解了如何通过管理员,利用貌似无害的API函数,配合某些非常巧妙的技巧,最终将任意代码注入具有最高级别的PPL中。我参考ProcDump,在一款新的工具(PPLdump)中实现了这种技术。假设我们已经具备管理员或者SYSTEM
权限,就可以转储任何PPL的内存,包括启用LSA保护时的LSASS。
这个“漏洞”最早于2018年公布,并且现在还没修复。如果想了解背后原因,我们可以参考微软漏洞赏金项目中的Windows Security Servicing Criteria,会发现即使在非管理员权限下实现PPL绕过也不是一个待解决的问题。
通过在一个独立工具中实现该技术,我学习了很多与Windows内部原理有关的知识,以前并没有机会去了解。我也在本文中涉及了部分内容。
0x06 参考资料
- @tiraniddo – Windows Exploitation Tricks: Exploiting Arbitrary Object Directory Creation for Local Elevation of Privilege – link
- @_ForrestOrr – Masking Malicious Memory Artifacts – Part I: Phantom DLL Hollowing – link