利用SMBGhost (CVE-2020-0796)实现本地权限提升
2020-04-13 09:39:09 Author: xz.aliyun.com(查看原文) 阅读量:365 收藏

原文: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行的地址也就没有用了。

选择溢出的位置

只有两个我们可以控制的字段能够造成整数溢出:OriginalCompressedSegmentSizeOffset,所以选择并不多。我们实验了几种组合,其中的一种引起了我们的注意:有效的Offset值和极大的OriginalCompressedSegmentSize值。下面看一下在这种情况下,代码执行过程中的三个主要步骤分别会发生什么:

  1. 分配:由于整数溢出,缓冲区分配的字节数要小于两个字段值之和。

  2. 解压缩:由于OriginalCompressedSegmentSize过大,函数会认为目标缓冲区几乎为无限大,但是因为解压缩函数中的其他参数不受影响,这一步将按照预期工作。

  3. 复制:如果真的可以执行到这里的话,复制RawData操作也会按预期工作。

无论是否能执行到第三步,事情已经开始变得有趣了——我们可以在解压缩阶段触发越界写操作,因为在分配阶段分配的缓冲区小了。

从上图可以看出,我们可以使用这种方法触发任意大小和内容的溢出,但是缓冲区之外的数据究竟是什么呢?

深入SrvNetAllocateBuffer函数

要想回答上面的问题,就要看一下实现分配功能的函数了,即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函数;
  • 通过Python的快速计算,9个后备列表的大小分别为:
>>> [hex((1 << (i + 12)) + 256) for i in range(9)]
[0x1100, 0x2100, 0x4100, 0x8100, 0x10100, 0x20100, 0x40100, 0x80100, 0x100100]

这也符合我们上面的发现:大于0x100100字节的分配请求不使用后备列表进行分配。

所以说每个分配请求最终都调用了SrvNetBufferLookasideAllocate函数,下面我们来看一下这个函数。

SrvNetBufferLookasideAllocate函数及其分配的缓冲区分布

SrvNetBufferLookasideAllocate函数通过调用ExAllocatePoolWithTagNonPagedPoolNx池中分配缓冲区,并用数据填充其中的一些结构,下图表示了已分配缓冲区的分布情况:

唯一与我们这次研究有关的位置就是User buffer以及ALLOCATION_HEADER结构。可以看出,如果在User buffer发生溢出,最终就可以覆盖ALLOCATION_HEADER结构,看起来十分方便。

覆盖 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调用在更低的完整性级别中不可用。如果进行更深的研究,或许我们可以实现更强的功能,毕竟已分配的缓冲区中还有很多区域可以被覆盖,也许其中的某个区域就可以帮助我们实现更多有趣的功能,例如远程代码执行。

POC源码

防御与修复

  1. 我们建议将服务器和主机升级到Windows的最新版本,如果可能的话,在更新完成前关闭445端口。事实上,无论是否有CVE-2020-0796漏洞,我们都建议在可能的情况下启用主机隔离。

  2. 尽可能禁用SMBv3.1.1的压缩功能,以避免触发此漏洞。不过还是建议在可能的情况下进行完整的更新。


文章来源: http://xz.aliyun.com/t/7537
如有侵权请联系:admin#unsafe.sh