原文:Exploiting SMBGhost (CVE-2020-0796) for a Local Privilege Escalation: Writeup + POC
作者:ZECOPS安全团队
CVE-2020-0796是SMBv3.1.1的压缩机制中的一个漏洞,也叫做“SMBGhost”。这个漏洞会影响Windows 10的1903和1909版本,在三周前由微软发布并修复。得知此消息后,我们快速阅读了这个漏洞的细节并编写了一个简单的PoC,这个PoC说明了如何在未验证的情况下,通过引发死亡蓝屏在远程触发该漏洞。几天前,我们再一次研究该漏洞,想看看除了DoS之外,这个漏洞还能产生什么影响。微软的安全公告将该漏洞描述为远程命令执行(RCE)漏洞,但目前还没有公开的PoC实现这一点。
该漏洞是一个整数溢出漏洞,发生在SMB服务器驱动程序srv2.sys的Srv2DecompressData
函数中。下面给出该函数的一个简化版本,省略了一些无关信息:
typedef struct _COMPRESSION_TRANSFORM_HEADER { ULONG ProtocolId; ULONG OriginalCompressedSegmentSize; USHORT CompressionAlgorithm; USHORT Flags; ULONG Offset; } COMPRESSION_TRANSFORM_HEADER, *PCOMPRESSION_TRANSFORM_HEADER; typedef struct _ALLOCATION_HEADER { // ... PVOID UserBuffer; // ... } ALLOCATION_HEADER, *PALLOCATION_HEADER; NTSTATUS Srv2DecompressData(PCOMPRESSION_TRANSFORM_HEADER Header, SIZE_T TotalSize) { PALLOCATION_HEADER Alloc = SrvNetAllocateBuffer( (ULONG)(Header->OriginalCompressedSegmentSize + Header->Offset), NULL); If (!Alloc) { return STATUS_INSUFFICIENT_RESOURCES; } ULONG FinalCompressedSize = 0; NTSTATUS Status = SmbCompressionDecompress( Header->CompressionAlgorithm, (PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER) + Header->Offset, (ULONG)(TotalSize - sizeof(COMPRESSION_TRANSFORM_HEADER) - Header->Offset), (PUCHAR)Alloc->UserBuffer + Header->Offset, Header->OriginalCompressedSegmentSize, &FinalCompressedSize); if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) { SrvNetFreeBuffer(Alloc); return STATUS_BAD_DATA; } if (Header->Offset > 0) { memcpy( Alloc->UserBuffer, (PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER), Header->Offset); } Srv2ReplaceReceiveBuffer(some_session_handle, Alloc); return STATUS_SUCCESS; }
从代码中可以看出,Srv2DecompressData
函数接收从客户端发来的压缩信息,分配所需内存,解压缩信息,之后,如果Offset
字段不为0,函数会将放置在压缩数据前的数据原样复制到分配的缓冲区开头。
如果仔细观察,可以发现错误的输入可能会导致代码的第20行和第31行发生整数溢出。例如,许多漏洞发布后不久给出的导致系统崩溃的PoC都使用0xFFFFFFFF
作为Offset
字段的值,这个值会导致代码的第20行发生整数溢出,从而使分配的缓冲区变小。
它同样会在之后的第31行引发另一个整数溢出,代码第30行计算出的地址与接收到的消息位置相距甚远,系统崩溃的原因就是访问了这个地址。如果代码在第31行对计算结果进行了验证,由于结果为负数,程序将提前结束,这样第30行的地址也就没有用了。
只有两个我们可以控制的字段能够造成整数溢出:OriginalCompressedSegmentSize
和Offset
,所以选择并不多。我们实验了几种组合,其中的一种引起了我们的注意:有效的Offset
值和极大的OriginalCompressedSegmentSize
值。下面看一下在这种情况下,代码执行过程中的三个主要步骤分别会发生什么:
分配:由于整数溢出,缓冲区分配的字节数要小于两个字段值之和。
解压缩:由于OriginalCompressedSegmentSize
过大,函数会认为目标缓冲区几乎为无限大,但是因为解压缩函数中的其他参数不受影响,这一步将按照预期工作。
复制:如果真的可以执行到这里的话,复制RawData操作也会按预期工作。
无论是否能执行到第三步,事情已经开始变得有趣了——我们可以在解压缩阶段触发越界写操作,因为在分配阶段分配的缓冲区小了。
从上图可以看出,我们可以使用这种方法触发任意大小和内容的溢出,但是缓冲区之外的数据究竟是什么呢?
要想回答上面的问题,就要看一下实现分配功能的函数了,即SrvNetAllocateBuffer
,下面是这个函数中一段有意思的代码:
PALLOCATION_HEADER SrvNetAllocateBuffer(SIZE_T AllocSize, PALLOCATION_HEADER SourceBuffer) { // ... if (SrvDisableNetBufferLookAsideList || AllocSize > 0x100100) { if (AllocSize > 0x1000100) { return NULL; } Result = SrvNetAllocateBufferFromPool(AllocSize, AllocSize); } else { int LookasideListIndex = 0; if (AllocSize > 0x1100) { LookasideListIndex = /* some calculation based on AllocSize */; } SOME_STRUCT list = SrvNetBufferLookasides[LookasideListIndex]; Result = /* fetch result from list */; } // Initialize some Result fields... return Result; }
从上面的代码可以看出,根据请求的字节数不同,分配函数会执行不同的操作。如果请求字节数大于约16MB,请求失败;在约1MB至约16MB之间,使用SrvNetAllocateBufferFromPool
函数进行分配;小于约1MB则返回后备列表中的空间。
注:函数中还使用了一个SrvDisableNetBufferLookAsideList
标志,同样会影响函数的功能,这个标志由未记录的注册表设置,默认禁用,因此在这里不做考虑。
后备列表是为驱动程序保留的一组可重用,固定大小的缓冲区,其功能之一就是为管理缓冲区定义了一组自定义的分配和释放函数。通过查看SrvNetBufferLookasides
数组的引用,可以发现它是在SrvNetCreateBufferLookasides
函数中被初始化的,从初始化的过程中,我们有以下几点发现:
SrvNetBufferLookasideAllocate
,它调用了SrvNetAllocateBufferFromPool
函数;>>> [hex((1 << (i + 12)) + 256) for i in range(9)] [‘0x1100’, ‘0x2100’, ‘0x4100’, ‘0x8100’, ‘0x10100’, ‘0x20100’, ‘0x40100’, ‘0x80100’, ‘0x100100’]
这也符合我们上面的发现:大于0x100100
字节的分配请求不使用后备列表进行分配。
所以说每个分配请求最终都调用了SrvNetBufferLookasideAllocate
函数,下面我们来看一下这个函数。
SrvNetBufferLookasideAllocate
函数通过调用ExAllocatePoolWithTag
从NonPagedPoolNx
池中分配缓冲区,并用数据填充其中的一些结构,下图表示了已分配缓冲区的分布情况:
唯一与我们这次研究有关的位置就是User buffer
以及ALLOCATION_HEADER
结构。可以看出,如果在User buffer
发生溢出,最终就可以覆盖ALLOCATION_HEADER
结构,看起来十分方便。
一开始我们认为,因为在SmbCompressionDecompress
函数调用后,有以下代码:
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) { SrvNetFreeBuffer(Alloc); return STATUS_BAD_DATA; }
在条件判断中,OriginalCompressedSegmentSize
是一个极大值,而FinalCompressedSize
表示解压缩后的真实字节数,因此条件符合,会执行SrvNetFreeBuffer
函数,返回STATUS_BAD_DATA
,程序会执行失败。因此我们分析了SrvNetFreeBuffer
函数,想要把参数替换为其他值,让释放函数尝试释放它,之后再引用这个值时可以实现use-after-free或者类似漏洞。但让我们惊讶的是,崩溃竟然发生在memcpy
函数,这挺让人高兴的,因为我们本来没想到程序会执行到这里,无论如何,还是检查一下发生这种情况的原因。可以在SmbCompressionDecompress
函数中找到解释:
NTSTATUS SmbCompressionDecompress( USHORT CompressionAlgorithm, PUCHAR UncompressedBuffer, ULONG UncompressedBufferSize, PUCHAR CompressedBuffer, ULONG CompressedBufferSize, PULONG FinalCompressedSize) { // ... NTSTATUS Status = RtlDecompressBufferEx2( ..., FinalUncompressedSize, ...); if (Status >= 0) { *FinalCompressedSize = CompressedBufferSize; } // ... return Status; }
从以上代码可以看出,如果解压缩成功,函数会直接把CompressedBufferSize
的值赋值给FinalCompressedSize
,而CompressedBufferSize
就是OriginalCompressedSegmentSize
。根据这一步的赋值操作,以及已分配缓冲区的分布情况,我们可以很容易地利用这个漏洞。
因为程序一直执行到复制RawData的步骤,我们先回顾一下这部分的代码:
memcpy( Alloc->UserBuffer, (PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER), Header->Offset);
目标地址Alloc->UserBuffer
是从ALLOCATION_HEADER
结构中获取的,可以在解压缩步骤中被我们覆盖重写,而缓冲区的内容以及大小即RawData也由我们控制,至此,我们就可以实现内核上的远程任意内存覆盖(Remote write-what-where)。
我们用Python实现了一个Write-What-Where CVE-2020-0796 Exploit,代码简单直接,是根据maxpl0it的CVE-2020-0796 DoS PoC写出的。
那么我们可以用这个exploit做些什么呢?显然我们可以使系统崩溃,或者虽然还没有找到实际的方法,但是我们也可能触发远程代码执行。如果我们在本地使用该exploit,可以泄露额外的信息,那么就可以用来提升本地权限,目前已经有多种技术证明了这种方法的可行性。
我们使用的第一种技术来自2017年Morten Schenk在Black Hat上的演讲,此技术会覆盖重写win32kbase.sys
驱动程序的.data
段中的一个函数指针,然后从用户模式中调用适当的函数来实现代码执行。j00ru在2018年的WCTF上写了一篇关于如何使用此技术的优秀文章,并且提供了exploit代码。我们对这段代码进行了修改,并应用到我们的任意内存覆盖exploit中,但是并不起作用,因为处理SMB消息的线程不是GUI线程,因此不会映射win32kbase.sys
文件,也就无法使用此技术(除非能找到一种方法把它变成GUI线程,而我们对此并无研究)。
我们最终使用了一个cesarcer在2012年的Black Hat演讲“轻松实现本地Windows内核利用”中提出的著名的技术。这项技术会用NtQuerySystemInformation(SystemHandleInformation)
API泄露并覆盖当前进程令牌地址,授予当前进程令牌权限并可用于之后的权限提升。 Bryan Alexander (dronesec)和Stephen Breen (breenmachine)在“EoP中的令牌权限滥用”中给出了多种使用不同令牌权限进行权限提升的方法。
根据Alexandre Beaulieu在他的文章“用任意写实现权限提升”中给出的代码,我们重写了自己的exploit。通过将一个DLL文件注入到winlogon.exe
中,我们修改了进程的令牌权限并实现了权限提升,使用这个DLL文件是为了启动一个有特权模式的cmd.exe
程序。你可以在这里获取完整的本地权限提升PoC,此PoC仅用于科研与防御研究。
在这篇文章中,我们证明了CVE-2020-0796漏洞可以用来实现本地权限提升,但是注意,我们的exploit只处于中等的完整性级别,因为它依赖的API调用在更低的完整性级别中不可用。如果进行更深的研究,或许我们可以实现更强的功能,毕竟已分配的缓冲区中还有很多区域可以被覆盖,也许其中的某个区域就可以帮助我们实现更多有趣的功能,例如远程代码执行。
我们建议将服务器和主机升级到Windows的最新版本,如果可能的话,在更新完成前关闭445端口。事实上,无论是否有CVE-2020-0796漏洞,我们都建议在可能的情况下启用主机隔离。
尽可能禁用SMBv3.1.1的压缩功能,以避免触发此漏洞。不过还是建议在可能的情况下进行完整的更新。