前几天, GitHub 上的PPLdump出现了一个issue,指出它不再适用于 Windows 10 21H2 Build 19044.1826。起初我持怀疑态度,所以我启动了一个新的虚拟机并开始调查。这是我发现的……
原文链接:https://itm4n.github.io/the-end-of-ppldump/
简而言之 PPLdump
如果您正在阅读本文,我会假设您已经知道 PPLdump 是什么以及它的作用。但以防万一,这里有一个非常简短的总结。
PPLdump是一个用 C/C++ 编写的工具,它实现了一个用户态漏洞利用,以管理员身份将任意代码注入 PPL。这项技术是 Alex Ionescu 和 James Forshaw 对受保护进程(PPs 和 PPLs)进行的深入研究的众多发现之一。
提醒一下,它的工作原理是这样的:
- 调用API
DefineDosDevice
以诱使 CSRSS 服务创建\KnownDlls
指向任意位置的符号链接。 - 创建一个新的 Section 对象(由前面的符号链接指向)来托管包含我们要注入的代码的自定义 DLL 的内容。
- 由作为 PPL 运行的可执行文件导入的 DLL 被劫持,我们的代码被执行。
这里要记住的最重要的事情是,整个漏洞利用依赖于 PPL 中存在但不存在于 PP 中的弱点。实际上,PPL 可以从\KnownDlls
目录加载 DLL ,而 PP 总是从磁盘加载 DLL。这是一个关键的区别,因为只有在最初从磁盘读取 DLL 以创建新的 Section 对象时才会检查 DLL 的数字签名。映射到Process的虚拟地址空间时,事后不检查。
构建 19044.1826 发生了什么?
PPLdump 的调试输出已在 GitHub问题中提供,但我在带有 2022 年 7 月更新包(Windows 10 21H2 Build 19044.1826)的 Windows 10 VM 中复制了它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
c:\Temp\PPLdump.exe -d lsass lsass.dmp [lab-admin] [*] Found a process with name 'lsass' and PID 740 [DEBUG][lab-admin] Check requirements [DEBUG][lab-admin] Target process protection level: 4 - PsProtectedSignerLsa-Light [lab-admin] [*] Requirements OK [...] '\KernelObjects\EventAggregation.dll' [lab-admin] [*] DefineDosDevice OK [...] [DEBUG][SYSTEM] Check whether the symbolic link was really created in '\KnownDlls\' '\KernelObjects\EventAggregation.dll' [...] [DEBUG][SYSTEM] Create protected process with command line: C:\WINDOWS\system32\services.exe 740 "lsass.dmp" 2f2e0a5f-40d4-4034-ba27-81498c6869b -d [SYSTEM] [*] Started protected process, waiting... [DEBUG][SYSTEM] Unmap section '\KernelObjects\EventAggregation.dll'... [DEBUG][SYSTEM] Process exit code: 0 [-] The DLL was not loaded. :/ |
总的来说,输出看起来相当不错,符号链接被正确创建,\KnownDlls
所以乍一看,这个DefineDosDevice
技巧仍然可以正常工作。这可以通过 WinObj 轻松确认,因为如果不能在“Windows TCB”级别执行 PPL 中的代码,就无法删除符号链接。
然后使用我们自定义 DLL 的内容创建一个新部分,但该工具[-] The DLL was not loaded.
在尝试劫持后最终失败并出现错误,该错误EventAggregation.dll
通常由services.exe
.
在这种情况下,显而易见的做法是启动 Process Monitor,看看我们是否能发现任何看起来不正确的地方。
从最初的事件中,我们已经可以看到某些事情并没有按计划进行。由于services.exe
作为 PPL 执行,我们不应该在 DLL 上看到任何文件操作(例如 CreateFile
或CreateFileMapping
)kernel32.dll
,KernelBase.dll
因为这些是已知的 DLL。相反,它们应该直接从各自的部分\KnownDlls\kernel32.dll
和\KnownDlls\kernelbase.dll
.
结论是 PPL 现在看起来就像 PP 一样,因此不再依赖于Known DLL。
NTDLL 中的补丁?
PPL 流程的创建方式显然发生了一些变化。我已经知道去哪里看,但为了这篇文章,我将通过二进制差异以正确的方式做到这一点。
我首先在 Winbindex 上获得了 Windows 10 21H2 的最后两个版本,并ntdll.dll
使用Windows SDK下载了公共符号。symchk.exe
1 2 3 4 |
"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\symchk.exe" /s srv*C:\symbols*https://msdl.microsoft.com/download/symbols C:\Temp\ntdll_*.dll SYMCHK: FAILED files = 0 SYMCHK: PASSED + IGNORED files = 2 |
在加载文件并分析它们之后,我只是使用 Ghidra 的BinDiff 扩展来以适当的格式导出结果。
然后可以将两个“BinExport”文件导入 BinDiff 以比较两个版本的ntdll.dll
. 通过“相似度”对函数进行排序,我们可以立即看到 7 个函数有一些细微的差异,但其中一个非常突出:LdrpInitializeProcess
. 这正是我期望找到一些变化的地方。
我们还可以看到有一个不匹配的功能,它是在最新版本中添加的:Feature_Servicing_2206c_38427506__private_IsEnabled
.
加载程序中已知的 DLL 处理
最初,当创建一个新进程时,只加载 NTDLL。在 NTDLL 中实现的图像加载器负责加载其他 DLL(以及许多其他事情)。要确定它是否应该使用已知 DLL ,它只需检查进程环境块( PEB
)中的几个标志。
此检查在以下屏幕截图(构建版本10.0.19044.1741
)中突出显示。
该PEB
结构已部分记录,但我们不会在官方文档中找到我们需要的信息。另一方面,Process Hacker包含一个更详细的定义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// phnt/include/ntpebteb.h typedef struct _PEB { BOOLEAN InheritedAddressSpace; // Byte at (byte*)peb+0 BOOLEAN ReadImageFileExecOptions; // Byte at (byte*)peb+1 BOOLEAN BeingDebugged; // Byte at (byte*)peb+2 union { BOOLEAN BitField; // Byte at (byte*)peb+3 struct { BOOLEAN ImageUsesLargePages : 1; BOOLEAN IsProtectedProcess : 1; BOOLEAN IsImageDynamicallyRelocated : 1; BOOLEAN SkipPatchingUser32Forwarders : 1; BOOLEAN IsPackagedProcess : 1; BOOLEAN IsAppContainer : 1; BOOLEAN IsProtectedProcessLight : 1; BOOLEAN IsLongPathAwareProcess : 1; }; }; // ... } |
在偏移量 3 处(peb + 3
在if
语句中),我们可以找到一个包含一组 8 位标志的字节值。最低有效位保存ImageUsesLargePages
标志的值,而最高有效位保存IsLongPathAwareProcess
标志的值。
有了这些知识,我们就可以将代码翻译*(byte *)(peb + 3)
成peb->BitField
. 然后,该值0x42
是一个掩码,允许加载程序隔离和检查标志IsProtectedProcess
和IsProtectedProcessLight
. 因此,反编译后的代码if ((*(byte *)(peb + 3) & 0x42) == 2)
可以解释如下。
1 2 3 4 5 |
if (peb->IsProtectedProcess && !peb->IsProtectedProcessLight) { // Do NOT use Known DLLs } else { // Use Known DLLs } |
换句话说,只有当进程是PP时, Known DLL才会被忽略,因此PPL的行为就像正常进程一样。这是对我们已经知道的内容的确认,所以让我们找出构建版本中的变化。10.0.19044.1806
如果我们搜索同一行代码,我们会立即意识到还有一个额外的检查取决于Feature_Servicing_2206c_38427506__private_IsEnabled()
. 多么巧合!
在该else
块中,我们可以看到以下检查。
因此,Ghidra 生成的反编译代码可以总结如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
bool bFeatureEnabled = Feature_Servicing_2206c_38427506__private_IsEnabled(); if (bFeatureEnabled == 0) { if ((*(byte *)(peb + 3) & 0x42) != 2) { // Use Known DLLs } else { // Do NOT use Known DLLs } } else { if ((*(byte *)(peb + 3) & 2) != 0) { // Do NOT use Known DLLs } else { // Use Known DLLs } } |
如果我们应用我之前详述的相同逻辑,我们可以将上面的代码翻译成这个更易读的版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
bool bFeatureEnabled = Feature_Servicing_2206c_38427506__private_IsEnabled(); if (bFeatureEnabled == FALSE) { if (peb->IsProtectedProcess && !peb->IsProtectedProcessLight) { // Do NOT use Known DLLs } else { // Use Known DLLs } } else { if (peb->IsProtectedProcess) { // Do NOT use Known DLLs } else { // Use Known DLLs } } |
补丁现在看起来很清晰。首先,检查“功能服务”值。如果禁用此功能,加载程序会退回到以前版本的代码,因此 PPL 会加载Known DLL。另一方面,如果启用此功能,加载程序只需检查标志是否peb->IsProtectedProcess
已设置。因此,受保护的进程(无论是 PP 还是 PPL)都不会使用Known DLLs。
装载机中的新检查
在上一部分中,我们看到 的结果Feature_Servicing_2206c_38427506__private_IsEnabled()
决定了加载器将使用的有关受保护进程和已知 DLL的逻辑。乍一看,这个函数似乎并不复杂,所以让我们看看我们能从中学到什么。
根据 Ghidra 生成的反编译代码,该函数似乎首先检索全局变量的值Feature_Servicing_2206c_38427506__private_featureState
,如果尚未初始化,则对其进行初始化,然后返回其第四位 ( uVar1 >> 3 & 1
) 的值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
DWORD Feature_Servicing_2206c_38427506__private_IsEnabled() { DWORD dwFeatureServicingState; BOOL bIsEnabled; dwFeatureServicingState = Feature_Servicing_2206c_38427506__private_featureState; if ((dwFeatureServicingState & 1) == 0) { // The global variable is not yet initialized, initialize it. dwFeatureServicingState = wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState(...); } // Extract the fourth bit bIsEnabled = dwFeatureServicingState >> 3 & 1; // ... return bIsEnabled; } |
因此,看起来全局变量Feature_Servicing_..._featureState
包含一组位标志,用于确定是否启用了特定功能。借助几行 C/C++ 和调试器,我们可以很容易地验证这一点。
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 |
#include <iostream> #include <Windows.h> typedef UINT(NTAPI* _FeatureIsEnabled)(); int wmain(int argc, wchar_t* argv[]) { DWORD dwOffsetFeatureIsEnabled = 0x0009b360; DWORD dwOffsetFeatureServicingState = 0x0016d288; PDWORD pFeatureServicingState = NULL; _FeatureIsEnabled FeatureIsEnabled = NULL; BOOL bFeatureIsEnabled = FALSE; // Get NTDLL base address HMODULE ntdll = LoadLibraryW(L"ntdll.dll"); // Calculate address of Feature_Servicing_..._featureState pFeatureServicingState = (PDWORD)((PBYTE)ntdll + dwOffsetFeatureServicingState); // Calculate address of Feature_Servicing_..._IsEnabled() FeatureIsEnabled = (_FeatureIsEnabled)((PBYTE)ntdll + dwOffsetFeatureIsEnabled); wprintf(L"Feature_Servicing_2206c_38427506__private_featureState: 0x%08x\r\n", *pFeatureServicingState); bFeatureIsEnabled = FeatureIsEnabled(); wprintf(L"Feature enabled: %d\r\n", bFeatureIsEnabled); wprintf(L"----\r\n"); wprintf(L"Setting the fourth bit to 0\r\n"); *pFeatureServicingState = *pFeatureServicingState & 0xfffffff7; wprintf(L"Feature_Servicing_2206c_38427506__private_featureState: 0x%08x\r\n", *pFeatureServicingState); bFeatureIsEnabled = FeatureIsEnabled(); wprintf(L"Feature enabled: %d\r\n", bFeatureIsEnabled); return 0; } |
运行上面的代码会产生以下输出。
1 2 3 4 5 6 7 |
C:\Temp\FeatureServicing.exe Feature_Servicing_2206c_38427506__private_featureState: 0x0000001b Feature enabled: 1 ---- Setting the fourth bit to 0 Feature_Servicing_2206c_38427506__private_featureState: 0x00000013 Feature enabled: 0 |
Feature_Servicing_..._featureState
is的值0x0000001b
,转换为0001 1011
二进制。第四位被设置,返回值为1
. 1111 0111
在第二部分中,我使用掩码(即 )使用按位与操作手动取消设置第四位0xf7
。在这种情况下,返回值为0
,这倾向于证实我对代码的解释。
最后,为了更好的衡量,我们还可以手动设置Feature_Servicing_..._featureState
to的值0
并检查返回的值wil_..._ReevaluateCachedFeatureEnabledState(...)
以确保它是0x1b
.
返回值(见RAX
)实际上是0x7ff700000000001b
但EAX
寄存器(即前32位RAX
)用于以下操作(mov ebx,eax
)所以有效值确实是0x0000001b
。
结论
我不确定是什么促使微软首先区分关于已知 DLL的 PP 和 PPL。也许这是一个性能问题,我不知道。无论如何,他们已经意识到了这个潜在的弱点,否则我猜他们不会对 PP 破例。问题是,这个安全漏洞现在已经修补,这是向前迈出的一大步。我喜欢认为我在这个变化中扮演了一个小角色,尽管我完全知道所有的工作都已经由 Alex 和 James 完成。
总之,这确实是“ PPLdump 的终结”。然而,这个工具只利用了 PPL 的一个弱点,但我们可能仍然可以利用其他几个用户空间问题。所以,从我的角度来看,这也是一个开始研究另一个旁路的机会……
链接和资源
- Windows 漏洞利用技巧:利用任意对象目录创建来提高本地特权 – https://googleprojectzero.blogspot.com/2018/08/windows-exploitation-tricks-exploiting.html
- 您真的了解 LSA 保护 (RunAsPPL) 吗?- https://itm4n.github.io/lsass-runasppl/
- 在用户区绕过 LSA 保护 – https://blog.scrt.ch/2021/04/22/bypassing-lsa-protection-in-userland/