本博客介绍两个连环漏洞:第一阶段是由 ROOT 驱动器重新映射导致的 DLL 劫持漏洞,第二阶段是由 CSRSS 服务器管理的激活缓存毒化漏洞。
第一阶段在 Ekoparty 2023 上由 BlueFrost Security 的 Nicolás Economou 在题为 “I'm High” 的演讲中详细展示。他解释了如何利用当时尚未被 Microsoft 修补的漏洞。该漏洞允许 MEDIUM INTEGRITY 用户提升至有限的 HIGH PRIVILEGES,但无法获得完整的 Administrator 权限。
第二阶段并未在该会议上展示,不过提供了开始研究的一些步骤提示。
首先,我们将回顾第一阶段以提供背景介绍。然后深入探讨我对第二阶段的研究,详细说明如何从有限的 HIGH INTEGRITY 完全提权至 Administrator。这包括针对所有 Windows 版本的两个阶段完整可用的 PoC,已在 Windows 10、Windows 11、Windows Server 2022 和 Windows Server 2019(已应用所有更新)上成功测试。

该阶段唯一的要求是初始进程以 MEDIUM INTEGRITY LEVEL 启动,且用户属于 Administrator 组。
第一阶段利用可总结为以下步骤:
例如:将磁盘从 "C:\" 重新映射到 "C:\users\public"
这也会将文件夹 "system32" 从 "C:\windows\system32" 重新映射到 "C:\users\public\windows\system32"
其中一个受影响的程序是 CTFMON,它以 HIGH INTEGRITY LEVEL 运行,但不具备 Administrator 权限。
正常情况下,它会尝试从真实的 system32 文件夹加载名为 MsCtfMonitor.dll 的模块,但由于 ROOT 驱动器被重新映射,它会在我们控制的假 system32 中查找 MsCtfMonitor.dll,我们可以在其中创建并放置一个同名特制 DLL。
此时,通过在假 system32 文件夹中放置我们版本的 MsCtfMonitor.dll,其 DoMsCtfMonitor 函数被调用,并在 HIGH INTEGRITY LEVEL 下执行我们的代码。




同时,我们可以确认,尽管进程处于 HIGH INTEGRITY LEVEL,但并不具备 Administrator 权限:


在他的 Ekoparty 演讲中,Nicolas 建议了以下步骤来完成利用:


虽然这看起来简单,但需要大量时间进行逆向和调试。
进一步研究这个攻击向量故事后,发现激活上下文缓存的毒化已在一些漏洞利用中被使用。因此,了解之前如何进行利用是有价值的,可以提供额外的背景和见解。关于此利用的详细信息可通过 Zero Day Initiative 的文章 毒化激活上下文缓存:利用 CSRSS 进行权限提升 获取。
激活缓存的使用发生在程序即将加载需要特定版本的库时。
例如,如果一个应用程序要加载 C:\Windows\System32\comctl32.dll,不能保证该位置的 comctl32.dll 是应用程序所需的版本。这是激活上下文缓存的一个基本用例。程序可以向 CSRSS 服务器发送请求,以处理一个新的激活上下文条目并添加到缓存中,这样程序就可以加载所需的特定库版本。
为此,使用了所谓的清单,它是 XML 格式的。通常作为资源嵌入在 EXE 或 DLL 文件中。或者,Windows 会在程序可执行文件所在的文件夹中搜索清单文件。
上面提到的 URL 包含一些旧漏洞利用使用的清单文件示例,例如诱使系统从攻击者通过 PATH TRAVERSAL 技术到达的受控目录加载库 advapi32.dll。

当然,一些使用的攻击向量已被修补,同时发现了新技术。此外,在 Windows 11 22H2 的 2022 年 10 月补丁中,添加了一个新的检查。
实施此补丁后,当注册 激活上下文 (ACTX) 时,只有当将新条目添加到缓存中的进程的 RID 大于或等于使用该条目的进程的 RID 时,才能绕过检查。
在 winnt.h 中我们可以看到 RID 值:

绕过此检查的提议是从运行特制 DLL 的 CTFMON 进程创建一个包含 激活上下文 的请求。此特制 DLL 的 RID=0x3000,在条目添加到缓存后,TCMSETUP(RID=0x3000)将加载 tapi32.dll。
在我尝试按照步骤操作的过程中,我尝试了所有可能的组合来使用 CreateActCtx 注册 ACTX。这被证明是不可能的,因为总有一个检查阻止它。
需要注意的是,此函数位于用户态,由 kernel32.dll 导出。可以通过在内存中修补 DLL 来绕过检查,这不太优雅,但可行且应该有效。

Nicolas 的演讲幻灯片建议使用 LOW LEVEL。然而,注意到那个眨眼表情,很明显在没有补丁的情况下利用此漏洞时,使用 CreateActCtx 不是 更好的选择。

高级本地过程调用 (ALPC) 是一种用于在 Windows 操作系统内高速发送消息的跨进程通信机制。与标准 Windows API 不同,ALPC 不直接对应用程序可用。相反,它是一种内部机制,只能由 Windows 操作系统的组件访问。(以及我们。)
经过进一步研究,我注意到一些旧的缓存毒化漏洞利用使用了 ALPC 与服务器直接通信。例如在 Philip Tsukerman 的文章 激活上下文——一个爱情故事 中可以看到。
CsrClientCallServer 函数实现了 Win32 进程与 CSRSS 进程之间的 ALPC 接口。
因此,应该尝试使用 CsrClientCallServer 调用作为服务器的 CSRSS 进程。
在寻找旧漏洞利用中的示例时,我在 Packet Storm 上找到了一个关于 相关堆缓冲区溢出问题 的页面。
当使用正确的包调用 CSRSS 服务器时,它会被 BaseSrvSxsCreateActivationContextFromMessage 函数接收,该函数属于模块 sxssrv.dll。
该函数只有一个参数:指向接收到的包的指针。为了逆向它,我创建了一个自定义的 TotalMessage 结构。
TotalMessage 结构包的前 0x40 字节是 HEADER,之后是嵌入的 激活上下文消息,其结构为 _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG。
TotalMessage 结构如下所示:

这里是 _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG 结构:

在此结构内部有六个 UNICODE_STRINGS,分别对应语言或 CultureFallbacks、AssemblyDirectory、TextualAssemblyIdentity、AssemblyName,以及两个 _BASE_MSG_SXS_STREAM 结构,每个结构内部包含一个 UNICODE_STRING。
以下是 _BASE_MSG_SXS_STREAM 结构:

鉴于创建服务器接受的有效包的难度,值得详细说明如何做到。
_BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG 内部的 Flags 字段值非常重要,因为有很多组合。没有正确的标志值,就无法利用此漏洞。
例如,以我的 MsCtfMonitor.dll 代码为例。经过多次尝试,我得出结论,此漏洞利用的正确 flags 值是 0x41:

不同值的组合可能导致错误的路径标志值:

相同的 TotalMessage 结构将具有大小为 0x40 字节的头部。剩余的 0x1f8 字节保留给 _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG 结构:
struct TotalMessage
{
signed __int64 pad[8];
_BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG message;
};
分配大小为 0x40+0x1f8:

然后我组合了字符串,并为 tapi32.dll 执行了激活缓存上下文。这是一个非常少用的 DLL,由名为 TCMSETUP 的进程加载。它具有 HIGH PRIVILEGES INTEGRITY LEVEL(RID=0x3000),权限与 Administrator 相同。

在我的 DLL 代码中,调用了 CaptureUnicodestring 函数。这最终会调用 CsrCaptureMessageString:
NTSTATUS CaptureUnicodeString(LPVOID CaptureBuffer, PSTR OutputString,
PCWSTR String, ULONG Length = 0) {
if (Length == 0) {
Length = lstrlenW(String);
}
return CsrCaptureMessageString(CaptureBuffer, (PCSTR)String, Length * 2,
Length * 2 + 2, OutputString);
}
这一步是正确准备包所必需的,允许系统将我的包中的字符串复制到 CSRSS 进程。这使字符串保持有效,并将我的指针替换为在其上下文中有效的指针。
我还添加了一个 嵌入式 XML 清单,其中包含 "Tasks" 语言。这是一种不存在的语言,但它将成为利用的关键(感谢 Nico 提供这个):

我的代码中的另一个重要细节是 CaptureBuffer 创建时。函数 CsrAllocateCaptureBuffer 有一个参数,定义它应该管理并复制到服务器的 UNICODE_STRINGS 数量。
在我的情况下,我使用了 "4" 个字符串:

值为 "4" 的参数如下所示:

要到达激活服务器,CsrClientCallServer 函数将从我的 MsCtfMonitor.dll 发送我的包,使用与上述旧漏洞利用相同的 ApiNumber 0x1001001E。
Geoff Chappell 的博客 提供了更多关于 CsrClientCallServer 的详细信息:

以下是调用 CsrClientCallServer 的代码:

以下是我 DLL 中构建的要发送的包:

Manifest.Offset 值指向我的 嵌入式 XML 清单:

一个用于记录激活过程的有趣命令是 sxstrace,在目标的管理员控制台中使用。
此命令启用跟踪并将日志结果保存到 sxstrace.etl。(按 ENTER 结束跟踪。)
sxstrace trace -logfile:sxstrace.etl
原始 sxstrace.etl 文件随后可以转换为可读格式:
sxstrace parse -logfile:sxstrace.etl -outfile:sxstrace.txt
如果包正确,它应该到达 csrss 进程 sxssrv 模块中的 BaseSrvSxsCreateActivationContextFromMessage 函数。因此,在调试远程内核时,需要将上下文切换到该进程。然后,需要重新加载用户模式符号以设置断点:

我使用 IDA PRO 配合 Windbg 插件进行内核调试:

一旦在 BaseSrvSxsCreateActivationContextFromMessage 处停止,RCX 将指向 TotalMessage 结构:

在初始的 0x40 字节 HEADER(系统用一些值填充,如客户端进程的 PID 等)之后,可以看到我的属于 _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG 结构的激活消息:

请注意,指向字符串的指针与发送时的值不同:

但它们正确地指向了字符串:

当包从客户端发送到服务器时,系统将字符串从我的进程复制到 CSRSS 进程,并将我包中的指针更改为其上下文中有效的指针。
之后,函数 BaseSrvSxsCreateActivationContextFromMessage 检查字符串是否有效。

在一个循环中,它检查六个字符串,但完美通过了检查。在我的情况下,我只传递了四个字符串,另外两个为零。
在进行了其他次要检查后,它调用了 BaseSrvSxsCreateActivationContextFromStructEx,这是激活过程中最重要的函数:

一旦进入 BaseSrvSxsCreateActivationContextFromStructEx,r8 将指向 _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG,即激活消息:

它评估 flags 的值。在我的情况下,值是 0x41 对 0xD:

可以使用对应于验证处理器架构 (1) 的标志选项绕过 test 函数。

之后,它获取调用进程的 RID 并存储以供进一步比较。在这种情况下,RID 是 0x3000,因为 CTFMON 具有 HIGH INTEGRITY LEVEL。

此函数最重要的部分是调用 BaseSrvActivationContextCacheLookupEntry:

它搜索 激活上下文缓存 以确定是否存在任何用于 tapi32.dll 的条目。
它调用名为 BaseSrvActivationContextCacheCompareEntries 的函数,该函数将 激活消息 条目的某些部分与缓存中所有现有条目进行比较:

它比较我包中发送的 LastWriteTime 值与所有条目中的相同值。
我之前使用 GetFileTime 在 tapi32.dll 上计算了这个值,并将其发送到我的激活包中:

由于没有 tapi32.dll 的条目,比较将不匹配。如预期,它返回错误 0xC0000225。之后,它将检查我的 ACTX 是否适合添加到缓存中:

服务器需要读取我的 XML 嵌入式清单,以及指向它的 Manifest.Offset 地址。然而,在这个新上下文中,它还不是一个有效的指针。值得在此值上设置断点,以观察我的 XML 嵌入式清单 何时以及如何被读取。
为了验证 CSRSS 何时读取我在 ACTX 请求中发送的 嵌入式 XML 清单,应在 Manifest.Offset 处设置断点。此外,每次停止时,如果它复制到另一个地址,还应添加断点。

它在读取 Manifest.Offset 值地址时在断点处停止。
它将使用此地址从 CTFMON 进程读取我的 嵌入式 XML 清单,使用 NtReadVirtualMemory,因为 Manifest.Offset 字段中放置的地址属于该上下文:

我的 嵌入式 XML 清单 被读取并复制到目标缓冲区:

切换到 CTFMON 进程上下文,并验证我的 嵌入式 XML 清单 在我之前发送的 Manifest.Offset 地址中。在我的情况下,它是 0x7ff93a261470。

读取的 嵌入式 XML 清单 是从 SxSGenerateActivationContext 调用的。由于在缓存中未找到任何有效条目,它会尝试使用嵌入式清单“生成”它:

从那里开始,它解析我的 嵌入式 XML 清单。
查看最后一个调用堆栈,我决定在调用 RtlReadOutOfProcessMemoryStream 处设置一个断点,以便在缓冲区完全填充时停止。

现在可以在字符串 "Tasks" 的访问上设置一个断点,以便在服务器读取或处理它时停止。

这是嵌入式 XML 清单中的 tasks 字符串:

它多次停止,读取和复制:

它在 CharEncoder::wideCharFromUtf8 处停止,当它将字符串 "tasks" 转换为宽字符时:

然后它在 XML 解析器 处停止:

它继续解析 XML 属性,正如函数名 parseAttributes 所示。

然后,它在 memcpy 处停止,从 ValidateElementAttributes 调用:

可以在复制处设置另一个断点:

它验证语言属性,正如函数名 SxspValidateLanguageAttribute 所示:

它再次在 memcpy 处停止,但从 SxspCreateAssemblyIdentityfromIdentityElement 调用:

再次,它在 memcpy 处停止,这次从 SxsInsertAssemblyIdentityAttribute+0xc48 调用:
然后它在 SxsInsertAssemblyIdentityAttribute 中停止:

它最后一次调用 memcpy,这次来自 BufferedStream::prepairForInput:

接着它在这里读取字符串 tasks:

然后,它从这里读取:


它继续从这里读取:


这些函数的名字引起了我的注意。在名字 ProbingCandidate 中,它包含了 SXS 文本日志文件中使用的相同词汇(probing manifests)。

它再次停在这里:

接下来,它使用 GetFileAttributesExW 检查 SXS 文本日志中提到的第一个文件是否存在。由于该文件不存在,它返回零。

文件中检查的顺序可以在日志文件中看到:

第二个文件不存在,因为它是 tasks 文件夹中 tapi32.dll 的路径:

从那里开始,它似乎是在 “probing” tasks 中的 tapi32.manifest:

然后它到达 CProbedAssemblyInformation::ProbeManifestExistence:


它检查我的清单文件是否存在于 tasks 文件夹中。由于它确实存在,它返回无错误:

好了,“tasks” 文件夹中的 tapi32.manifest 被找到了。
服务器被迫在我的 嵌入式 XML 清单(其中包含 “tasks” 语言值)的驱动下,在 system32 的 “tasks” 子文件夹中搜索清单文件:


如果继续设置断点以查看它在哪里使用该路径,它会在 EncodingStream::Read 处停止,其中读取了 tapi32.manifest 文件的内容。

接下来,它将解析 TAPI32.manifest 文件的内容。如果有错误,它会在 SXS TRACE 日志中显示,这使得更容易纠正。

如果我的 TAPI32.manifest 文件被正确解析,它将无错误地返回到 BaseSrvSxsCreateActivationContextFromStructEx。这避免打印包含字符串 FAILED 的消息。
在我的情况下,激活上下文生成成功,使用了我的 TAPI32.manifest 文件。


然后我到达了将我的条目插入缓存的调用。
它顺利通过,返回零。这是正确的值,包含精心构造的 TAPI32.manifest 的条目被成功插入。

我的条目被包含在激活缓存中,服务器对来自 CTFMON 的 DLL 调用返回了 OK 响应。
日志文本文件显示了完整的过程。
它读取 嵌入式 XML 清单。由于它的语言是 “Tasks”,它会在 system32 的 “Tasks” 子文件夹中搜索一个新的清单文件,就像如果语言设置为 “en-us” 时,它会在 system32 的名为 “en-us” 的子文件夹中搜索清单一样。

SXS 日志文本文件显示消息 “Activation Context generation succeeded”!

在我的 ACTX 条目被添加到缓存后,如果运行 tcmsetup.exe,它将加载 tapi32.dll,并且应该使用我的清单文件来加载 imm32.dll。
然而,事情并不那么简单,因为它无法加载 imm32.dll,因为有一些检查可能会阻止其加载。
这些检查是在后续对同一个 BaseSrvSxsCreateActivationContextFromStructEx 函数的调用中进行的,因此移除所有断点,只保留一个。
从那里我们可以从控制台运行 TCMSETUP.EXE,尽管我的 PoC 在激活缓存投毒完成后从 MsCtfMonitor.dll 执行 TCMSETUP:

它在断点处多次停止。每次停止时,查看由 r8 指向的结构,以确定它是否对应于与 tapi32.dll 相关的请求。

在多次因其他模块停止后,出现了 TCMSETUP.exe 的请求:

我们在调用堆栈中看到,它来自进程创建的时刻。它被调用以检查 TCMSETUP 在激活缓存中是否有任何条目。
让它继续运行,直到调用到达 TAPI32.dll。在此之前,会有几次针对 TCMSETUP 的调用。

最后,到达的数据包必须与之前从我的 DLL 插入缓存条目时发出的数据包 非常 相似。然而,现在它会在 TCMSETUP 尝试加载 TAPI32.dll 时停止。

此时我注意到这个包中有一些重要的值。

从 _BASE_SXS_CREATE_ACTIVATION_CONTEXT_MSG 结构开头向上延伸 0x40 字节,分配 TotalMessage 结构。发出 TAPI32.dll 请求的进程的 PID 是 TCMSETUP,因为它想要加载这个 DLL。

将上下文切换到 TCMSETUP 进程,可以看到 Manifest.Offset 值指向某个 嵌入式 XML 清单。


在 NOTEPAD 中打开 tapi32.dll,可以看到接收到的 XML 嵌入式清单与文件中包含的清单相同。
TCMSETUP 之前读取了文件资源以获取清单,并将其放入数据包中作为 嵌入式 XML 清单。
之后,再次由 BaseSrvActivationContextCacheCompareEntries 函数进行比较,该函数从 BaseSrvSxsCreateActivationContextFromStructEx 调用。现在,我的 tapi32.dll 条目也存在于缓存中。

BaseSrvActivationContextCacheCompareEntries 在一个循环中被调用,将实际请求与 激活上下文缓存 中的每个条目(如我的条目)进行比较。
首先,它比较两个 LastWriteTime 值,由于它们相等,它继续比较更多值。
这个 LastWriteTime 值至关重要。如果值不同,它将丢弃我的缓存条目,我的 imm32.dll 将不会被加载。
它继续前进,停在下一个检查处。

现在,它检查 ResourceName 值,两个都必须为 0x7c。

然后它比较实际 ACTX 数据包的语言("en-us")与我的缓存条目的语言。我的缓存条目的语言也是 "en-us"。

我的包具有相同的语言值:

然后,它比较处理器架构,这一次两者都是 9:


然后,它比较两个 Manifest.path。

我通过使用系统目录值构建了相同的路径,没有硬编码:

然后它比较 AssemblyDirectory,这也是相同的:


如果所有比较都正确,它返回零。这意味着它在 激活缓存 中找到了我的条目,并且它将被使用。
请记住,当我第一次发送请求添加条目时,比较返回了一个错误,因为缓存中没有 TAPI32.dll 的条目。由于我的条目之前已添加,现在它返回零。
之后,它比较 TCMSETUP 和 CTFMON 的 RID,由于两者都有 RID = 0x3000,进程继续。
关于 RID 补丁的完整解释可在 Zero Day Initiative 的一篇博客 中找到。

这是此补丁的代码:

R15 包含调用者 TCMSETUP = 0x3000 的 RID,而 buffer 存储了 CTFMON 进程的 RID=0x3000。
如前所述,微软在 2022 年 10 月添加了这个 RID 检查 补丁。
在该补丁实现之后,如果您想尝试使用来自 MEDIUM INTEGRITY LEVEL PROCESS (0x2000) 的同一个 MsCtfMonitor.dll 将 tapi32.dll 条目添加到缓存中,条目会被添加到缓存,但会失败。这是因为调用进程的 RID 0x2000 被存储,当您尝试执行 TCMSETUP(RID=0x3000)以加载 imm32 时,会比较 RID 并移除条目。
在那个假设的情况下,R15 将具有请求加载 tapi32.dll 的 TCMSETUP 进程的 RID=0x3000,而 “buffer” 变量将存储将条目添加到缓存(具有 MEDIUM INTEGRITY LEVEL)的进程的 RID=0x2000。

在 Windows 的最新版本上,如果请求添加条目的进程的级别低于执行者,则缓存投毒将不起作用,条目将被移除。在此补丁之前的旧版本将正常工作,无论 RID 如何。

回到这个案例,RID 检查通过了,两个进程具有相同的 RID=0x3000。因此,条目未被删除,它继续运行且没有错误。
服务器向 TCMSETUP 返回响应。当它加载 tapi32.dll 时,它将使用我的包含 tapi32.manifest 文件的条目,该文件将从 tasks 文件夹加载 imm32.dll。
这是从 LoadLibrary 到 TCMSETUP 向激活缓存发出请求的完整路径
当加载 tapi32.dll 时。

BasepCreateActCtx 是向 CSRSS 服务器发出请求的那个。需要尝试查看它最终何时加载 IMM32.dll 模块。
查看 kernel32.dll,它调用 CsrBasepCreateActCtxCommon。内部有一个与从我的 DLL 插入缓存条目时类似的服务器调用。

它使用与我相同的 ApiNumber。
当执行 TCMSETUP 时,可以在那里设置断点,以便在服务器返回后,我的 tapi32.manifest 文件被接受时触发。

这是直到在 CsrBasepCreateActCtxCommon 中调用服务器之前的整个调用堆栈。

在调用堆栈的一些函数的返回处设置断点。

当它停止时,可以观察到 imm32.dll 已从 "tasks" 文件夹加载:

可以使用 PROCESS MONITOR 进行验证,TCMSETUP 从 "tasks" 文件夹加载 IMM32.dll。

刚刚执行的 CMD 进程具有 HIGH 特权。

此外,它具有与 Administrator 相同的特权。
有了这些特权,我们现在可以安装任何需要提升到管理员权限的程序,并写入任何文件夹。例如,写入 SYSTEM32 或任何程序安装文件夹,如下面的 VIDEO DEMO 中所示。
以下是在利用之前的特权(完整性级别 Medium,非 Administrator):

现在这是在利用之后的特权(完整性级别 High,FULL Administrator):

此时,这是一个轻松提升到 SYSTEM 的好机会,将一些精心构造的 DLL 放入系统文件夹。


我向 CSRSS 服务器发送了一个精心构造的 ACTX 消息。
这个 ACTX 消息包含一个 嵌入式 XML 清单,并有一个 偏移量 指向它。
当服务器收到它时,它使用该偏移量从 CTFMON 进程上下文中读取 嵌入式 XML 清单。
嵌入式 XML 清单 被解析。如果被接受,它将尝试从外部文件夹加载第二个外部清单。
要读取的文件夹取决于 嵌入式 XML 清单 中的语言字段(由我控制)。
在我的案例中,嵌入式 XML 清单 将 "tasks" 作为其语言。因此,它在 system32 的 "tasks" 子目录中搜索外部清单,并找到了它。
它解析了我创建的 tapi32.manifest 文件,并接受了它,从而允许从同一个 “tasks” 文件夹加载外部 IMM32.dll。
感谢 Nicolas Economou,他的演示是我研究和发布这篇博客的起点。
Ricardo Narvaja