references:
- https://www.zscaler.com/blogs/security-research/technical-analysis-windows-clfs-zero-day-vulnerability-cve-2022-37969-part
- https://www.coresecurity.com/core-labs/articles/understanding-cve-2022-37969-windows-clfs-lpe
- https://github.com/ionescu007/clfs-docs/blob/main/README.md
成功将exp进程的token重写为system的token
安装完这个升级补丁之后就和上面文章中的测试环境版本是一致的了
CLFS即 Common Log File System
blf(base log file)日志文件格式
三种类型的record
control、base、truncate
我们只关注base,因为只有他和这个漏洞相关
blockheader的结构体定义:
typedef struct _CLFS_LOG_BLOCK_HEADER
{
UCHAR MajorVersion;
UCHAR MinorVersion;
UCHAR Usn;
CLFS_CLIENT_ID ClientId;
USHORT TotalSectorCount;
USHORT ValidSectorCount;
ULONG Padding;
ULONG Checksum;
ULONG Flags;
CLFS_LSN CurrentLsn;
CLFS_LSN NextLsn;
ULONG RecordOffsets[16];
ULONG SignaturesOffset;
} CLFS_LOG_BLOCK_HEADER, *PCLFS_LOG_BLOCK_HEADER;
sizeof(CLFS_LOG_BLOCK_HEADER )=0x70
sector type枚举
const UCHAR SECTOR_BLOCK_NONE = 0x00;
const UCHAR SECTOR_BLOCK_DATA = 0x04;
const UCHAR SECTOR_BLOCK_OWNER = 0x08;
const UCHAR SECTOR_BLOCK_BASE = 0x10;
const UCHAR SECTOR_BLOCK_END = 0x20;
const UCHAR SECTOR_BLOCK_BEGIN = 0x40;
sector signature存在于每个sector的末尾,偏移量为0x200,它包含两个字节,一个是sector type,另一个是usn
base block起始于blf文件的0x800,终止于0x71ff
漏洞更新补丁,更新完成后版本号变更为978,我们对两个版本的clfs.sys进行对比
通过diff可以看到这两个驱动的一个clfs日志处理相关的函数发生了变动
CClfsBaseFilePersisted::LoadContainerQ
patch相比vuln在这个地方多了一个检查1C0037963,这个在bidiff中也能看到
右侧分支发生了很大的变化
我么可以注意到这个0x1338实际上是_CLFS_BASE_RECORD_HEADER结构体的size
那么这个BaseLogRecord变量就是_CLFS_BASE_RECORD_HEADER,在ida中导入这个结构体的定义
导入完定义之后,应用之后可以看到如下代码
他把结构体结束位置和cbSymbolZone进行了相加
cbSymbolZone的偏移量是1328h
那么这个字段在原始文件中的偏移就是800h+70h+1328h=1b98h
我们后面会伪造这个字段的值
base block的size
rcx是this
poi(poi(rcx+30)+30) 是blf文件起始地址+800h
从这个pool指令中可以看到整个base block的size是7a00h,这个在后面的堆喷洒中会用到
我们通过循环创建logfile的方式来使得内核中出现很多clfs pool,然后直到出现7个连续的pool,就停下来,如下图所示
现在我们已经拥有了7个连续的间隔为0x10000,size为0x7a00的内存块
CreatePipe() / NtFsControlFile()
使用这种方法可以获取到一块任意读取的内核内存,通过bigpoolinfo技术查询tag来定位,tag是0x7441704E NpAt(Named Pipe AtTribute)
这里是我们写入的数据,前面有24h字节的系统数据,后面的就是我们自己的数据
0x18是buffersize,比我们实际传进去的少了2bytes,我也不知道为啥,我们的实际长度是fd8h,内核里面是fd6h
_NtFsControlFile(hReadPipe[0], 0, 0, 0, &v30, 0x11003c, v9a, 0xfd8, dest, 0x100);
关键位置
我们现在要看一下68h这个signature offset的值是什么时候被更改掉的
在我们第二次打开初始log文件时,此时这个文件的内容已经被修改了,68h被修改50h,我们在源代码中调用一下int3来中断到调试器中,此时我们开启CLFS!CClfsBaseFilePersisted::LoadContainerQ函数断点,看一下此时的68h,在内核中也是0x50
然后我们来在这个地方下一个写入断点
触发后的调用栈如下
1: kd> g
Breakpoint 3 hit
CLFS!ClfsEncodeBlockPrivate+0xc4:
fffff803`2bc366b4 4983c002 add r8,2
1: kd> dqs ffffc006`d55a0068 l1
ffffc006`d55a0068 00000000`ffff0050
1: kd> k
# Child-SP RetAddr Call Site
00 ffffd88b`8477ffe0 fffff803`2bc33535 CLFS!ClfsEncodeBlockPrivate+0xc4
01 ffffd88b`8477fff0 fffff803`2bc5dd02 CLFS!ClfsEncodeBlock+0x1d
02 ffffd88b`84780020 fffff803`2bc5d720 CLFS!CClfsBaseFilePersisted::WriteMetadataBlock+0x152
03 ffffd88b`847800b0 fffff803`2bc334ef CLFS!CClfsBaseFilePersisted::FlushImage+0x40
04 ffffd88b`847800f0 fffff803`2bc393b5 CLFS!CClfsLogFcbPhysical::FlushMetadata+0xef
05 ffffd88b`84780140 fffff803`2bc6a313 CLFS!CClfsLogFcbPhysical::Initialize+0xc79
06 ffffd88b`84780250 fffff803`2bc5fecb CLFS!CClfsRequest::Create+0x48b
07 ffffd88b`847803a0 fffff803`2bc5fc97 CLFS!CClfsRequest::Dispatch+0x97
08 ffffd88b`847803f0 fffff803`2bc5fbe7 CLFS!ClfsDispatchIoRequest+0x87
09 ffffd88b`84780440 fffff803`29c8a6a5 CLFS!CClfsDriver::LogIoDispatch+0x27
0a ffffd88b`84780470 fffff803`2a148d37 nt!IofCallDriver+0x55
0b ffffd88b`847804b0 fffff803`2a139092 nt!IopParseDevice+0x897
0c ffffd88b`84780670 fffff803`2a138501 nt!ObpLookupObjectName+0x652
0d ffffd88b`84780810 fffff803`2a081adf nt!ObOpenObjectByNameEx+0x1f1
0e ffffd88b`84780940 fffff803`2a0816b9 nt!IopCreateFile+0x40f
0f ffffd88b`847809e0 fffff803`29e2d375 nt!NtCreateFile+0x79
10 ffffd88b`84780a70 00007fff`50224624 nt!KiSystemServiceCopyEnd+0x25
11 00000079`eed7e718 00007fff`127b48e2 ntdll!NtCreateFile+0x14
12 00000079`eed7e720 00007ff6`8df724ba clfsw32!CreateLogFile+0x6c2
13 00000079`eed7e8c0 00007ff6`8df93c01 lfs+0x24ba
关键位置
ffffffff00000写入1C001295C
signature offset写入1C000669E
溢出的关键位置 1C0040880
下面我说的800的意思就是base block的起始位置,800是他相对于文件开头的偏移量,这个800是个16进制值,如果不特殊说明,我下面说的所有的数字都是16进制形式的
循环计算出需要构造的值
可以看到1bfe的word值为0xffff,这个值是在1C001295C 写入的,这个偏移我们是可以控制的,因为我们可以控制ccoffsetArray的第一个ele为0x1b30,他指示client context对象相对于800+70的偏移位置,那么也就是相对于800的偏移是1ba0,而1C001295C 写入的是client context的0x58的qword,那么就是1ba0+58=1bf8,也就是说从1bf8长度为8的内存的内容是这个样子的
1: kd> db poi(poi(rcx+30)+30)+1bf8 l8
ffffc006`cfbdabf8 00 00 00 00 ff ff ff ff
那么很明显,从1bfe(相对于1bf8跳过6字节)读取一个DWORD的话就会读取到最后的两个ff
而1C000669E处循环中的v10的值我们也是可以控制的,他是由800+signatureoffset计算出来的,而signatureoffset字段的值我们是可以伪造的,这个循环里的a1就是800
通过上面的循环计算,我们知道读取到ffff的时候,v10增长了1a字节,而signatureoffset字段相对于800的偏移量是68h,我们想要覆盖掉68h的高WORD部分,也就是0x6a,6a-1a=50h,那么我们只需要将初始signatureoffset字段的值设置为50h即可
可以看到这里用到了signatureoffset字段,由于我们把他的高字节写成了ffffh,可以保证这个判断条件总是为false,从而防止返回错误,如果没有写入ffff到高字节,那么我们会因为cbSymbolZone过大而返回错误,其实这里就是一个越界检查,被我们伪造的数据给绕过了
然后在下面通过伪造的cbSymbolZone来造成outbound write
连续间隔pool
在GetOffsetBetweenPools函数中创建了很多随即名称的logfile,每次创建都会触发内核分配内存,得到连续6个相同间隔的pool之后返回,而且这些连续pool的间隔总是11000h,关键就是引发系统内存管理器的惯性分配,这是我自己瞎发明的名词,哈哈,但是我感觉就是使用了这个,后面分配内存也是有挺大的概率会不满足我们的条件的,后面我可以尝试把这个函数删了,看看他是否有影响,目前看来用处不大
这个操作很有必要,因为如果没有前面这一对操作,内存管理器大概率不会分配出来正好间隔11000h的两块内存,而如果我们指示反复操作正常和异常两个文件,那么内存管理器最终会给异常文件一直分配同一个内存地址
valid pool addr: 0xFFFFC006D239C000
valid pool addr: 0xFFFFC006D23AD000
valid pool addr: 0xFFFFC006D0D0C000
valid pool addr: 0xFFFFC006D0D03000
valid pool addr: 0xFFFFC006D0D14000
valid pool addr: 0xFFFFC006D23BE000
valid pool addr: 0xFFFFC006D0D24000
valid pool addr: 0xFFFFC006D0D35000
valid pool addr: 0xFFFFC006D0D46000
valid pool addr: 0xFFFFC006D0D57000
valid pool addr: 0xFFFFC006D2769000
valid pool addr: 0xFFFFC006D277A000
valid pool addr: 0xFFFFC006D278B000
valid pool addr: 0xFFFFC006D279C000
valid pool addr: 0xFFFFC006D27AD000
valid pool addr: 0xFFFFC006D27BE000
valid pool addr: 0xFFFFC006D0036000
valid pool addr: 0xFFFFC006D0047000
valid pool addr: 0xFFFFC006D0058000
valid pool addr: 0xFFFFC006D0069000
valid pool addr: 0xFFFFC006D007A000
valid pool addr: 0xFFFFC006D008B000
valid pool addr: 0xFFFFC006D009C000
这样之后,等我们第一次调用pipeArbitraryWrite函数,在该函数中使用CreateLogFile函数分别打开异常和正常log文件的时候,他们两个在内核内存中的间距也一定是11000h,我们可以验证一下
但是他这个是有概率的,并不总是能够成功
像下面这种,就算成功了,正常文件的内容在异常文件的地址+11000h
可一看到此时memset的dst已经加上了一个非常大的数
我们一场文件的buffer地址落后正常文件的buffer地址11000h,此时我们的目标地址是70+1338+1114b
那么在正常文件中就是70+1338+14b
1: kd> ?70+1338+14b
Evaluate expression: 5363 = 00000000`000014f3
可以看到memset被调用之后出现了下面这种情况
注意我们这里用的这个基地址是文件中800的位置,也就是说这个东西+70就是_CLFS_BASE_RECORD_HEADER结构体了
当运行到memest的时候,目标位置是正常文件的800的14f3偏移,
从上图可以看到正常文件的container数组的第一个成员的值是1468,而这个1468是相对于800+70的,也就是说相对于800的偏移是1468+70=14d8
而+18正是container对象的地址
那么也就是14d8+18=14f0
通过我们的精准控制,可以把这个地址值ffffc006c31468c0的高5字节全部清0,只保留低位3字节
memset运行完成后结果如下
为什么要控制container对象的地址呢?
因为在我们关闭正常log文件的句柄时,作为对象销毁流程的一部分,CClfsBaseFilePersisted::RemoveContainer函数会被调用,他会从container context的0x18偏移取出来container对象,然后调用他的成员函数
可以看到这里调用了两个成员函数,一个位于18,一个位于8
我们前面的堆喷射,这个时候就排上用场了,由于我们抹掉了container对象地址的高5字节,那么这个地址就变成用户模式的地址了,就可以由堆喷射来进行控制里面的内容,这个用户模式的第一个qword就是container对象的虚函数表,这样我们就可以控制0x18和0x8处要调用的函数以及传入的参数了
可以看到原始的内核地址是16字节对齐的,这也是为什么我们的堆喷射要每隔10h字节射一次
我们从0x10000开始,每隔10h射一个0x5000000到qword内存中,范围是0x10000~0x1000000
由于篡改后的地址只有3字节,0x1000000完全可以覆盖这个地址的最大值,而这个地址又不太可能低于10000,所以这个范围就够用了
我们在exp代码中控制了内存地址0x5000000的值
之所以把18设置为ClfsEarlierLsn函数,是因为这个函数把rdx的值设置为了FFFFFFFF,而这个值正好可以作为用户模式的地址,这里有一点点rop的味道
我们在exp代码中,已经在FFFFFFFFh这个地址中保存了system进程的eprocess地址
需要注意的是system的eprocess是可以在用户模式下使用普通用户的权限查询到的
函数运行结束,rdx变成包含了system eprocess的地址
出来之后我们调用SeSetAccessStateGenericMapping函数
这个函数内容可以说是相当简单,就是把rdx的OWORD读出来写到poi(rcx+48)+8这个地址中
而rcx我们是可以控制的,+48的内容由堆喷控制
可以看到这里i从10008开始,每隔10h字节射一次v16a变量的值,在内存中的布局就会变成这个样子
0000000005000000 和ffffc006d09fe018会交替出现,保证rcx+48取值出来一定是ffffc006d09fe018
而ffffc006d09fe018是我们exp代码中使用_NtFsControlFile创建出来的内核buffer地址
SeSetAccessStateGenericMapping函数结束后,这块内核buffer的值如下
可以看到+8位置已经写入了system ep的地址
而在_NtFsControlFile函数伪造的内核buffer中,这个位置本身应该是指向用户控制的内核buffer的地址的,现在被替换为system ep的地址,所以我们就可以读取system ep地址所指向的内存了
在上面有一个节标题为CreatePipe() / NtFsControlFile()的内容的参考链接中我们提到过如何用这个东西任意读写内存
我们首先使用控制码0x11003C写入了内核内存
然后我们可以使用0x110038来读取这块内存
可以看到这里已经读取到了system ep的token
我们把这个token存入FFFFFFFF中,然后对堆喷射中的+8位置要喷的值进行修改,改为exp进程的ep的token偏移-8
后面我们利用SeSetAccessStateGenericMapping函数的xmmword ptr [rax+8], xmm0
指令写入exp的ep的+4b0的10h字节
而MmReserved即使被修改为不正常的值也不会影响程序正常运行
0xffffffff地址中的值改为前面获取到的system ep的token
exp进程的token被替换成system进程的token
此时我们再来创建一个cmd进程,他应该是ntsystem权限
exp
总结
其实就是1C0040880 的边界被绕过了
比较牛逼的地方是1C000669E,不知道原作者是怎么发现可以在这里修改这个地方的值的
这个利用链确实屌