综述
该漏洞样本为前段时间奇安信威胁情报中心日常在野漏洞监控运营经发现,其最早被上传时只有6个查杀。
经过分析确认该漏洞应该是在八月的微软补丁中被修复,是一个被修复的未知 nday 利用,运行的具体效果如下所示。
漏洞样本分析
这里首先过一下整个样本,样本开始首先启动了一个 cmd,之后调用核心 fun_vulstar。
fun_vulstar 中判断当前的机器的相关版本。
之后动态获取部分系统 api 的函数地址。
开启一个新线程,调用漏洞利用函数 fun_expProc。
fun_expProc 调用 fun_IoRingandPipeinit。
该函数中判断目标系统的版本是否支持 I/O ring 的提权方式,如果支持,则完成相关的初始化工作,并返回 var_ioringRegBuffers/var_ioringRegBuffersCount,这种方式具体利用细节可以看以下文章(https://windows-internals.com/one-i-o-ring-to-rule-them-all-a-full-read-write-exploit-primitive-on-windows-11/),简单来说这是一种 Windows 11 22H2+ 后独有的利用原语,可以将 Windows 内核中的任意写入甚至任意增量错误转变为对内核内存的完全读/写,在 i/o ring 的利用中通过任意地址写入修改 _IORING_OBJECT 对象的以下两个字段(var_ioringRegBuffers/var_ioringRegBuffersCount),从而实现全局内存读写。
之后根据是否使用 I/O ring 提权来完成先相关的初始化工作。
以使用 I/O ring 提权方式举例,这种情况下会在0地址上 spray 0x2000 长度的 var_ioringRegBuffers-0x2c 地址。
Fun_init 中则用于在 0x1000000000 的地址上分配长度 0x10000 的内存,并获取 NtCreateWorkerFactory 返回的 WorkerFactory 对象的地址 var_KWorkerHandleaddr。
接着往下,进入一个大循环,其中 fun_NtAlpcConnectPort 用于调用 NtAlpcConnectPort 创建一个 Alpc 连接对象,连接对象创建完毕,开启两个线程分别调用函数 fun_NtRegisterThreadTerminatePort/fun_expWorker。
fun_NtAlpcConnectPort 的功能很简单就是调用 NtAlpcConnectPort,和系统的 pdc alpc port 服务连接,并返回对应的 alpc porthandle。
如下图,两个线程开启后,调用 fun_setEvilmessage 设置一段自构造的内存,之后通过 WaitForSingleObject 监控 fun_NtRegisterThreadTerminatePort 对应的线程1是否结束,如果结束,则进入图中红框的部分,这里的核心是函数 fun_NtCreateEvent。
fun_setEvilmessage 完成了一段内存的构造,其会根据一开始获取的系统版本,进入不同版本的内存构造。
最终的效果如下所示,构造的内存都是从66130这个位置开始,这里我们测试的系统版本构造的内存如下红框中所示,可以看到无论哪个版本,最后位置放置的都是前面获取到的var_KWorkerHandleaddr的地址加一个偏移。
可以看到 fun_setEvilmessage 调用完之后,再次初始化了一段 7FF7F21671B0 开始的内存,fun_setEvilmessage 中构造的 7FF7F2166130 被放置到7FF7F21671B0 +0x20 处的 7FF7F21671D0 位置。
7FF7F21671B0 最终的内存构造如下所示。
fun_NtCreateEvent 函数会根据第三个参数进入两个分支,如果非零,则进入以下分支,循环调用 NtQueryLicenseValue。
否则进入以下分支,可以看到主要核心是调用 NtCreateEvent,注意第二个大红框中同样在设置 7FF7F21671B0 处的地址,设置的内容和外层函数中一致,而 7FF7F21671B0 则被设置为 NtCreateEvent 参数 ObjectAttributes.ObjectName。
接下来详细看两个线程的作用,线程一调用函数 fun_NtRegisterThreadTerminatePort,该函数很简单,前面的 alpc porthandle var_alpcConnectionHandle 创建成功,则对其调用函数 NtRegisterThreadTerminatePort。
NtRegisterThreadTerminatePort这个函数是一个未公开的函数,但是网上有不少相关的信息,简单来说这个函数的作用是将一个的 alpc porthandler 和当前的线程关联,当线程退出时,内核调用 NtTerminateThread 后会已发送一条 LPC_TERMINATION_MESSAGE 到对应的 alpc 服务端口。
实际来看该函数,调用 ObReferenceObjectByHandle 获取该 porthandle 对应的内核 alpcport 对象,之后分配一个长度为 0x10 的内存 pool,将该对象保存在该内存 pool 0x8 偏移处,之后将该内存池和当前线程 _ETHREAD 对象相互引用,有意思的是该函数 NtRegisterThreadTerminatePort 在 k0shl 的对 CVE-2022-22715 漏洞(https://whereisk0shl.top/post/break-me-out-of-sandbox-in-old-pipe-cve-2022-22715-windows-dirty-pipe)的利用中作为一个工具函数以实现长度为 0x20 的对象 spray。
之后则是第二个线程调用函数 fun_expWorker,其内部根据标记位调用 fun_loopNtSetInformationWorkerFactory。
fun_loopNtSetInformationWorkerFactory 中首先调用 fun_setEvilmessage
之后后调用 NtAlpcSendWaitReceivePort,该函数通过前面 NtAlpcConnectPort 函数获取的 pdc porthandler 向 pdc alpc port 服务发送了一条消息,消息内容为 v6。
有趣的是当 NtAlpcSendWaitReceivePort 调用完毕后,似乎之前的 WorkerFactory 被修改了,这导致通过该 WorkerFactory 调用 NtSetInformationWorkerFactory 可以实现任意地址写入,代码中分为两种类型进行利用,如果是通过 I/O ring 的方式,则依次通过修改 I/O ring 利用中的关键 var_ioringRegBuffers/var_ioringRegBuffersCount 地址从而获取全局读写的能力,可以看到 NtSetInformationWorkerFactory 的第三个参数为写入的内容,而写入的目标地址则被 spray 在 0x1000000000 上,也就是说此时通过 NtSetInformationWorkerFactory 可以实现基于 0x100000000-0x1000002000 范围上保存随机地址的写入,而另一种提权方式则是通过该任意地址写入直接修改 PreviousMode,PreviousMode 地址同样被 spray 在 0x100000000-0x1000002000上,NtSetInformationWorkerFactory 调用设置 PreviousMode 后,通过 NtReadVirtualMemory/NtReadVirtualMemory 来获取全局读写的能力。
修改 PreviousMode 的利用方式最终在 fun_eopCmdProcess 中通过 NtReadVirtualMemory/NtReadVirtualMemory 实现提权。
I/O ring 的利用方式则在 fun_tokenChangewithSystem 中通过全局读写能力直接替换 cmd 进程的 token 为 system 实现提权。
I/O ring 任意地址读。
I/O ring 任意地址写入。
之后通过的写入功能修改畸形的 WorkerFactory,以便于之后顺利Close,可以看到其修改的位置是分别是 WorkerFactory-0x28/-0x30 的位置。
漏洞详细分析
通过以上样本的分析,我们基本可以得出一个结论,即 NtAlpcSendWaitReceivePort 调用之后,对应的 var_KWorkerHandleaddr 内核对象应该是被修改了,从而导致使用该 var_KWorkerHandleaddr 调用函数 NtSetInformationWorkerFactory 可以做到 0x100000000-0x1000002000 地址范围上的指针内容的写入,但是这里目前来看也是猜测(只是从我多年的直觉而言非常确信),因此此时我们总结出以下几个核心的问题。
- NtSetInformationWorkerFactory 中的 var_KWorkerHandleaddr 是否是被修改了,为何会导致 NtSetInformationWorkerFactory 可以在 0x100000000-0x1000002000 地址范围上的指针内容的写入。
- 如果var_KWorkerHandleaddr是被修改了是如何实现的?
- 在基于以上两个问题成立的情况下,NtRegisterThreadTerminatePort/NtAlpcSendWaitReceivePort的作用如何,我们的猜测是NtAlpcSendWaitReceivePort导致了var_KWorkerHandleaddr的修改。
- fun_NtCreateEvent中大量的NtCreateEvent调用起到什么作用。
- fun_setEvilmessage中的7FF72DE66130及外围的7FF72DE671B0中构造的内存有何作用?
针对第一个问题我们直接来看 NtSetInformationWorkerFactory 函数的实现,这里我们知道该函数的第三个参数是写入的 value,因此直接在该函数中找该参数的赋值位置,可以看到比较合理的只有这里,直接下断。
运行之后断下,赋值目标 rcx 通过 !object 看就是一个 TpWorkerFactory 的内核对象,其地址也和 exp 运行时获取 var_KWorkerHandleaddr 的地址一致,可以看到这里 var_KWorkerHandleaddr+0x10 的位置已经被修改为 0x10000000110。
而 0x10000000110 这个位置之后则被 exp spray 上成了 var_ioringRegBuffers。
赋值完毕后 var_ioringRegBuffers 被修改为 ffff0000。之后通过将 ffff0000 设置为 0,以实现 I/O Ring 的全局读写原子,因此这里确认 NtSetInformationWorkerFactory 实现了任意 0x100000000-0x1000002000 位置范围上指针的写入,是因为 var_KWorkerHandleaddr+0x10 位置的指针被设置为了 0x100000000-0x1000002000 区间的一个地址,这也是为什么 var_KWorkerHandleaddr 需要 spray 到这个区间的原因。
那紧接着第二个问题,var_KWorkerHandleaddr 是如何被修改的了?我们直接对 exp 中获取到的 var_KWorkerHandleaddr+0x10 处下内存写入断点,运行 exp 断下之后如下所示,此时是还未修改前,可以看到 0x10 偏移处这个地址通过 !object 并不能识别出来。
继续运行后,其修改发生在内核的 KeSetEvent 函数中,需要注意,这里的修改并不是一蹴而就的,KeSetEvent 执行的过程中该指针被修改多次,这里只列出比较重要的两次,如下是第一次修改。
第二次修改:
在ida中可以看到,实际上 KeSetEvent 中是在修改 event 对象中的 header,第一次修改如下。
第二次修改如下,从这里就可以确认我们的 var_KWorkerHandleaddr 地址的对象 +0xd/var_KWorkerHandleaddr 地址的对象 +0x11 被直接传入了 KeSetEvent 函数中作为一个 event 对象处理,最终造成了该 var_KWorkerHandleaddr 地址的对象 0x10 处指针的修改,由于每次 var_KWorkerHandleaddr 地址的对象都不一致,因此 0x10 处的指针也是变化的,这就造成了 0x10 处的指针最终被修改的地址是一个区间值(处于 0x100000000-0x1000002000),因此写入时目标地址才需要在该区间内进行 spray。
此时调用 KeSetEvent 时的堆栈如下,可以看到其调用的源头正是 NtAlpcSendWaitReceivePort,因此之前的猜测就没有任何问题了,由于漏洞导致 NtAlpcSendWaitReceivePort 修改了 var_KWorkerHandleaddr 地址的对象,从而使得在 NtSetInformationWorkerFactory 实现的任意 0x100000000-0x1000002000 范围位置保存指针的写入。
完整调用栈如下所示
那到底是什么样的漏洞导致了 NtAlpcSendWaitReceivePort 可以修改 var_KWorkerHandleaddr 地址的对象?从上述分析可以基本确认和 NtRegisterThreadTerminatePort/NtAlpcSendWaitReceivePort 这两个 alpc 函数有关,这里最简单的分析思路即直接逆向推导,监控调试 NtAlpcSendWaitReceivePort 到 KeSetEvent 的整个过程既可以知道 var_KWorkerHandleaddr 对象的修改是如何实现的,但是在这个之前我们需要先对 Windows 中 ALPC 这个机制有一个了解。
ALPC
ALPC 是一种快速、功能强大且在 Windows 操作系统(内部)中使用非常广泛的进程间通信机制,ALPC 通信的主要组件是 ALPC 端口对象。 ALPC 端口对象是一个内核对象,其使用类似于网络套接字的使用,其中服务器打开客户端可以连接的套接字以交换消息,ALPC通信场景涉及 3 个 ALPC 端口对象,第一个是由服务器进程创建的、客户端可以连接的 ALPC 连接端口 Connection port(类似于网络套接字) 。一旦客户端连接到服务器的 ALPC 连接端口,内核就会创建两个新端口,称为 ALPC 服务器通信端口 Server Communication Port 和 ALPC 客户端通信端口 Client Communication Port。
一旦服务器和客户端通信端口建立,双方就可以使用ntdll.dll公开的函数NtAlpcSendWaitReceivePort向对方发送消息,客户端可以使用函数NtAlpcConnectPort 开启一次连接,因此作为客户端的使用来说,以下两个函数就够用了。
- NtAlpcConnectPort
- NtAlpcSendWaitReceivePort
首先是 NtAlpcConnectPort,该函数用于连接 alpc 服务端,调用成功后会返回一个 PortHandle,其在内核就是前面提到的 ALPC 客户端通信端口。
完成 Connect,获取对应的 portHandle 后,就可以通过 NtAlpcSendWaitReceivePort 进行消息的发送和接收,这里需要注意该函数同时可以进行发送和接收的操作,此外,客户端通过该函数发送消息并不是直接发送到服务端,其需要通过内核进行一层转发,内核会负责路由所有消息,内核负责将消息放置在消息队列,通知各方收到的消息以及验证消息和消息属性等其他事情。
如下所示可以看到触发 var_KWorkerHandleaddr 地址的对象修改的 NtAlpcSendWaitReceivePort 函数调用堆栈以红线为分割,首先是 NtAlpcSendWaitReceivePort 的发送消息部分,之后通过 callback 通知对应的 pdc alpc port 服务实际处理程序 pdc.sys,在 pdc.sys 中完成相关的处理,因此我们直接跳过 NtAlpcSendWaitReceivePort 进入 pdc 中来看看 pdc.sys 是怎么处理收到的消息的。
首先 pdc 中处理 alpc 的核心函数在 PdcpAlpcProcessMessages 中,该函数中是一个 while 循环,其内部调用 ZwAlpcSendWaitReceivePort 接受内核发过来的消息,ZwAlpcSendWaitReceivePort 就是对 NtAlpcSendWaitReceivePort 的一个包装,我们前面提到过 alpc 的机制中发送和接收都是通过函数 NtAlpcSendWaitReceivePort 实现,且发送和接收方不直接对接,中间由内核进行路由,并最终在 PdcProcessMessage 中进行消息的处理,其两个参数分别是 ReceiveMessage;MessageAttribute,我们结合之前的调用栈来看看 var_KWorkerHandleaddr 地址是怎么传入修改的,这里注释中已经给出了答案是在 poi(poi(poi(poi(MessageAttribute)+0x20)+0x20)+0x6c8) 的位置,其来自于 MessageAttribute。MessageAttribute 则为 poi(ReceiveMessageAttributes(v5)+8) 的位置。
下面我们实际来看看整个传入的过程,函数 PdcProcessMessage 调用 PdcProcessReceivedUserMessage。
PdcProcessReceivedUserMessage 中调用 PdcpTaskClientReceive。
PdcpTaskClientReceive 中调用 PdcpDereferenceTaskClient。
PdcpDereferenceTaskClient 中调用 PdcpTaskClientAcknowledge。
PdcpTaskClientAcknowledge 中调用 PdcSendKernelMessage。
PdcSendKernelMessage中调用PdcPortQueueMessage。
PdcPortQueueMessage 中调用 KeSetEvent,最终传入的 poi(poi(poi(poi(MessageAttribute)+0x20)+0x20)+0x6c8) 将被修改。
看到这里仔细的读者可能会发现有问题的地方,即 MessageAttribute 是怎么来的,要知道我们的利用样本中调用 NtAlpcSendWaitReceivePort 时只有前三个参数,且只设置了 SendMessage,而对应的 SendMessageAttributes 参数则是空的,为什么我们在 PdcpAlpcProcessMessages 中,却能收到对应的 v5 ReceiveMessageAttributes,还能从中提取到 MessageAttribute,MessageAttribute 是怎么来的?
这一问题其实开始也困扰了我许久,但是这其实是一个思维误区,我们发送的时候确实是没有设置对应的 SendMessageAttributes,但是由于 alpc 中发送方和接受方并不是直接对接,这里接收方对接的其实是内核,而 pdc 接收方在接受 ZwAlpcSendWaitReceivePort 中是设定了对应的 ReceiveMessageAttributes 的,因此该参数会在内核路由的时候通过内核生成。
这里来看 NtAlpcSendWaitReceivePort 的接受分支代码即可知 AlpcpExposeAttributes 调用的前提就是先判断 ReceiveMessageAttributes 是否存在,pdc 中的 ZwAlpcSendWaitReceivePort 设置了该参数,因此内核路由这条消息时会在其中自动设置对应的 ReceiveMessageAttributes。
该过程的调用栈如下:
搞清楚了 messageattribute 的来历,我们现在需要确认 poi(poi(poi(poi(MessageAttribute)+0x20)+0x20)+0x6c8) 是如何被修改的?通过以上的分析我们可以确认问题应该不出在 NtAlpcSendWaitReceivePort 的位置,这种情况下就只有另一个函数,即 NtRegisterThreadTerminatePort。
这里通过测试发现该利用样本在安装了 2024 年 8 月的补丁后,将会失效,为此我们通过 bindiff 对 2024 年 7/8 两月的 Windows 内核文件进行对比,发现新版本的内核文件中,利用样本使用的 NtRegisterThreadTerminatePort 函数被删除了!
该函数的作用如前面的分析可知,是将一个的 alpc porthandler 和当前的线程关联,当内核调用 NtTerminateThread 后会已发送一条 LPC_TERMINATION_MESSAGE 到对应的端口,其调用逻辑如下
最终会调用 PspExitThread,PspExitThread 中有以下的处理,该函数会查看当前线程并获取之前通过 NtRegisterThreadTerminatePort 绑定的 alpc 端口对应的内核对象,并通过函数 LpcRequestPort 向对应的 alpc 服务端(利用代码中就是 pdc alpc port 服务)发送一条消息,该消息内容是以 300008006 开头,也就是前面说的 LPC_TERMINATION_MESSAGE。
LpcRequestPort 如下所示,最终发送通过 AlpcpSendMessage 实现,实际上 Lpc 是 Windows 中 Vista 之前内部进程进行通信的一种机制,Vsita 后被替换为更高效的 Alpc,为了保持兼容,可以看到所有的 Lpc调用本质上最终都是转向了 Alpc。
而我们这里的 alpc porthandler 实际上同样是 pdc alpc port 服务对应的 alpc 端口,其对应的驱动是 pdc.sys。
而进入 PdcProcessMessage 后,其中有一个分支用于处理 LPC_TERMINATION_MESSAGE,如下其判断的正是我们刚才发送的消息 300008006中+4 的 6 的位置,而这里 PdcFreeClient 将用于释放 poi(poi(MessageAttribute)+0x20),而该释放的位置之后应该是被 exp 中占据,并修改为了一段恶意的内存,该恶意内存中 poi(poi(evil+0x20)+0x6c8) 指向了一段 var_KWorkerHandleaddr,从而在函数 KeSetEvent 中传入 poi(poi(poi(poi(MessageAttribute)+0x20)+0x20)+0x6c8) 并修改
那我们接下来的问题就是需要确认:
- 是否是 PdcFreeClient 造成释放,并之后被重用
- 问题1 成立的情况下,这段释放的内存是什么,如何生成的,其为什么在系统发送的 LPC_TERMINATION_MESSAGE 消息及我们通过 NtAlpcSendWaitReceivePort 发送触发的消息之间没有修改
- 如何实现的内存占据,我们的猜测是 NtCreateEvent,毕竟代码中部 NtCreateEvent 的 spray 的操作过于明显。
当对应的绑定线程退出时,触发 LpcRequestPort 的调用,内核将向对应的 pdc alpc port 服务发送一条 300008 开头的 LPC_TERMINATION_MESSAGE 消息。
pdc alpc port服务在pdc.sys的PdcpAlpcProcessMessages函数中处理接受的消息,如前文所说,alpc中的消息是由内核路由,这里调用ZwAlpcSendWaitReceivePort接受消息,由于此处ZwAlpcSendWaitReceivePort中指定了ReceiveMessageAttributes(v5),因此内核在路由该消息时也会生成该数据,哪怕实际发送发送方发并没有发送。
PdcpAlpcProcessMessages 中调用 ZwAlpcSendWaitReceivePort 前,通过 AlpcInitializeMessageAttribute 创建一个 ReceiveMessageAttributes 的对象。
ZwAlpcSendWaitReceivePort 调用,实际还是进入到内核中的 NtAlpcSendWaitReceivePort,并进入 AlpcpReceiveMessage,并调用 AlpcpReceiveMessagePort。
如下所示,AlpcpReceiveMessagePort 的核心在于返回接受消息对应的 _KALPC_MESSAGE。
这里对应的 server connection port 端口对象如下所示。
nt!_ALPC_PORT 的整体结构如下。
0: kd> dt nt\!\_ALPC\_PORT
\+0x000 PortListEntry : \_LIST\_ENTRY
\+0x010 CommunicationInfo : Ptr64 \_ALPC\_COMMUNICATION\_INFO
\+0x018 OwnerProcess : Ptr64 \_EPROCESS
\+0x020 CompletionPort : Ptr64 \_KQUEUE
\+0x028 CompletionKey : Ptr64 Void
\+0x030 CompletionPacketLookaside : Ptr64 \_ALPC\_COMPLETION\_PACKET\_LOOKASIDE
\+0x038 PortContext : Ptr64 Void
\+0x040 StaticSecurity : \_SECURITY\_CLIENT\_CONTEXT
\+0x088 IncomingQueueLock : \_EX\_PUSH\_LOCK
\+0x090 MainQueue : \_LIST\_ENTRY
\+0x0a0 LargeMessageQueue : \_LIST\_ENTRY
\+0x0b0 PendingQueueLock : \_EX\_PUSH\_LOCK
\+0x0b8 PendingQueue : \_LIST\_ENTRY
\+0x0c8 DirectQueueLock : \_EX\_PUSH\_LOCK
\+0x0d0 DirectQueue : \_LIST\_ENTRY
\+0x0e0 WaitQueueLock : \_EX\_PUSH\_LOCK
\+0x0e8 WaitQueue : \_LIST\_ENTRY
\+0x0f8 Semaphore : Ptr64 \_KSEMAPHORE
\+0x0f8 DummyEvent : Ptr64 \_KEVENT
\+0x100 PortAttributes : \_ALPC\_PORT\_ATTRIBUTES
\+0x148 ResourceListLock : \_EX\_PUSH\_LOCK
\+0x150 ResourceListHead : \_LIST\_ENTRY
\+0x160 PortObjectLock : \_EX\_PUSH\_LOCK
\+0x168 CompletionList : Ptr64 \_ALPC\_COMPLETION\_LIST
\+0x170 CallbackObject : Ptr64 \_CALLBACK\_OBJECT
\+0x178 CallbackContext : Ptr64 Void
\+0x180 CanceledQueue : \_LIST\_ENTRY
\+0x190 SequenceNo : Int4B
\+0x194 ReferenceNo : Int4B
\+0x198 ReferenceNoWait : Ptr64 \_PALPC\_PORT\_REFERENCE\_WAIT\_BLOCK
\+0x1a0 u1 : <unnamed\-tag>
\+0x1a8 TargetQueuePort : Ptr64 \_ALPC\_PORT
\+0x1b0 TargetSequencePort : Ptr64 \_ALPC\_PORT
\+0x1b8 CachedMessage : Ptr64 \_KALPC\_MESSAGE
\+0x1c0 MainQueueLength : Uint4B
\+0x1c4 LargeMessageQueueLength : Uint4B
\+0x1c8 PendingQueueLength : Uint4B
\+0x1cc DirectQueueLength : Uint4B
\+0x1d0 CanceledQueueLength : Uint4B
\+0x1d4 WaitQueueLength : Uint4B
AlpcpReceiveMessagePort 会从 _ALPC_PORT 对象中获取消息队列 MainQueue 中的消息。
消息队列中的消息为 nt!_KALPC_MESSAGE 对象,如下所示可以看到取出的消息对象 +0xf0 的位置正是发送的 3000008 消息实体。
_KALPC_MESSAGE 的结构如下所示,0x68 开始就是 MessageAttributes,0xf0 则是对应的消息实体。
之后对该 _KALPC_MESSAGE 进行一些设置,跳转到 Label_19。
AlpcpReceiveMessagePort 函数最后将该 _KALPC_MESSAGE 通过 a4 返回。
如下所示,返回的 _KALPC_MESSAGE。
由于 PdcpAlpcProcessMessages 中 ZwAlpcSendWaitReceivePort 设置了 ReceiveMessageAttributes 参数,也就是这个地方的 a4,因此进入函数 AlpcpExposeAttributes。
AlpcpExposeAttributes 函数调用的参数如下所示,需要注意的是 a2=0,a3 则是前面 AlpcpReceiveMessagePort 返回的 _KALPC_MESSAGE 对象,a4=2000000,a5 则是 ReceiveMessageAttributes。
因此这里 AlpcpExposeAttributes 经过 a2,a4 的判断后直接进入下图红框中的位置。
之后会设置 ReceiveMessageAttributes,其数据原就是 _KALPC_MESSAGE 对象中的数据。
如下所示 rcx 就是 ReceiveMessageAttributes+8。
这次赋值中核心的是 ReceiveMessageAttributes+8 位置的赋值,可以看到这里传入的是 _KALPC_MESSAGE->MessageAttributes->PortContext。
PortContext 被设置到 ReceiveMessageAttributes+8。
完成设置的 ReceiveMessageAttributes+8 如下所示。
可以看到该 ReceiveMessageAttributes 被设置的内容如下所示:
PdcpAlpcProcessMessages 中 ZwAlpcSendWaitReceivePort 调用返回,此时的传入函数 PdcProcessMessage 的第二个参数 MessageAttribute 就是 ReceiveMessageAttributes+8,第一个参数则是 300008 的消息实体,如前文分析,该消息实体 +0x4 位置处的 6 将导致进入 PdcFreeClient。
PdcFreeClient 中将依此释放 poi(poi(poi(MessageAttribute)+0x20)+0x20) 及 poi(poi(MessageAttribute)+0x20)。
如下所示 poi(poi(poi(MessageAttribute)+0x20)+0x20) 实际指向了 poi(MessageAttribute),因此这里两次释放的实际是 poi(MessageAttribute) 及 poi(poi(MessageAttribute)+0x20)。
首先释放的 poi(poi(MessageAttribute)+0x20),如下所示,其大小为 0x50 的 pool。
之后是 poi(MessageAttribute)。
poi(MessageAttribute) 的大小同样是 0x50。
PdcFreeClient 返回后,这两个位置皆被释放。
3000008 消息导致释放时的调用栈。
因此这里我们就明白了该漏洞的核心,NtRegisterThreadTerminatePort 会将当前的 var_alpcConnectionHandle 绑定到当前的线程 CreateThread1,当 CreateThread1退出时,内核中会通过该线程对象获取 var_alpcConnectionHandle 对应的 alpc port 内核对象,并向 pdc alpc port 发送一条 LPC_TERMINATION_MESSAGE 消息,PdcpAlpcProcessMessages 中在处理该 LPC_TERMINATION_MESSAGE 消息时会调用 ZwAlpcSendWaitReceivePort 获取该消息,由于此时函数中传入了 ReceiveMessageAttributes 参数,因此,内核在路由该消息时将生成对应的 ReceiveMessageAttributes,ReceiveMessageAttributes+8 的位置会被设置为了该条消息的 KALPC_MESSAGE->MessageAttributes->PortContext,ZwAlpcSendWaitReceivePort 返回后,进入 PdcProcessMessage 处理 LPC_TERMINATION_MESSAGE 消息,并最终调用 PdcFreeClient 释放掉了 ReceiveMessageAttributes+8 指向的 KALPC_MESSAGE->MessageAttributes->PortContext。那该释放的 KALPC_MESSAGE->MessageAttributes->PortContext 是通过什么方式被重用的了?答案是通过 NtCreateEvent 的 spray,这里直接对释放地址下写入断点,可以看到 NtCreateEvent 最终调用 ObpLookupObjectName,并通过 ExAlloctePool 完成释放 pool 的重用。
如下所示实际位置如下,并在之后的 memove 中将 NtCreateEvent 调用时设置的 ObjectAttributes.ObjectName 拷贝到该释放地址,而 ObjectAttributes.ObjectName 此时的内容在一开始被设置指向了恶意构造的 evil message PortContext。
如下所示可以看到此时 poi(MessageAttribute)+0x20 写入的就是我们的 evil message PortContext 的地址。
如下即为重用时的函数调用栈
而这里 fun_NtCreateEvent 中是有两套占据重用的方案。除了 NtCreateEvent 外,其下还有一个 NtQueryLicenseValue。
NtQueryLicenseValue 这里同样是通过传入的第一个参数分配一段 0x40 的 pools,正好可以占据释放的 PortContent 内存,之后会将 7FF72DE671B0 处的内容写入这段 pools,其 0x20 位置正好就是 Evil message portcontent,但是在实际利用中,这个函数基本上不需要用到,exp 代码中哪怕直接将其调用 patch 掉,也不会影响实际的利用效果。
NtCreateEvent 完成释放的 PortContext 重用后,exp 中使用该 var_alpcConnectionHandle 调用 NtAlpcSendWaitReceivePort,如下所示为此时 PdcProcessMessage 调用时接收到的由内核路由的 exp 发送的 30002d8 消息,同样该消息通过 ZwAlpcSendWaitReceivePort 从内核接收,因为设置了 ReceiveMessageAttributes,因此这里 300002d8 的消息同样也会返回 ReceiveMessageAttributes。
由于都是由内核的 connection port 返回,因此尽管 Mainqueus 中的 KALPC_MESSAGE 不同,但是 KALPC_MESSAGE->MessageAttributes->PortContext 却是一致,而 PortContext 在之前的 300008 消息的处理后已经被释放,并被 NtCreateEvent 重用被写入了构造 evil message PortContext。
可以看到此时 PortContext+0x20 就指向了 evil message PortContext,而 evilmessage+0x1798 的位置就保存了 var_KWorkerHandleaddr + 0xD 的地址
而实际的寻址则是遵循 poi(poi(poi(poi(MessageAttribute)+0x20)+0x20)+0x6c8)。最终找到保存了 var_KWorkerHandleaddr + 0xD 的位置。
也正是我们一开始构造的 evil message PortContext。
如下可见 30000008 消息时对应的 PortContext 为 0xffffe30b16a0d850。
而同样在 30000d28 消息时 PortContext 依旧是 0xffffe30b16a0d850,因此才会保证 UAF 的复用。
300002d8 消息的处理中,后续会通过该 evil message portContext 修改 var_KWorkerHandleaddr+0x10 处的指针,详情前面已经分析过,具体的调用栈如下,最终在KeSetEvent中完成修改。
这里每次调用只是完成四个字节的修改,因此要完成 8 个字节长度指针的修改需要触发两次,这也是为什么要设置 var_countsForintoLoopWorkerFactory 保证调用 NtAlpcSendWaitReceivePort 两次以上的原因。
第一次修改四字节,可以看到此时 evilmessage 中设置的是 var_KWorkerHandleaddr+0xd 开始的四字节。
第二次发送 3000002d8 消息。
可以看到第二次则修改 var_KWorkerHandleaddr+0x11 开始的四字节,通过 KeSetEvent 最终将该指针控制在 0x100000000-0x1000002000 的范围。
最终在将目标写入地址 spray 在 0x100000000-0x1000002000 范围,通过调用实现对 i/o ring 的修改,从而获取任意地址读写原子。
总结
整个利用的流程如下所示:
1. 调用 NtAlpcConnectPort 连接 pdc alpc port 服务,获取一个 var_alpcConnectionHandle。
2. 在线程1中调用 NtRegisterThreadTerminatePort,将 var_alpcConnectionHandle 绑定在线程1的 _ETHREAD 内核对象上。
3.1 监控线程1的情况,当线程1退出时,内核中 PspExitThread 调用,_ETHREAD 内核对象上绑定 var_alpcConnectionHandle 内核对象会调用 LpcRequestPort 向 pdc port 服务端发送一条 LPC_TERMINATION_MESSAGE消息。
3.2 pdc 服务端通过 PdcpAlpcProcessMessages 函数处理相关的消息,该函数中接收内核路由的 alpc 消息是通过 ZwAlpcSendWaitReceivePort 实现,该函数的调用中设置了参数 ReceiveMessageAttributes,这将导致 ZwAlpcSendWaitReceivePort->NtAlpcSendWaitReceivePort->AlpcpReceiveMessage->AlpcpExposeAttributes 调用,通过 AlpcpReceiveMessagePort 获取该消息的 _KALPC_MESSAGE,并设置对应的 ReceiveMessageAttributes,这里 ReceiveMessageAttributes+8 的位置会被设置为 _KALPC_MESSAGE.MessageAttributes.PortContext,该值和connection port 绑定,即此时所有的接收到的消息中的 _KALPC_MESSAGE.MessageAttributes.PortContext 都是固定的指针。
3.3 调用 PdcProcessMessage 处理该消息,并最终在 PdcFreeClient 中释放掉 ReceiveMessageAttributes+8 保存的 _KALPC_MESSAGE.MessageAttributes.PortContext 指针。
4. 确保 ReceiveMessageAttributes->_KALPC_MESSAGE.MessageAttributes.PortContext 释放后,循环调用 NtCreateEvent,这里将其参数 ObjectAttributes.ObjectName 设置为 7FF72DE671B0,而在 7FF72DE671B0+0x20 的位置则保存了 evil message PortContext 7FF72DE66130,最终 NtCreateEvent 调用,并在 ObpLookupObjectName 中通过ExAllocatePool2 占据了释放的 ReceiveMessageAttributes->_KALPC_MESSAGE.MessageAttributes.PortContext 内存,并随后通过 memory 将 ObjectAttributes.ObjectName 中设置的 7FF72DE66130 写入到 ReceiveMessageAttributes->_KALPC_MESSAGE.MessageAttributes.PortContext 这段内存 +0x20 的位置,实现重用及修改。
5.1 线程2 中,当确保了 NtCreateEvent 占据完毕,ReceiveMessageAttributes->_KALPC_MESSAGE.MessageAttributes.PortContext+0x20 指向了 evil message PortContext 7FF72DE66130 后,通过 var_alpcConnectionHandle 调用 NtAlpcSendWaitReceivePort,向 pdcport 服务端发送一条30002d8的消息
5.2 类似于前面 3000008 LPC_TERMINATION_MESSAGE 消息的处理,此时通过 ZwAlpcSendWaitReceivePort 从内核获取 ReceiveMessageAttributes,ReceiveMessageAttributes+8 的位置指向了_KALPC_MESSAGE.MessageAttributes.PortContext,由于该指针在同一个 connection port 下的所有 _KALPC_MESSAGE 一致,因此这里返回的 _KALPC_MESSAGE.MessageAttributes.PortContext,其中的 0x20 偏移处已经在第四部分被修改 evil message PortContext 7FF72DE66130。
5.3 PdcProcessMessage 处理 30002d8 消息,最终会导致 poi(poi(poi(poi(poi(ReceiveMessageAttributes+8))+0x20)+0x20)+0x6c8) 处的 var_KWorkerHandleaddr + 0xd/0x11 在 KeSetEvent 被设置,两次 NtAlpcSendWaitReceivePort 调用后(每次修改4个字节) var_KWorkerHandleaddr+0x10 处的指针将被修改为一个 0x100000000-0x1000002000 范围的值,这里我们抢占的是 poi(poi(poi(poi(poi(ReceiveMessageAttributes+8))+0x20)+0x20)+0x6c8) 红色指针释放的内存,poi(poi(poi(poi((poi(ReceiveMessageAttributes+8))+0x20)+0x20)+0x6c8),替换的则是蓝色部分的指针,将其设置为 evil message PortContext。
6. var_KWorkerHandleaddr+0x10 处的指针被修改为 0x100000000-0x1000002000 范围的值,通过在该范围的地址上 spray 目标写入地址,使用 var_KWorkerHandleaddr 调用 NtSetInformationWorkerFactory,将可以获取一次任意地址写入的能力,通过该能力最终提供了修改 i/o ring/PreviousMode 的两种提权方式。
参考链接
[1]. https://github.com/avalon1610/ALPC/blob/master/ALPC/ntlpcapi.h
[2]. https://whereisk0shl.top/post/break-me-out-of-sandbox-in-old-pipe-cve-2022-22715-windows-dirty-pipe
[3]. https://recon.cx/2008/a/thomas_garnier/LPC-ALPC-slides.pdf
[4]. https://csandker.io/2022/05/24/Offensive-Windows-IPC-3-ALPC.html
[5]. https://i.blackhat.com/Asia-22/Friday-Materials/AS-22-Xu-The-Next-Generation-of-Windows-Exploitation-Attacking-the-Common-Log-File-System.pdf