利用SMBGhost (CVE-2020-0796)实现本地权限提升

2020-04-13 约 347 字 预计阅读 2 分钟

声明:本文 【利用SMBGhost (CVE-2020-0796)实现本地权限提升】 由作者 zoemur**** 于 2020-04-13 08:39:09 首发 先知社区 曾经 浏览数 141 次

感谢 zoemur**** 的辛苦付出!

原文: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的压缩功能,以避免触发此漏洞。不过还是建议在可能的情况下进行完整的更新。

关键词:[‘安全技术’, ‘漏洞分析’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now