该漏洞样本为前段时间奇安信威胁情报中心日常在野漏洞监控运营经发现,其最早被上传时只有6个查杀。
经过分析确认该漏洞应该是在八月的微软补丁中被修复,是一个被修复的未知nday利用,运行的具体效果如下所示。
这里首先过一下整个样本,样本开始首先启动了一个cmd,之后调用核心fun_vulstar。
fun_vulstar中判断当前的机器的相关版本。
开启一个新线程,调用漏洞利用函数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实现提权。
之后通过的写入功能修改畸形的WorkerFactory,可以看到其修改的位置是分别是WorkerFactory-0x28/-0x30的位置。
通过以上样本的分析,我们基本可以得出一个结论,即NtAlpcSendWaitReceivePort调用之后,对应的var_KWorkerHandleaddr内核对象应该是被修改了,从而导致使用该var_KWorkerHandleaddr调用函数NtSetInformationWorkerFactory可以做到0x100000000-0x1000002000地址范围上的指针内容的写入,但是这里目前来看也是猜测(只是从我多年的直觉而言非常确信),因此此时我们总结出以下几个核心的问题:
1.NtSetInformationWorkerFactory中的var_KWorkerHandleaddr是否是被修改了,为何会导致NtSetInformationWorkerFactory可以在0x100000000-0x1000002000地址范围上的指针内容的写入。
2.如果var_KWorkerHandleaddr是被修改了是如何实现的?
3.在基于以上两个问题成立的情况下,NtRegisterThreadTerminatePort/NtAlpcSendWaitReceivePort的作用如何,我们的猜测是NtAlpcSendWaitReceivePort导致了var_KWorkerHandleaddr的修改。
4.fun_NtCreateEvent中大量的NtCreateEvent调用起到什么作用。
5.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,该函数用于连接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)并修改
那我们接下来的问题就是需要确认:
1.是否是PdcFreeClient造成释放,并之后被重用
2.问题1成立的情况下,这段释放的内存是什么,如何生成的,其为什么在系统发送的LPC_TERMINATION_MESSAGE消息及我们通过NtAlpcSendWaitReceivePort发送触发的消息之间没有修改
3.如何实现的内存占据,我们的猜测是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 :
+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返回。
由于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)。