Windows内核驱动漏洞利用实战:从堆溢出到任意读写与权限提升

📅 2026/7/4 18:00:45
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)。合法操作Offset0, UserBufferSize100拷贝100字节到开头。合法操作Offset90, UserBufferSize10拷贝10字节到末尾。漏洞触发操作Offset90, UserBufferSize20。检查1:Offset (90) Size (100)?否通过。检查2:UserBufferSize (20) Size (100)?否通过。实际拷贝从内核缓冲区第90字节开始写入20字节数据。这会覆盖从第90字节到第109字节的范围而我们的缓冲区只到第99字节。多出来的10字节就溢出了缓冲区边界覆盖了紧随其后的内核内存。这就是一个典型的**堆缓冲区溢出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文件对象结构体庞大包含多个函数指针表如FsContextFsContext2以及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里都有唯一的UniqueProcessIdPID。我们可以用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:886464位靶机。如果调试机和靶机不是同一台物理机则填靶机的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 (管理模式执行保护)防止内核态执行用户态内存页的代码。如果我们想通过覆盖函数指针并跳转到用户态的ShellcodeSMEP会阻止。绕过方法通常是转向ROP返回导向编程或篡改不会触发SMEP的内核数据比如我们使用的令牌覆盖法完全不涉及执行用户态代码。KCFG (内核控制流防护)和HVCI (基于虚拟化的安全)这些是更高级的防护。KCFG对间接调用进行验证HVCI强制实行代码完整性保护。它们会使得覆盖函数指针变得极其困难。在真正的安全比赛中或实战中遇到开启这些防护的系统需要更复杂的利用链例如利用一个可写的函数指针表如HalDispatchTable中尚未受保护的条目。6.3 从CTF到实战的差距CTF题目为了可解性和教育性往往做了简化环境纯净系统通常没有其他第三方驱动堆布局相对可预测。漏洞孤立通常只有一个关键漏洞利用路径清晰。防护关闭SMEP、KASLR等可能默认关闭或容易被绕过。目标明确提权到SYSTEM就是终点。而实战中环境复杂成千上万的驱动和进程堆布局高度不可预测。漏洞链需求可能需要多个漏洞如信息泄露任意写组合才能完成利用。全防护开启所有缓解机制默认开启需要更精巧的绕过技术。后续利用提权后还要维持权限持久化、横向移动、清理痕迹。因此CTF是绝佳的学习起点它帮助我们建立对漏洞原理、利用技术和系统机制的深刻理解。但要将这些知识用于真正的安全研究或渗透测试还需要在更复杂、更真实的对抗环境中不断磨练。分析像《shadow》这样的高质量赛题正是构建这种深度理解不可或缺的一环。通过手动复现每一个步骤你收获的将不仅仅是一个“flag”而是面对未来更复杂安全挑战时那份抽丝剥茧、直击要害的分析能力。