综述
该漏洞最早被微软威胁情报中心发现在野利用,并于 2025 年 4 月的补丁日修复,奇安信威胁情报中心于 2025/05/30 日捕获到该在野样本被上传至 vt,样本 md5 如下:
881a60702d4876db65098176a3ec7e3a

当时只有 23 个引擎查杀。

运行之后弹出对应的 system shell。

漏洞成因
一开始我们并不确定该漏洞样本的 CVE 编号,经过一番测试后确定该漏洞在 2025 年 4 月的补丁中被修复,而四月修复的漏洞中正好有一个 clfs 相关的在野利用 CVE-2025-29824,补丁对比结果如下所示,结合样本中大量 closehandle 函数的调用,以及 CVE-2025-29824 UAF 的漏洞类型,CClfsRequest::Close 及 CClfsRequest::Cleanup 这两个函数进入了我们的视野。

CClfsRequest::Close 中修改的代码如下,可以看到补丁代码对 Irp->Tail.Overlay.CurrentStackLocation->FileObject->FsContext2 处引用进行检测,并在之后通过 CClfsLogCcb::Release 将其释放。

CClfsRequest::Cleanup 的补丁对比代码如下,这里在调用 CClfsLogCcb::Release 前增加了 IsEnableDeviceUsage 这个函数的检测,以防止对应的释放,结合之前 CVE-2025-29824 漏洞的猜测,基本确认是与 FsContext2 对象有关的 UAF 漏洞,CClfsLogCcb::Release 的操作也从 CClfsRequest::Cleanup 移至 CClfsRequest::Close。

CClfsRequest::Cleanup 上级的 CClfsRequest::Cleanup 中,补丁之后,CClfsLogCcb::Release 将不会调用,因为一开始的 CClfsLogCcb::AddRef 增加了 FsContext2 的引用,在之后的 CClfsRequest::Cleanup 的补丁代码中限制了 CClfsLogCcb::Release 的调用,因此该 CClfsLogCcb::AddRef 将导致 CClfsRequest::Cleanup 中 CClfsLogCcb::Release 调用时,对象引用不为 0,因此此处的 CClfsLogCcb::Release 永远不会调用。

这里 CClfsLogCcb::Release 中判断引用减一为 0,且对应的对象不为空后通过 ExFreeToNPagedLookasideList 完成对 FsContext2 的释放。

ExFreeToNPagedLookasideList 中注意其并不是每次调用都会释放指定的 Entry,而是需要当数量满足 L.Depth 时才会释放。

那如何触发 CClfsRequest::Cleanup/CClfsRequest::Close 的调用了?当我们通过应用层调用 closehandle 关闭一个 clfs 的文件对象时会依次发送 IRP_MJ_CLEANUP/IRP_MJ_CLOSE 到内核中的 CClfsRequest::Cleanup/CClfsRequest::Close,如下所示:

IRP_MJ_CLEANUP 用于清理文件, IRP_MJ_CLOSE 则用于关闭文件,但是二者存在先后关系,IRP_MJ_CLEANUP 是当与目标设备对象管理的文件对象最后一个用户模式句柄关闭的时候发送,但是此时由于可能还存在未完成的 I/O,因此该文件对象可能尚未被释放,而 IRP_MJ_CLOSE 则是该文件对象最有一个引用被释放,且 I/O 请求都已完成时发送,由此可见二者见存在一个时间差,这也如下图中一次 closehandle 调用后 CClfsRequest::Cleanup/CClfsRequest::Close 二者触发的顺序吻合。

而此处的漏洞则是因为 CClfsRequest::Cleanup 中提前调用 CClfsLogCcb::Release 释放掉了对应的文件对象的附加状态 FsContext2 ,而此时该文件对象却并没有在内核中被释放,攻击者可以通过该文件对象以某些方式再次触发对附加状态 FsContext2 的使用,如果攻击者能在 ClfsLogCcb::Release 释放掉了对应的文件对象的附加状态 FsContext2 时重用这段内存,则再次调用涉及对附加状态 FsContext2 使用的函数时将可能进入攻击者控制的流程,从而导致 UAF 漏洞的权限提升,而如何触发该附加状态 FsContext2 的重用,其实在 clfs 中有很多类似的函数,之前 startlab 的文章中也列举了一些,如
- CClfsRequest::ReserveAndAppendLog()
- CClfsRequest::WriteRestart()
- CClfsRequest::ReadArchiveMetadata()

漏洞利用
了解了对应的漏洞成因,我们来看实际攻击中的漏洞利用样本是如何实现的提权,样本一开始检测 SkyPDF 工作目录中是否有之前运行产生的 blf 文件,检测目标系统的版本,以确定是否可以触发该漏洞利用。

之后就是两个关键的线程,可以看到这两个线程调用结束后就是遍历寻找 Winlogon 进程的过程,因此这里可以确认该漏洞利用的核心就在这两个线程的回调函数中,两个回调函数都传入了同一个参数 Parameter,该参数是一个巨大的数组,包含了多个两个线程之间用于同步及交换时涉及的数据 。

之后通过 DuplicateHandle 目标 Winlogon 进程的句柄,使用 Winlogon 进程的句柄创建了一个远线程以加载后续的 payload 文件,从而实现 Winlogon 进程加载注入 Dll。

首先来看 threadProc1,该函数比较复杂,一开始通过一张本地的配置 config 进行了一系列的判断,之后配置表被保存到函数传入的参数 Parameter 中,依次调用 fun_getRtlSetAllBits/fun_CreatePipe,同样 Parameter 数组中的数据也作为参数传入到了两个函数中。

fun_getRtlSetAllBits 函数开始时分配两段内存 A,B,长度分别为 E0,5E0,并尝试写入一些数据,其中 A 填充 0x11,B 则比较复杂。

这里经过一番操作后的 B 数据如下所示,可以看到这里每个 section 构造如下,依次通过指针引用。

获取函数 NtQuerySystemInformation 地址。

通过 NtQuerySystemInformation 获取当前进程对应内核_EPROCESS 对象的 Privieges(_SEP_TOKEN_PRIVILEGES)位置,该核心位置会被写入到前面分配的内存中 section F 中,再次调用 VirtualAlloc 在 0x10000000 地址上分配 0x1000 长度的地址 C。

获取函数 RelSetAllBits 地址,该函数作为一个出色的写入工具函数,在多个 Windows 提权漏洞中被使用,RelSetAllBits 函数地址的偏移被写入到了前面分配的内存 0x10000000 中,之后该函数中前面分配的几段内存地址 A,B,C 同样被记录到了该线程函数传入的参数对象 Parameter 中。

此时的 Parameter 如下所示,地址 B 中 A,B,C,D,E section 依次引用,F 中构造了一个对 RtSetAllBits 的调用栈,传入的参数为 PreviousMode。

fun_CreatePipe 函数则比较简单,依次创建了两组 pipe 对,每组 0x400 个 readpipe/writepipe。四个记录了 0x400 个 readpipe/writepipe 的数组也分别被保存到了传入的参数对象 Parameter 中。

fun_getRtlSetAllBits/fun_CreatePipe 调用完毕后,Parameter 这个核心对象的内容大致如下所示:

之后多次调用 piperWrite2/pipeRead2 这对 pipe 进行反复写入,这里主要是为了在漏洞触发前做好内存布局,以便于之后 UAF 的内存抢占。

通过 CreateFileW 创建 0x10 个 clfs log 文件对象。

反复调用 CloseHandle 关闭之前创建 clfs log 文件对象,从而触发 CClfsRequest::Cleanup 调用 CClfsLogCcb::Release 释放 Irp->Tail.Overlay.CurrentStackLocation->FileObject->FsContext2,通过 WriteFile 写入我们恶意构造的 fake CClfsManagedLogClientUser 数据,其对应了前面 Parameter 中的长度为 0xE0 的 section A,以重用这段被释放的 Irp->Tail.Overlay.CurrentStackLocation->FileObject->FsContext2。

需要注意的是这个位置一共调用了四次 CloseHandle 才进入到利用的循环中,本质上是因为前面提到过 ExFreeToNPagedLookasideList 这个函数需要满足一定的 L.Depth,对于利用中的 Fscontext2 这个深度值为 4,因此利用中才会在进入核心利用循环前至少保证 4 次 CloseHandle 的调用。

ExFreeToNPagedLookasideList 中用于维护 paged 内存管理依赖的数据结构为 PPAGED_LOOKASIDE_LIST,也就是前面 L.Depth 中的 L,其中的 Depth 前面已经提到,用于记录当前需释放 paged 内存的数量,当达到 L.Depth 时统一释放,其中的 size 则标记了对应 List 维护的 paged 内存块的大小。

ExFreeToNPagedLookasideList 与之对应的则是 ExAllocateFromPagedLookasideList 函数,用于分配一个对应的 paged 内存块。

因此这里可以看到漏洞触发时释放的 FsContext2 对应的数据结构 CClfsLogCcb,其大小 size 为 0x110,要实现对应的释放,需要 freelist 中 CClfsLogCcb 数量达到四。

一个 CClfsLogCcb 对象的具体内存如下,除了实际的 0x110 大小的 size 外,包含一个 0x10 的标记头。实际分配的内存大小为 0x120,因此这里要重用释放的 FsContext2(CClfsLogCcb)对象,我们需要实际占用的内存大小为 0x120。

通过前面的分析可知攻击代码中通过 pipe 写入 0xE0 大小的数据来实现这一目的,这一操作导致内核内存中实际分配的内存大小为 0xE0(实际 size)+0x40(头部 size) = 0x120,正好和释放的 FsContext2(CClfsLogCcb)对象大小一致。

接下来是 threadPro2,这个函数比较简单,在通过 WaitForSingObject 及 EnterSynchronizationBarrier 等到 Parameter 参数对象中的事件/同步屏障触发,且参数 Parameter 中的位置判断条件满足后,调用 DeviceIoControl 发送 0x80077028 ioctl 码。

0x80077028 在 clfs.sys 驱动中对应了函数 CClfsManagedLogClientUser::ReadNotification,注意此时该函数的第一个参数来自 FsContext2,正好是前面释放并重用的位置,其偏移+0x108 的位置被作为 ReadNotification 的第一个参数传入。

由于 CClfsManagedLogClientUser 为攻击者重用并构造的 fake CClfsManagedLogClientUser,因此在函数中以下位置 fake CClfsManagedLogClientUser 中构造引用的 RtlSetAllBits 将被触发调用。

直接在 RtlSetAllBits 函数下断点,可以看到对应的 RtlSetAllBits 在 CClfsManagedLogClientUser::ReadNotification 中被调用,以实现提权。

动态调试
详细来看看调试过程中的漏洞利用。
直接通过以下断点监控对应的 CClfsRequest::Close 时 FsContext2 的值:
bp CLFS!CClfsRequest::Close+0x5c ".if(poi(poi(rax+0x20)+0x108)){.printf\"current FsContext2 of irp is:%p\\n\",poi(poi(rax+0x20)+0x108);gc}.else{gc}"

可以看到整个利用过程中直到 Readnotification 调用,CLFS!CClfsRequest::Close+0x5c 中 irp 对应的 FsContext2 中指向的 CClfsManagedLogClientUser 对象的位置都是空的,并于之后被重用。

此时被重用的内存如下所示,ClfsMgmtDispatchIo 函数中,irp 为 fffffc782997fd790,被重用的 FsContext2 为 ffffc78293d88710 其指向了我们构造的 A section,A section 的长度为 0xE0,但是其实际内核内存需要加上 0x30 的额外结构,因此原本 A section 0xD8 中的偏移处的 B section 地址对于 A section 对象的偏移应该是 0x30+0xD8 = 0x108。

正好作为传入 CClfsManagedLogClientUser::ReadNotification 中的第一个参数,我们伪造的 fake CClfsManagedLogClientUser 对象,其对应 section B。

CClfsManagedLogClientUser::ReadNotification 调用对应的 CClfsManagedLogClientUser 已经被完全重用,被修改为攻击者在 fun_getRtlSetAllBits 中构造的 fake CClfsManagedLogClientUser。

ClfsManagedLogClientUser + 0xE0 的位置如下图所示:
在 CClfsManagedLogClientUser::ReadNotification 中依次调用,最终调用到 fake CClfsManagedLogClientUser 中恶意引用的 RtlSetAllBits 指针。
CClfsManagedLogClientUser::ReadNotification
CLFS\!CClfsMdlReference::AddRef
CLFS\!CClfsRequest::ReadMgmtNotificationInProgress
nt\!RtlSetAllBits

RtlSetAllBits 调用的参数 BitMapHeader 同样已经通过 fake CClfsManagedLogClientUser 中 F section 构造好,其指向当前进程的 token->priviliege。如下所示可以看到最终会被 memset 设置,从而导致当前进程被提权。

RtlSetAllBits 中的写入过程如下所示:

可以看到此时提权样本的进程已经被设置为 system 权限。


此时再来回顾之前的 Parameter 对象,我们补上对应的占用位置 0xE0 的 pipe 写入恶意内存在实际利用中的调用关系如下所示。

最终完整的利用示意图如下所示:

参考引用
[1] https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-29824
[2] https://starlabs.sg/blog/2025/07-my-blind-date-with-cve-2025-29824/