近乎零日漏洞:Microsoft 内核流媒体服务漏洞利用分析。

一位留着胡须的男士在办公室加班赶截止日期,两名同事坐在他身后

Microsoft 于上月修复了 Microsoft Kernel Streaming Server 中的一处漏洞,该组件是 Windows 内核的核心模块,主要用于摄像头设备的虚拟化与共享功能。该漏洞 (CVE-2023-36802) 允许本地攻击者将权限提升至 SYSTEM 级别。

本文详细记录了我在 Windows 内核中探索新攻击面、发现零日漏洞、研究一类有趣的漏洞类型并构建稳定漏洞利用工具的全过程。本文无需读者具备任何专业的 Windows 内核知识,但具备内存损坏及操作系统相关基础概念会有助于理解。此外,我还将介绍对未知内核驱动进行初步分析的基础知识,并简化针对新目标的调研流程。。

攻击面

Microsoft Kernel Streaming Server (mskssrv.sys) 是 Windows 多媒体框架服务 Frame Server 的核心组件。该服务对摄像头设备进行虚拟化处理,支持该设备在多个应用程序之间共享使用。

在关注到 CVE-2023-29360 漏洞后,我开始对这一攻击面展开探索。该漏洞最初被归类为 TPM 驱动漏洞,但实际上其缺陷存在于 Microsoft Kernel Streaming Server 中。尽管当时我对 MS KS Server 并不熟悉,但该驱动程序的名称已足以引起我的关注。即便完全不了解其用途与功能,我仍认为内核中的流媒体服务器有望成为漏洞挖掘的富矿。在毫无前期认知的情况下,我着手围绕以下问题展开探索:

  • 非特权应用程序可以以何种方式与该内核态模块交互?
  • 模块会直接处理来自应用程序的哪些类型的数据?

为解答第一个问题,我首先在反汇编工具中对该二进制文件展开分析。我很快便定位到了前文提及的漏洞,这是一处简洁典型的逻辑漏洞。该漏洞的触发与完整利用路径看似清晰,因此我着手开发了一个快速概念验证 ,以便更深入地理解 mskssrv.sys 驱动程序的内部工作机制。

初步分析

在 MS KS Server 内部触发执行

首先,我们需要从用户态应用程序触达该驱动程序。存在漏洞的函数可通过驱动程序的 DispatchDeviceControl 访问,这意味着通过向驱动程序发送 IOCTL 即可调用该函数。要实现这一点,需先通过设备路径调用 CreateFile,获取该驱动程序的句柄。通常,设备名称/路径的查找流程较为简单:在驱动程序中找到 IoCreateDevice 的调用处,其第三个参数中便包含了设备名称。

mskssrv.sys 中的函数调用 IoCreateDevice 时,设备名称的指针为 NULL。

mskssrv.sys 中的函数调用 IoCreateDevice 时,设备名称的指针为 NULL。

在这种情况下,设备名称的参数是 NULL。从调用函数名称可推断,mskssrv 是一款即插即用驱动程序;而 IoAttachDeviceToDeviceStack 函数的调用则表明,所创建的设备对象属于某个设备栈的一部分。这意味着,当向设备发送 I/O 请求时,会触发多个驱动程序的调用。对于即插即用设备,需通过设备接口路径才能访问该设备。

借助 WinDbg 内核调试器,我们可以查看哪些设备归属于 mskssrv 驱动程序,以及对应的设备栈结构:

!drvobj 和 !devobj 命令的输出结果,展示上层设备与下层设备

!drvobj 和 !devobj 命令的输出结果,展示上层设备与下层设备

从上述输出可知,mskssrv 的设备对象已附加至 swenum.sys 驱动程序所属的下层设备对象,且有一个 ksthunk.sys 驱动所属的上层设备对象附加于其上。

在设备管理器中,我们可以找到目标设备实例 ID

显示设备实例 ID 和接口 GUID 的设备管理器

显示设备实例 ID 和接口 GUID 的设备管理器

现在,我们有足够的信息可以使用配置管理器或 setupAPI 函数,以获取设备接口路径。使用检索到的设备接口路径,我们可以打开设备的句柄。

最终,我们成功触发了 mskssrv.sys 驱动程序内部的代码执行。当设备对象被创建时,会调用该驱动程序的即插即用调度创建函数。若要触发更多代码执行,我们可以向该设备发送 IOCTL 进行交互,这些请求将在驱动程序的设备控制调度函数中执行。

调试 Ghost 驱动程序

进行二进制分析时,最佳实践是结合静态分析工具(反汇编器、反编译器)与动态分析工具(调试器)协同使用。WinDbg 可用于内核态调试目标驱动程序。我们可在预期会触发代码执行的位置(调度创建函数、设备控制调度函数)设置若干断点。

起初我遇到了一些困难——在该驱动程序中设置的所有断点均未触发。我开始怀疑自己是否打开了正确的设备对象,或是在其他操作环节出现了失误。后来我才意识到,断点之所以失效,是因为驱动程序被系统卸载了。我曾尝试通过网络搜索解决方案,但尽管 mskssrv.sys 是 Windows 系统默认加载且可访问的驱动,关于它的相关搜索结果却寥寥无几。在为数不多的结果中,我发现了 OSR 论坛上的一个讨论帖,其中有用户遇到了与我类似的问题。

论坛评论

事实证明,即插即用筛选器驱动程序若一段时间未被使用,将会被系统卸载,待需要时再按需重新加载。

我通过如下方式解决了该问题:在打开设备句柄之后、调用 DeviceIoControl 之前设置断点,以此确保驱动程序处于刚被加载的状态。

驱动程序功能快览

mskssrv 驱动程序只是一个 72 KB 的二进制文件,支持可调用以下函数的设备 IO 控制代码:

  • FSRendezvousServer::InitializeContext
  • FSRendezvousServer::InitializeStream
  • FSRendezvousServer::RegisterContext
  • FSRendezvousServer::RegisterStream
  • FSRendezvousServer::DrainTx
  • FSRendezvousServer::NotifyContext
  • FSRendezvousServer::PublishTx
  • FSRendezvousServer::PublishRx
  • FSRendezvousServer::ConsumeTx
  • FSRendezvousServer::ConsumeRx

通过这些符号名称,我们可以推断出该驱动程序的部分功能,与流的传输和接收相关。至此,我进一步深入研究了该驱动程序的设计用途。我发现了 Michael Maltsev 关于 Windows 多媒体框架的一份演示文稿,从中了解到该驱动是进程间摄像头流共享机制的组成部分。

由于该驱动程序体积不大,且支持的设 IOCTL 数量较少,我得以逐一分析每个函数,进而掌握该驱动的内部工作机制。每个 IOCTL 函数均操作于上下文注册对象或流注册对象,这两类对象通过对应的 “初始化(Initialize)”IOCTL 分配并初始化。对象指针存储在 Irp->CurrentStackLocation->FileObject->FsContext2 中:FileObject 指向每个打开文件所创建的设备文件对象,而 FsContext2 是一个用于存储每个文件对象元数据的字段。

漏洞

在尝试直接与该驱动程序建立通信时,我发现了这个漏洞,起初我并未分析用户态组件 fsclient.dll 和 frameserver.dll,而是优先聚焦于驱动本身的交互逻辑。我差点就漏掉了这个漏洞,因为我默认开发者会添加一个简单的校验,只是(在实际编码中)忽略了这一步。下面我们来看一下 PublishRx IOCTL 对应的处理函数:

FSRendezvousServer::PublishRx 反编译片段

FSRendezvousServer::PublishRx 反编译片段

FsContext2 中检索到流对象后,函数会调用 FSRendezvousServer::FindObject,以验证该指针是否与全局 FSRendezvousServer 存储的两个列表中的某个对象匹配。起初我以为该函数会通过某种方式校验请求的对象类型,但实际上,只要指针在上下文对象列表或流对象列表中的任意一个中被找到,函数就会返回 TRUE需要注意是,FindObject 并未接收任何关于“对象应属类型”的信息——这意味着上下文对象可以被当作流对象传递。这是一个对象类型混淆漏洞!该漏洞存在于所有操作流对象的 IOCTL 函数中。为修复此漏洞,Microsoft 将 FSRendezvousServer::FindObject 替换为 FSRendezvousServer::FindStreamObject,后者会先通过检查类型字段来验证对象是否为流对象。

开发

原语

由于上下文注册对象(0x78 字节)小于流注册对象(0x1D8 字节),当对上下文注册对象执行流对象相关操作时,会导致越界内存访问。

对象类型混淆漏洞示意图

对象类型混淆漏洞示意图

内存池喷射

为了利用该漏洞原语,我们需要能够控制被越界访问的内存区域。这一目标可通过在漏洞对象所在的同一内存区域中触发大量对象分配来实现,该技术被称为堆喷射或内存池喷射。漏洞对象分配于非分页低碎片堆内存池中。我们可采用经典技术喷射填充缓冲区(Alex Ionescu 提出),这些缓冲区能完全控制 0x30 字节的 DATA_QUEUE_ENTRY 头部之后的内存内容。通过该喷射技术,我们可得到如下图所示的内存布局:

非分页内存池喷射

通过所选的内存池喷射方法,可控制对象偏移量在 0xC0-0x10F 0x150-0x19F 范围内的字段再次回顾了流对象对应的 IOCTL 处理函数,以寻找可利用的漏洞原语,并搜索这些可控对象字段被访问和操作的位置。

常量写入

我在 PublishRx IOCTL 中找到了一个优质的“任意地址常量写”漏洞原语。该原语可用于在任意内存地址写入一个固定值。下面我们来看 FSStreamReg::PublishRx 函数的代码片段:

FSStreamReg::PublishRx 反编译片段

FSStreamReg::PublishRx 反编译片段

该流对象在偏移量 0x188 处包含一个链表头,用于描述一组 FSFrameMdl 对象的链表结构。在上述反编译代码片段中,程序会遍历该链表:若 FSFrameMdl 对象中的标签值与应用层传入的系统缓冲区中的标签相匹配,则调用 FSFrameMdl::UnmapPages 函数。

借助前文提到的漏洞原语,我们可完全控制 FSFrameMdlList 链表,进而完全控制 pFrameMdl 指针所指向的 FSFrameMdl 对象。现在我们来看看 UnmapPages:

FSFrameMdl:UnmapPages 反编译

FSFrameMdl:UnmapPages 反编译

在上述反编译函数的最后一行,程序会将常量值 2 写入到 this 指针(即 FSFrameMdl 对象)的一个可控偏移位置。该常量写漏洞原语可与 I/O Ring 技术结合使用,实现任意内核读写与权限提升。关于该技术的工作原理,可参考此处此处的详细说明。

尽管我选择利用“常量写入”漏洞原语,但该函数中还存在另一个实用的漏洞原语。两个参数 BaseAddressMemoryDescriptorList(用于MmUnmapLockedPages 函数调用)均处于可控状态。借助这一条件,攻击者可取消任意虚拟地址的内存映射,并构造类似“释放后使用”的漏洞原语。

计费问题

至此,我们已识别出多个可实现任意内核读写的合适漏洞原语。你可能已经注意到,要触发目标代码路径,需要通过对流对象内容的多项校验。多数情况下,可通过内存池喷射使对象达到预期状态,但我在此过程中遇到了一个颇具挑战性的问题。以下是 FSStreamReg::PublishRx 函数遍历完 FSFrameMdlList 链表后的代码片段:

FSStreamReg::PublishRx 反编译片段

FSStreamReg::PublishRx 反编译片段

在上述反编译代码中,bPagesUnmapped 是一个布尔变量,若调用了 FSFrameMdl::UnmapPages 函数,该变量会被置位如果该变量为真(即已调用 UnmapPages 函数),则会读取流对象偏移量 0x1a8 处的数据;若该数据不为空,则会对其调用 KeSetEvent 函数。

该偏移量对应越界内存区域,且指向 POOL_HEADER(内存池中用于分隔缓冲区分配的数据流结构)内部。具体来说,它指向 ProcessBilled 字段,该字段用于存储一个指向 _EPROCESS 对象的指针,对应“承担”此次内存分配“计费”的进程。这一机制用于统计特定进程可拥有的内存池分配数量。并非所有内存池分配都会向进程“计费”:对于不计费的分配,ProcessBilled 字段(位于 POOL_HEADER 中)会被设为 NULL。此外,ProcessBilled 中存储的 _EPROCESS 指针实际上会与一个随机 Cookie 进行异或加密,因此 ProcessBilled 字段中并不存在有效的指针。

这带来了一个难题:由于 NpFr 缓冲区会向调用进程“计费”,因此其 ProcessBilled 字段会被赋值。而当触发所需的漏洞原语时,bPagesUnmapped 会被设为 TRUE。若向 KeSetEvent 传递无效指针,系统将会崩溃。因此,必须确保目标 POOL_HEADER 对应的是“不计费”的内存分配。此时我发现,上下文注册对象 (Creg) 本身不会被“计费”,但该对象无法让我们控制 FSFrameMdl 对应偏移量的内存内容。所以,我们需要同时喷射 NpFr 对象 Creg 对象,且必须保证它们的分配顺序正确。

内存池泄漏,拒绝“盲目喷射”!

与大内存池分配不同,无法通过 NtQuerySystemInformation 函数泄漏 LFH 内存池分配的地址,且其分配顺序是随机的。因此,我们无法判断漏洞对象的相邻缓冲区是否处于“既能触发漏洞原语,又能避免系统崩溃”的正确顺序。幸运的是,该漏洞可被用于触发相邻缓冲区的内存池泄漏。下面我们来看一下 ConsumeTx 对应的 IOCTL 处理函数:

FSRendezvousServer::ConsumeTx 反编译片段

FSRendezvousServer::ConsumeTx 反编译片段

上图调用了 FSStreamReg::GetStats 函数:

FSStreamReg::GetStats 反编译

FSStreamReg::GetStats 反编译

在此处,漏洞流对象的越界内存内容会被复制到 SystemBuffer 中,并返回给调用方用户态应用程序。这种内存池信息泄漏原语可用于对漏洞对象的相邻缓冲区执行特征校验。通过扫描大量漏洞对象,直至定位到符合目标内存布局的对象。一旦找到目标对象,其内存布局如下:

CVE-2023-36802 低碎片内存池池整理布局

CVE-2023-36802 低碎片内存池池整理布局

如今,既然已将目标漏洞对象定位到内存中的正确位置,便可在不导致系统崩溃的前提下,触发前文所述的针对该目标对象的漏洞利用原语。

野外利用

向 MSRC 报告该问题后,发现该漏洞已存在野外利用情况

本文中介绍的利用方法仅是众多可能方案中的一部分。目前,关于野外攻击者如何利用该漏洞,尚无公开信息。漏洞利用代码可通过此链接获取。

总结

回溯补丁分析显示,Windows 10 1809 版本的 mskssrv.sys 驱动中新增了大量代码。对新增代码的监控往往是发现漏洞的有效途径。

本次分析还揭示了另一个老生常谈但极具价值的经典经验:切勿想当然地认定相关校验已落实到位。一位好友兼同事提出,利用 FsContext2 实现的类型混淆可能是一类“常见但研究不足的漏洞类型”。我认为有必要对此类漏洞开展更多变体分析,尤其针对处理进程间通信的驱动程序。

该漏洞的发现,源于一次对未知攻击面的尝试性交互。对某个系统“几乎一无所知”,有时反而意味着拥有突破它的全新视角。。

Mixture of Experts | 12 月 12 日,第 85 集

解码 AI:每周新闻摘要

加入我们世界级的专家小组——工程师、研究人员、产品负责人等将为您甄别 AI 领域的真知灼见,带来最新的 AI 资讯与深度解析。