内核也太难了,主要讲述大NonPagedPool的溢出利用
公众号:https://mp.weixin.qq.com/s/XjaPdNwqABFqDZsZTDtZJg
或许我们的公众号会有更多你感兴趣的内容
【复现】Windows10 内核池溢出
前置知识:
- windows内核调试
- windows内核提权基础
- 简单的windows驱动编写(hello world级别)
- linux pwn堆溢出利用方式
- 一点点数据结构的知识(双向链表)
文章讲述并复现在Windows高版本内核中NonPagedPoolNx溢出的利用方法
目录
[toc]
利用方式
很多资料都是直接翻译外文文献,翻译质量差,没有直接的实操,并且随着windows的更新,比较缺乏现代windows 10\11的、比较易学的攻击方式。这里使用:Windows-Non-Paged-Pool-Overflow-Exploitation[1]作为基础的讲解,原文中的图示个人感觉还是讲的不够透彻,这里个人借着原文重新讲述下。
命名管道介绍
这是windows提供的用于进程间通讯的一种机制。首先是**服务端(server)**创建命名管道,**客户端(client)**使用CreateFile
连接到服务端。双方可以使用ReadFile
和WriteFile
进行读、写的通讯。这种利用方式非常常见,比如说在cobaltstrike
的木马中就是用到了这种通讯,具体例子可以参考公众号的文章: 【免杀】使用CobaltStrike的外置监听器绕过检测[2]。
当管道成功建立后,底层驱动程序(npfs.sys
)会在上下文控制块 (CCB) 中创建两个队列,每个队列对应一个CCB队列。当有消息写入时,比如**服务端想客户端发送消息(WriteFile
)**时,会在队列中创建如下结构体,后文简称为dqe
1 | struct DATA_QUEUE_ENTRY { |
当**客户端开始接收消息(ReadFile
)**时,就会从队列中释放掉该消息

-
NextEntry: Windows中的经典 双向循环链表,
LIST_ENTRY
中flink
指向下一个entry
,blink
指向上一个。当结构体被释放时(如执行ReadFile
),会进行相邻结构体链表之间的重新连接,也就是数据结构中双向链表中的删除操作。注意的是,在windows 10中新增了校验操作1
entry->Flink->Blink!=entry
这样可以避免溢出时覆盖
entry->flink
的blink
,造成的劫持 -
SecurityContext: 安全上下文,主要是模仿客户端的行为,比如客户端访问权限等,在这里并不重要。
-
EntryType:
DATA_QUEUE_ENTRY
的类型,一般分为两种:buffered
和unbuffered
-
buffered
:EntryType=0
,DATA_QUEUE_ENTRY
中申请的buf
大小足够存放UserData
,读取时直接从UserData
处复制 -
unbuffered
:EntryType=1
,DATA_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)
漏洞代码
这里使用:
这段代码的64位编译进行讲解,同时我增加了一段打印来方便调试
1 | NTSTATUS Al20c(size_t Size) |
在for
循环中有一个明显的 off by one 漏洞(应当是i < Size
),可以溢出到下一个区块一个字节,并修改为0x20
测试环境是

具体环境搭建可以参考:从零探索现代windows内核栈溢出-以HEVD练习为例(上)[3]
如何完成提权
这里就是利用任意地址读写来对System
的token
复制到当前进程
任意地址读实现
假设有如下布局
AAAA、BBBB、CCCC三个DATA_QUEUE_ENTRY
是逻辑上的相邻关系,但是在内存上,紫色page与BBBB相邻,所以BBBB->Flink
低位被溢出了,这也是在linux pwn的堆利用中很常见的off by one
利用手法。这使得BBBB->Flink
指向了CCCC+0x20
的位置,那么我们就可以提前在我们可控的CCCC->UserData
里面构建fake EntryType
、fake 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 | //... |
USER_DATA_ENTRY_ADDR
计算出来的值是**0xfd000000000
,在Creating the RIGHT entries时,申请的dqe中,dqe->quota=0xfd0
,dqe->DataSize=0xfd0
,根据小端序在内存中的排列方式从右到左,dqe->quota
和临近的32位大小全为0的dqe->EntryTpye
的值,合成fake->flink
,而且指向0xfd000000000
**。分配变为:
相关的内存信息:


参考最初始的布局图,如果我们想要读取的内容长度超过了DataSize_AAAA+DataSize_BBBB+DataSize_Fake_CCCC
,那么就会从FakeCCCC->flink
即**0xfd000000000
当作一个DATA_QUEUE_ENTRY
,来满足我们的读取。这个时候exp中最开始的VritualAlloc
就起到了关键作用,因为他申请到了0xfd000000000
这一地址,即使这个地址不是在内核0xfd000000000中,而是在当前的进程中,我们就可以在这里伪造DATA_QUEUE_ENTRY
,并且使用unbuffered
类型来利用Irp
实现内核的任意地址读取**。
1 | void PrepareDataEntryForRead(DATA_QUEUE_ENTRY* dqe, IRP* irp, uint64_t read_address) { |
如何读取到当前进程的Token地址?
与常规思路类似,我们依旧是找到当前进程的EPROCESS
。首先基于此我们拥有了任意地址读,那么就可以泄露出一个正常的DATA_QUEUE_ENTRY
,比如和CCCC物理地址相邻的dqe,我们称为DDDD(如上图)。exp中的DDDD是这样申请的
1 | DWORD WINAPI ThreadedWriter(void* arg) { |

DDDD->Irp
结构具体如下,详细可见IRP 结构 (wdm.h)[4]
1 | 1: kd> dt nt!_IRP |



这里又引申小问题:为什么要使用CreateThread
来进行新的dqe?这就要提到ThreadListEntry
的作用了
当一个线程发起异步 I/O 操作时,内核会将 IRP 插入到线程的
ThreadListEntry
链表中。线程可以通过遍历该链表检查是否有未完成的 I/O 请求。
这里创建线程来创建起到的就是 线程发起异步 I/O 操作 的作用。在 Windows 内核中,每个线程都由一个 ETHREAD
(Executive Thread)结构体表示,其中包含一个 IrpList
字段,用于管理该线程的所有 挂起(Pending)I/O 请求(即未完成的 IRP)
我们查看这个Irp所属的线程是那个_ETHREAD
1 | 0: kd> dt nt!_IRP ffffbe0f`9d473c30 Tail.Overlay.Thread |

_ETHREAD
结构体如下

如果你了解过windows内核提权的方法的话,如从零探索现代windows内核栈溢出-以HEVD练习为例(下)[5],你就会了解到在栈溢出中我们使用的是gs:[188h]
指向的是一个_KTHREAD
结构体,通过_KTHREAD
找到_EPROECSS
,然后找到了当前进程的_EPROCESS
,而且就可以通过遍历得到 System 的_EPROCESS
。
1 | 0: kd> !thread 0xffffbe0f9d0cb080 |
之后通过``EPROCESS获得自身
ActiveProcessLinks`,同时向前/向后查找,找到pid=4的进程,则该进程就为System,之后就是根据偏移读取Token
1 | uint64_t GetProcessById(uint64_t first_process, uint64_t pid) { |
任意地址写实现
根据前文的描述,当客户端读取服务端发送的消息时,利用甚于配额分配机制,Quota<Datasize
,就会触发对应dqe的Irp,将irp->AssociatedIrp
的值写入到irp->UserBuffer
中。那么我们可以伪造Irp,然后执行ReadFile
触发Irp。但是Irp对于系统来说十分的严格,[1]中利用了任意读复制一个合法的Irp,然后改造。原文提醒:
务必使用非缓冲条目来保存伪造的 IRP,因为它很可能会在 IofCompleteRequest 调用结束时被释放
1 | DATA_QUEUE_ENTRY: |
相关代码如下
1 | void PrepareWriteIRP(IRP* irp, PVOID thread_list, PVOID source_address, PVOID destination_address) { |
回忆前文命名管道介绍中的描述:
unbuffered
:EntryType=1
,DATA_QUEUE_ENTRY
中申请的buf
大小只存放DATA_QUEUE_ENTRY
,读取时要调用IRP
来实现读取。
那么对PrepareWriteIRP
函数生成的Irp这块内存的读写都要通过unbuffered->Irp
进行


如果Irp执行就是从0xffffe20fa485e4f8
的值复制到0xffffe20faab3b538
,并且0xffffe20fa485e4f8
的值就为Token的值
然后设置qde->Irp
后触发
1 | void PrepareDataEntryForWrite(DATA_QUEUE_ENTRY* dqe, IRP* irp, uint32_t size) { |
ReadFile
时,依旧会从AAAA开始读取8字节,同时AAAA->quota-=8
之后会调整队列中dqe的quota大小,正常来说对于我们伪造的dqe的quota<size
的,这个时候就会调用Irp,然后quota+=8
;但是我们修改了CCCC->flink=DDDD
,即指向0xfd000000000
,并且我们在DDDD中伪造了Irp,那么就会调用该Irp。
最后成功复制token完成提权

引用
[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