Windows内核驱动漏洞利用实战:从堆溢出到任意读写与权限提升
1. 项目概述:一次从用户态到内核态的“越狱”之旅
最近在复盘一些经典的CTF赛题,尤其是那些涉及操作系统内核安全的题目,总能带来不少启发。DEFCON CTF Finals的题目向来以高难度和贴近实战著称,30届决赛中的这道《shadow》内核驱动题就是一个绝佳的例子。它不仅仅是一个简单的堆溢出漏洞,而是一个精心设计的、需要选手串联起用户态漏洞利用、内核驱动逆向分析、堆风水布局以及最终权限提升的完整攻击链。这道题考察的远不止是写一个exploit那么简单,更是对漏洞原理、操作系统机制和调试技巧的深度理解。如果你对Windows内核安全、驱动漏洞利用感兴趣,或者正在准备高水平的CTF赛事,那么跟随我一起拆解这道《shadow》,无疑是一次极好的实战演练。我们将从漏洞点定位开始,一步步分析如何将一次看似普通的堆溢出,转化为夺取系统最高权限的利器。
2. 题目环境与核心组件逆向分析
2.1 驱动功能与交互接口剖析
拿到一个内核驱动题目,第一步永远是搞清楚它“是干什么的”以及“怎么跟它说话”。通过静态逆向分析驱动文件shadow.sys,我们可以快速定位到其派遣例程(Dispatch Routine)。通常,驱动会通过IoCreateDevice创建设备对象,并通过IoCreateSymbolicLink创建符号链接,方便用户态程序通过CreateFile和DeviceIoControl与之通信。
在《shadow》中,驱动创建了一个名为\Device\Shadow的设备对象,并导出了符号链接\DosDevices\Shadow。这意味着用户态程序可以通过\\.\Shadow这个路径来打开设备句柄。通过分析IRP_MJ_DEVICE_CONTROL的处理函数,我们发现了驱动支持的几个IO控制码(IOCTL)。这是驱动与外界交互的“菜单”。
经过逆向,主要的IOCTL功能包括:
- IOCTL_ALLOCATE_POOL: 请求驱动在内核池(Kernel Pool)中分配指定大小的内存块。驱动会返回一个用户态的句柄(Handle)或标识符(ID)来代表这块内核内存,后续操作都基于这个ID。
- IOCTL_FREE_POOL: 根据提供的ID,释放之前分配的内核内存块。
- IOCTL_READ_POOL: 根据ID和偏移,从分配的内核内存中读取数据到用户态缓冲区。
- IOCTL_WRITE_POOL: 根据ID和偏移,将用户态缓冲区的数据写入到指定的内核内存中。
这看起来像一个简单的“内核内存笔记本”服务。用户态程序可以申请、释放、读、写内核内存。然而,魔鬼藏在细节里,尤其是在“写”这个操作上。
2.2 漏洞点定位:失控的“写入”操作
漏洞的核心出现在IOCTL_WRITE_POOL的处理逻辑中。我们来看一下伪代码还原的关键部分:
// 假设驱动维护了一个全局数组 `PoolEntries[]` 来管理分配的内存块 typedef struct _POOL_ENTRY { PVOID KernelBuffer; // 指向内核池内存的指针 SIZE_T Size; // 分配的大小 BOOLEAN IsUsed; // 是否已被分配 } POOL_ENTRY; POOL_ENTRY PoolEntries[MAX_POOL_ENTRIES]; NTSTATUS HandleIoctlWrite(PIRP Irp, PIO_STACK_LOCATION IrpSp) { PWRITE_REQUEST WriteReq = (PWRITE_REQUEST)Irp->AssociatedIrp.SystemBuffer; ULONG PoolId = WriteReq->PoolId; ULONG Offset = WriteReq->Offset; PVOID UserBuffer = WriteReq->UserBuffer; SIZE_T UserBufferSize = WriteReq->Size; // 1. 参数基本检查 if (PoolId >= MAX_POOL_ENTRIES || !PoolEntries[PoolId].IsUsed) { return STATUS_INVALID_PARAMETER; } // 2. 检查偏移是否越界(这里出现了问题!) if (Offset >= PoolEntries[PoolId].Size) { return STATUS_INVALID_PARAMETER; } // 3. 检查用户缓冲区大小(这里也出现了问题!) if (UserBufferSize > PoolEntries[PoolId].Size) { return STATUS_INVALID_PARAMETER; } // 4. 执行拷贝 RtlCopyMemory( (PUCHAR)PoolEntries[PoolId].KernelBuffer + Offset, // 目标地址:内核缓冲区起始地址 + 偏移 UserBuffer, // 源地址:用户缓冲区 UserBufferSize // 拷贝长度:用户指定的大小 ); return STATUS_SUCCESS; }漏洞就藏在第2步和第3步的检查逻辑中。我们逐条分析:
- 偏移检查 (
Offset >= Size):这个检查是正确的。它防止了写入的起始位置超出缓冲区末尾。 - 缓冲区大小检查 (
UserBufferSize > Size):这个检查单独看也是正确的,它防止了要拷贝的数据总量超过缓冲区总容量。 - 致命的组合:问题在于,这两个检查是独立进行的,没有考虑
Offset + UserBufferSize这个组合条件。
漏洞原理:假设我们分配了一块大小为 100 字节的内核缓冲区 (Size = 100)。
- 合法操作:
Offset=0, UserBufferSize=100,拷贝100字节到开头。 - 合法操作:
Offset=90, UserBufferSize=10,拷贝10字节到末尾。 - 漏洞触发操作:
Offset=90, UserBufferSize=20。- 检查1:
Offset (90) >= Size (100)?否,通过。 - 检查2:
UserBufferSize (20) > Size (100)?否,通过。 - 实际拷贝:从内核缓冲区第90字节开始,写入20字节数据。这会覆盖从第90字节到第109字节的范围,而我们的缓冲区只到第99字节。多出来的10字节就溢出了缓冲区边界,覆盖了紧随其后的内核内存。
- 检查1:
这就是一个典型的**堆缓冲区溢出(Heap Buffer Overflow)**漏洞。攻击者可以控制溢出数据的长度和内容(UserBuffer),以及溢出发生的相对位置(Offset)。
注意:在实际的
shadow.sys驱动中,可能还存在其他细微差别,例如使用的内核池类型(分页/非分页)、分配标志、以及结构体对齐等,这些都会影响堆布局和后续利用,但核心漏洞模式就是上述的“偏移+长度”组合越界。
3. 内核堆风水与溢出目标规划
3.1 Windows内核池管理浅析
要在内核中成功利用堆溢出,不能像在用户态那样“盲打”。我们必须了解Windows内核池(Pool)的管理机制。内核池是供驱动和内核组件动态分配内存的区域,类似于用户态的堆。
几个关键概念:
- 池类型:主要分非分页池(NonPagedPool,执行中断级别高的代码使用)和分页池(PagedPool)。驱动通过
ExAllocatePoolWithTag等API分配。 - 块与头:内核池分配的基本单位是“块”。每个块前面有一个
_POOL_HEADER结构,包含了块大小、池类型、分配标签(Tag)等信息。溢出时,我们首先破坏的就是相邻块的池头。 - Lookaside List 与 Freelist:为了提高性能,内核维护了空闲列表。频繁分配释放固定大小内存会使用Lookaside List,它是一个后进先出(LIFO)的单链表。溢出可以篡改链表指针,实现任意地址写。
- Session Pool:这是一个特殊的池,用于会话空间(与用户登录会话相关)的内存分配。某些对象(如窗口站、桌面)会在这里分配,有时利用起来更方便。
对于《shadow》这道题,我们需要通过反复调用IOCTL_ALLOCATE_POOL和IOCTL_FREE_POOL,来“塑造”内核池的布局,让我们可控的“受害者”缓冲区(Vulnerable Buffer)紧挨着一个对我们有用的“目标”对象(Target Object)。然后,通过溢出受害者缓冲区,来篡改目标对象的内容。
3.2 目标对象的选择与布局技巧
选择什么作为目标对象是利用的关键。一个理想的目标对象需要满足:
- 可预测位置:我们能通过堆风水让它大概率出现在溢出缓冲区的后面。
- 结构可控:其内部有我们关心的数据指针或函数指针。
- 触发路径可控:在篡改其内容后,有确定的、可触发的代码路径会使用被篡改的成员,从而改变程序执行流。
在Windows内核中,经典的利用目标包括:
_POOL_HEADER:篡改相邻空闲块的池头,特别是其中的PreviousSize和PoolType,可能造成池解链时的混淆,进而导致任意地址释放(Free)或分配(Alloc)。但这种方式稳定性相对较低。_FILE_OBJECT:文件对象结构体庞大,包含多个函数指针表(如FsContext,FsContext2以及SectionObjectPointer等)。如果能让一个文件对象紧邻溢出缓冲区,并溢出修改其内部的某个函数指针(例如,修改其DriverObject->MajorFunction[IRP_MJ_WRITE]虽然不直接存在于_FILE_OBJECT中,但可以通过关联对象间接影响),当后续对该文件进行IO操作时,就可能跳转到我们控制的地址。但文件对象生命周期管理复杂,布局难度大。- 驱动或设备对象自身相关的管理数据结构:这是本题更可能的路径。既然驱动自己管理着一个
PoolEntries数组,这个数组本身也是分配在内核池中的。如果我们能通过溢出,修改PoolEntries数组中某个表项的KernelBuffer指针或Size字段,那么后续通过IOCTL_READ_POOL或IOCTL_WRITE_POOL对该ID的操作,就会变成对我们所篡改的指针指向的内存的读写。这就将一次内存破坏,升级为了一个稳定的任意地址读/写原语(Arbitrary Read/Write Primitive)。
布局实战思路:
- 喷涌(Spray):大量分配特定大小的内核缓冲区(比如512字节),目的是让内核池的分配器从新的页面(Page)中分配,让这些对象在内存中连续排列。
- 占位(Hole):有策略地释放其中一些缓冲区,在连续的内存区域中制造出“空洞”(Freed Chunks)。
- 精准投放:然后依次分配我们的“受害者缓冲区”(V)和预期的“目标对象”(T)。由于堆分配器倾向于复用最近释放的、大小合适的空闲块(LIFO特性),我们有很大的概率让V和T在内存中相邻,且V在T之前。
- 验证布局:这通常是最难的一步。在没有信息泄露的情况下,我们可能只能依靠概率。但如果题目像《shadow》这样,很可能通过
IOCTL_READ_POOL提供了某种形式的“信息泄露”,允许我们读取分配缓冲区的内容。我们可以通过在喷涌的缓冲区中写入特定的“魔术字”(Magic Bytes),然后读取所有缓冲区,通过比对来推断出内存的相对布局,甚至计算出绝对地址。
实操心得:内核堆风水成功与否,很大程度上取决于对内核池分配器行为的理解。在实战中,需要编写脚本反复测试,统计相邻的概率。有时需要结合不同大小的分配来对抗池的随机化(如
Low Fragmentation Heap特性)。在CTF环境中,由于系统是干净的、重启的,成功率往往比真实的多任务环境高得多。
4. 漏洞利用链的构建:从任意读写到权限提升
4.1 构建强大的任意地址读写原语
假设通过精心的堆风水,我们成功让PoolEntries数组中的某个表项(假设索引为target_id)紧跟在我们的溢出缓冲区(假设索引为vuln_id)之后。POOL_ENTRY结构体大致如下:
struct POOL_ENTRY { void* kernel_buffer; // 8字节指针 size_t size; // 8字节大小 bool is_used; // 1字节布尔值(可能带对齐) };当我们从vuln_id的缓冲区进行溢出时,溢出的数据就会覆盖target_id这个结构体的内容。
利用步骤:
- 溢出篡改:通过
IOCTL_WRITE_POOL对vuln_id发起写入,设置Offset为vuln_id缓冲区的大小减去一个小的值(例如size - 8),然后写入精心构造的数据。这部分数据会溢出,并覆盖target_id结构体的kernel_buffer指针。 - 重定向指针:我们将
target_id的kernel_buffer指针覆盖为我们想要读写的任意内核地址,比如一个系统全局变量的地址、一个函数指针表的地址等。同时,为了后续操作不崩溃,我们可能还需要合理设置size和is_used字段(例如,设置一个较大的size,并确保is_used为真)。 - 激活原语:现在,当我们使用
IOCTL_READ_POOL或IOCTL_WRITE_POOL对target_id进行操作时,驱动会使用被我们篡改后的kernel_buffer指针。于是:IOCTL_READ_POOL(target_id, offset):会将篡改后的指针 + offset处的内核内存内容读回用户态。IOCTL_WRITE_POOL(target_id, offset, data):会将用户态的data写入到篡改后的指针 + offset处的内核内存。
至此,我们获得了在内核空间进行任意地址读写的强大能力。这比单纯的代码执行更灵活,是内核利用中的“瑞士军刀”。
4.2 权限提升的终极目标:篡改访问令牌
在Windows中,进程的权限由其访问令牌(Access Token)决定。系统最高权限的令牌是SYSTEM令牌。权限提升的核心思想,就是将当前进程(或线程)的令牌,替换成一个高权限的令牌(通常是SYSTEM进程的令牌)。
每个进程的_EPROCESS结构体中都有一个指向其令牌的指针(Token)。每个线程的_ETHREAD结构体中也有一个可选的线程令牌指针。系统在检查权限时,会使用线程令牌(如果存在),否则使用进程令牌。
利用任意读写实现提权:
- 泄露内核地址:首先,我们需要突破KASLR(内核地址空间布局随机化)。利用任意读原语,我们可以从一些已知的内核数据结构中读取指针值。一个经典且稳定的目标是读取
PsInitialSystemProcess这个导出的全局变量。它指向系统第一个进程(System进程)的_EPROCESS。我们可以用IOCTL_READ_POOL读取这个地址。// 获取 PsInitialSystemProcess 地址 (需要事先通过驱动或别的方式获取该符号的偏移) ULONG64 psInitialSystemProcessAddr = BASE_KERNEL_ADDRESS + OFFSET_PsInitialSystemProcess; ULONG64 systemEprocess = read_qword(psInitialSystemProcessAddr); - 遍历进程链表:
_EPROCESS结构体通过ActiveProcessLinks双向链表连接所有进程。这是一个LIST_ENTRY结构。我们可以从System进程的_EPROCESS开始,用任意读原语遍历这个链表,寻找我们当前进程的_EPROCESS。如何识别呢?每个_EPROCESS里都有唯一的UniqueProcessId(PID)。我们可以用GetCurrentProcessId()获取自己的PID,然后在链表中对比。ULONG64 currentEprocess = systemEprocess; ULONG64 nextLink = systemEprocess + OFFSET_ActiveProcessLinks; ULONG64 currentPid = 0; do { // 读取 LIST_ENTRY.Flink 得到下一个 _EPROCESS 的链表地址 ULONG64 nextEprocessList = read_qword(nextLink); // 计算下一个 _EPROCESS 的基址 (链表在结构体内) currentEprocess = nextEprocessList - OFFSET_ActiveProcessLinks; // 读取该 _EPROCESS 的 PID currentPid = read_dword(currentEprocess + OFFSET_UniqueProcessId); nextLink = currentEprocess + OFFSET_ActiveProcessLinks; } while (currentPid != myPid); - 窃取SYSTEM令牌:找到当前进程的
_EPROCESS后,再找到System进程的_EPROCESS。读取System进程的Token值(注意,Token字段的低几位是引用计数等标志位,需要屏蔽)。ULONG64 systemToken = read_qword(systemEprocess + OFFSET_Token); systemToken &= ~TOKEN_FLAGS_MASK; // 清除标志位,获取纯指针值 - 覆盖当前进程令牌:最后,使用任意写原语,将我们当前进程
_EPROCESS中的Token值,替换为System进程的Token值。write_qword(currentEprocess + OFFSET_Token, systemToken); - 享受特权:操作完成后,返回到用户态。此时,我们进程的权限已经提升为
SYSTEM。可以简单地通过system(“cmd.exe”)或CreateProcess启动一个具有SYSTEM权限的命令行窗口,从而完全控制系统。
5. 实战调试与漏洞利用开发笔记
5.1 双机内核调试环境搭建
分析内核漏洞,静态逆向只是第一步,动态调试是无可替代的。我们需要搭建一个内核调试环境。
环境准备:
- 调试机(Host):运行WinDbg Preview(推荐)或旧版WinDbg的物理机或虚拟机。
- 靶机(Target/Victim):运行有漏洞驱动
shadow.sys的虚拟机(如VMware Workstation 或 Hyper-V)。 - 符号路径:在调试机上配置微软的符号服务器(
srv*https://msdl.microsoft.com/download/symbols),这是理解内核数据结构的关键。
配置步骤(以VMware为例):
- 在靶机虚拟机配置中启用调试:关闭靶机,编辑虚拟机设置,在“高级”选项中添加配置行
debugStub.listen.guest32 = “TRUE”(针对32位)或debugStub.listen.guest64 = “TRUE”(针对64位)。这会在本地回环(127.0.0.1)的某个端口(默认8832 for 32-bit, 8864 for 64-bit)开启一个调试服务器。 - 在调试机WinDbg中连接:以管理员身份运行WinDbg Preview,选择
Attach to kernel->Network。地址填127.0.0.1:8864(64位靶机)。如果调试机和靶机不是同一台物理机,则填靶机的IP地址。 - 加载驱动与符号:连接成功后,在靶机上加载
shadow.sys驱动(例如使用sc create和sc start)。在WinDbg中,使用.reload命令重新加载符号。如果驱动有私有符号(PDB文件),需要将其路径添加到符号路径中。
踩坑记录:最常见的连接失败原因是防火墙或杀毒软件拦截。确保调试端口(8832/8864)在防火墙中开放。如果使用本地回环,确保WinDbg和虚拟机在同一台物理主机上。有时VMware的“debugStub”配置不生效,可以尝试改用命名管道(
\\.\pipe\com_1)或串行端口(COM)方式进行调试,虽然更复杂但更稳定。
5.2 利用脚本开发与稳定性优化
利用脚本通常用C/C++或Python(配合ctypes)编写。主要步骤包括:
- 与驱动通信:
CreateFile打开\\.\Shadow,使用DeviceIoControl发送IOCTL。 - 堆风水函数:封装
allocate_pool,free_pool,read_pool,write_pool等函数。在风水阶段,可能需要成百上千次地调用分配和释放。 - 信息泄露与地址计算:如果题目提供了信息泄露(例如,分配的缓冲区初始内容包含某个内核指针),编写代码解析这些数据,计算基址。
- 任意读写原语函数:在成功篡改
POOL_ENTRY后,封装arbitrary_read和arbitrary_write函数。 - 提权逻辑:实现遍历进程链表、窃取令牌、覆盖令牌的代码。
- 启动特权Shell:最后调用
CreateProcess或system启动cmd.exe。
稳定性优化技巧:
- 错误处理与重试:堆风水不一定一次成功。脚本应包含验证步骤(例如,尝试使用篡改后的
target_id读取一个已知地址的值,如PsInitialSystemProcess,看是否返回合理的非零值)。如果失败,则清理现场(释放所有分配的块),重新开始风水流程。 - 应对池隔离:现代Windows版本(如Win10 19H1之后)引入了
Pool Isolation,将不同标签(Tag)的内存分配到不同的页面,这增加了让两个不同对象相邻的难度。在CTF环境中,可能使用的是较旧或特意配置的系统。如果遇到,可能需要寻找共享同一标签的对象,或者利用其他类型的漏洞(如UAF)来绕过。 - 令牌引用计数:直接覆盖
Token指针可能会破坏引用计数,导致系统不稳定或蓝屏(BSOD)。更稳健的做法是复制整个_TOKEN对象,或者使用内核API(如PsReferencePrimaryToken)来正确增加引用计数。但在CTF的“一击必杀”场景中,直接覆盖往往是可行的。
6. 拓展思考与防御启示
6.1 漏洞的根源与安全编程
《shadow》这道题的漏洞根源在于不完整的边界检查。开发者在编写IOCTL_WRITE_POOL时,只检查了偏移和长度各自是否小于缓冲区大小,却没有检查它们的和。这是安全编程中一个非常经典的错误模式。
正确的检查逻辑应该是:
// 正确的组合检查 if (Offset > PoolEntries[PoolId].Size || UserBufferSize > PoolEntries[PoolId].Size - Offset) { return STATUS_INVALID_PARAMETER; } // 或者更直观的: if (Offset + UserBufferSize > PoolEntries[PoolId].Size || Offset + UserBufferSize < Offset) { // 防止整数溢出 return STATUS_INVALID_PARAMETER; }对于内核驱动开发者而言,必须对所有从用户态传入的指针、长度、偏移等参数进行严格的、组合性的验证。同时,使用ProbeForRead和ProbeForWrite来确保用户态缓冲区可访问,并使用try/except块来捕获访问异常。
6.2 现代Windows的内核缓解机制
即使成功利用了类似《shadow》的漏洞,在现代Windows系统上也可能因为以下缓解机制而失败:
- KASLR (内核地址空间布局随机化):让内核模块和全局变量的地址在每次启动时都变化。我们的利用需要先通过信息泄露来绕过它,《shadow》题目设计通常提供了泄露的途径。
- SMEP (管理模式执行保护):防止内核态执行用户态内存页的代码。如果我们想通过覆盖函数指针并跳转到用户态的Shellcode,SMEP会阻止。绕过方法通常是转向ROP(返回导向编程)或篡改不会触发SMEP的内核数据,比如我们使用的令牌覆盖法,完全不涉及执行用户态代码。
- KCFG (内核控制流防护)和HVCI (基于虚拟化的安全):这些是更高级的防护。KCFG对间接调用进行验证,HVCI强制实行代码完整性保护。它们会使得覆盖函数指针变得极其困难。在真正的安全比赛中或实战中,遇到开启这些防护的系统,需要更复杂的利用链,例如利用一个可写的函数指针表(如
HalDispatchTable)中尚未受保护的条目。
6.3 从CTF到实战的差距
CTF题目为了可解性和教育性,往往做了简化:
- 环境纯净:系统通常没有其他第三方驱动,堆布局相对可预测。
- 漏洞孤立:通常只有一个关键漏洞,利用路径清晰。
- 防护关闭:SMEP、KASLR等可能默认关闭或容易被绕过。
- 目标明确:提权到SYSTEM就是终点。
而实战中:
- 环境复杂:成千上万的驱动和进程,堆布局高度不可预测。
- 漏洞链需求:可能需要多个漏洞(如信息泄露+任意写)组合才能完成利用。
- 全防护开启:所有缓解机制默认开启,需要更精巧的绕过技术。
- 后续利用:提权后还要维持权限(持久化)、横向移动、清理痕迹。
因此,CTF是绝佳的学习起点,它帮助我们建立对漏洞原理、利用技术和系统机制的深刻理解。但要将这些知识用于真正的安全研究或渗透测试,还需要在更复杂、更真实的对抗环境中不断磨练。分析像《shadow》这样的高质量赛题,正是构建这种深度理解不可或缺的一环。通过手动复现每一个步骤,你收获的将不仅仅是一个“flag”,而是面对未来更复杂安全挑战时,那份抽丝剥茧、直击要害的分析能力。