1. 项目概述从一道题看内核漏洞利用的基石最近在整理资料时翻到了一个非常经典的入门级内核pwn题目。说它“十分基础”是因为它几乎涵盖了从用户态程序漏洞利用转向内核态漏洞利用时所有必须跨越的第一个门槛。对于习惯了栈溢出、堆利用的CTF选手或者安全研究员来说内核pwn初看像是一座大山但其实只要理解了几个核心的“开关”就会发现其底层逻辑是相通的。这道题就是一个完美的“开关”演示器它不涉及复杂的堆风水、不要求你对内核内存管理有极深的理解但它要求你必须清晰地知道当程序从用户态User Land进入内核态Kernel Land后游戏规则发生了哪些根本性的变化。这道题通常被设计为一个可加载的内核模块LKM它向用户空间暴露了一个设备文件比如/dev/vuln。用户程序通过标准的文件操作接口open,read,write,ioctl与这个模块交互。而漏洞就藏在某个操作的处理函数里最常见的就是一个简单的栈缓冲区溢出。是的内核里也有栈也会溢出。但内核栈溢出能做什么怎么做这和用户态程序gets溢出一个char buffer[64]有天壤之别。通过解这道题你会第一次亲手完成通过用户态程序触发内核漏洞 - 劫持内核执行流 - 提权到root并返回用户态的完整链条。这个过程里你会接触到commit_creds(prepare_kernel_cred(0))这个“万能”提权公式会理解为什么需要swapgs和iretq这样的指令来安全地“回家”也会第一次配置带有符号的调试环境。所以别被“内核”二字吓到我们一步步来。2. 核心漏洞原理与内核环境认知2.1 用户态与内核态权限的鸿沟在用户态我们写的程序运行在一个受限制的“沙箱”里。它不能直接访问物理内存的所有区域不能执行特权指令如修改页表、关闭中断其能访问的内存地址空间也是有限的。这是操作系统为了保证安全性和稳定性而设立的基本规则。当程序需要执行一些特权操作比如读写文件、申请内存、进行网络通信时就必须通过“系统调用”System Call这个唯一的门户请求内核代为执行。此时CPU会从“用户态”切换至“内核态”。这个切换不仅仅是权限的提升更伴随着执行环境的彻底改变栈指针RSP会从用户栈切换到内核栈使用的内存地址空间也从用户页表切换到了内核页表。这意味着在内核态执行的代码其视角里的内存布局和用户程序看到的完全不同。内核拥有整个系统最高权限可以读写任何内存执行任何指令。因此一旦我们能控制内核的执行流就等同于控制了整个机器。这道基础题目的漏洞模型通常就是一个内核模块在处理来自用户态的数据时没有进行正确的边界检查。例如模块的write操作函数可能长这样static ssize_t device_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { char local_buf[64]; copy_from_user(local_buf, buf, len); // 危险len 可能大于 64 // ... 其他处理逻辑 return len; }copy_from_user函数负责将数据从用户空间拷贝到内核空间。如果len参数由用户控制且大于local_buf的大小64字节就会发生内核栈溢出。这和用户态的strcpy溢出在形式上一样但破坏力和利用方式截然不同。2.2 内核漏洞利用的特殊挑战与机遇在内核态利用漏洞我们面临几个核心挑战也正是这道题要教会我们的SMAP/SMEP保护现代CPU和内核支持SMAP管理模式访问保护和SMEP管理模式执行保护。简单说SMEP禁止内核态去执行用户态内存页里的代码SMAP禁止内核态去访问读/写用户态内存页的数据。这意味着传统的“把shellcode放在用户态缓冲区然后跳过去执行”的思路行不通了。我们需要在内核空间里寻找可用的代码片段即Gadget来构建ROP链。KASLR内核地址空间布局随机化和用户态的ASLR类似内核的代码、数据地址在每次启动时是随机化的。我们需要通过某种方式泄漏一个内核指针来计算出基址从而知道我们想跳转的函数的真实地址。栈迁移与稳定性内核栈通常比较小可能是8KB或16KB溢出空间有限。而且内核在执行过程中会频繁使用栈我们的ROP链可能会被破坏。因此常常需要先将栈指针RSP迁移到一个我们可控的、更稳定的内存区域比如用户态通过mmap分配的一块内存虽然SMAP禁止访问但我们可以通过swapgs和iretq在ROP链的最后阶段回到用户态所以迁移的目标通常是这块用户态内存的某个区域但需要注意利用阶段。然而也有机遇丰富的Gadget内核镜像本身就是一个巨大的、包含大量有用指令序列的二进制文件。找到形如pop rdi; ret、mov [rdi], rax; ret这样的gadget比在用户态程序里容易得多。强大的原语一旦控制流被劫持我们就能以最高权限调用任何内核函数。最经典的就是调用commit_creds(prepare_kernel_cred(0))这个组合会生成一个权限为root的凭证结构体并将其赋给当前进程。固定的返回路径内核在完成系统调用或中断处理必须返回用户态。这个返回过程是标准化的为我们提供了稳定的利用出口。我们不需要自己发明如何降权返回只需要模仿这个标准过程即可。这道基础题往往会关闭KASLRnokaslr内核启动参数并且可能关闭SMEP/SMAP通过nosmep,nosmap或CPU不支持让我们先专注于理解利用链条本身。环境配置通常是这样的使用QEMU启动一个定制内核并携带-append “nokaslr consolettyS0 oopspanic panic1”等参数。3. 实验环境搭建与核心工具链3.1 内核调试环境配置手动从头编译内核和构建文件系统对于新手来说过于繁琐。因此最推荐的方法是使用已经集成好的开源项目例如kernelCTF提供的入门题目模板或者使用qemu busybox构建最小系统。这里我们侧重于理解流程假设我们已经有了一个可运行的环境漏洞模块vuln.ko即包含漏洞的内核模块。启动脚本start.sh一个包含QEMU启动命令的脚本它指定了内核镜像bzImage、内存大小、文件系统rootfs.cpio以及重要的调试参数。一个典型的带调试的启动脚本start.sh内容如下#!/bin/bash qemu-system-x86_64 \ -m 128M \ -kernel ./bzImage \ -initrd ./rootfs.cpio \ -append consolettyS0 nokaslr quiet panic1 \ -monitor /dev/null \ -nographic \ -no-reboot \ -cpu qemu64,smep \ -s关键参数解读-append “nokaslr”关闭内核地址随机化简化利用。-cpu qemu64,smep启用SMEP保护。在基础题中我们可能先不加smep来关闭它后续再挑战开启的情况。-s这是一个快捷方式等价于-gdb tcp::1234在1234端口开启GDB调试服务。-initrd ./rootfs.cpio指定初始内存文件系统里面包含了我们的漏洞模块、测试程序等。3.2 调试与探索获取关键符号地址启动QEMU后我们需要在另一个终端使用GDB连接进行调试。这里推荐使用gdb-multiarch或gdb配合pwndbg/peda/gef等插件它们能提供更好的反汇编和搜索体验。gdb-multiarch ./vmlinux # vmlinux是带有调试符号的内核镜像 target remote localhost:1234连接成功后我们就可以像调试普通程序一样调试内核了。首先我们需要获取几个核心函数的地址这是构建ROP链的基础# 查找提权函数的地址 p/x commit_creds p/x prepare_kernel_cred # 查找漏洞模块的地址通常其代码段在 .text 节我们可以找到其 write 函数 # 首先需要加载模块的符号如果gdb没有自动加载 add-symbol-file vuln.ko [模块加载地址] # 模块加载地址可以在QEMU启动后的内核日志中看到或者通过 cat /proc/modules 查看 # 然后查找设备写函数 p/x device_write注意在实际做题时出题人可能会直接提供vmlinux和漏洞模块的.ko文件。vmlinux包含了所有内核符号而模块的符号需要我们知道其加载基址后才能导入GDB。获取基址的方法是在启动的系统里执行cat /proc/modules或lsmod查看vuln模块对应的地址。除了函数地址我们还需要寻找ROP gadget。我们可以用GDB插件搜索也可以提前用ROPgadget或ropper这样的工具在vmlinux文件中搜索并保存结果备用。# 使用 pwndbg 搜索 gadget ropper --search “pop rdi; ret” ropper --search “swapgs; ret” ropper --search “iretq”swapgs和iretq是安全返回用户态的关键。swapgs指令用于交换GS段寄存器的当前值和MSR中的值在系统调用入口和出口时用于切换内核与用户态的数据结构如current_task。在内核利用中我们通常需要执行一次swapgs来恢复用户态的GS值。iretq则是从中断返回的指令它会从栈上依次弹出RIP,CS,RFLAGS,RSP,SS从而彻底切换回用户态。4. 漏洞利用链的详细拆解与构建4.1 溢出点分析与栈布局测绘首先我们需要逆向分析漏洞模块确定溢出点的确切位置和可控数据。假设通过逆向我们确认device_write函数如下char local_buf[64]; size_t len; copy_from_user(len, buf, 8); // 先拷贝8字节的长度值 if (len 64) { printk(“len too long\n”); return -EINVAL; } copy_from_user(local_buf, buf8, len); // 再拷贝数据看起来有长度检查不这里有一个经典的逻辑错误它先从一个用户可控的缓冲区buf里读了一个len然后用这个len去做检查。但问题在于第一个copy_from_user拷贝的len本身也是用户可控的攻击者可以在buf的前8字节放置一个很大的数比如200这个值会被写入内核栈上的变量len。虽然接下来的检查if (len 64)会通过但第二个copy_from_user会使用这个被篡改的、很大的len值去拷贝数据到只有64字节的local_buf从而导致溢出。接下来我们需要知道从local_buf开始到函数返回地址saved RIP之间有多少字节的偏移。这可以通过静态分析结合动态调试获得。静态分析看汇编计算local_buf的栈地址与RBP/返回地址的偏移。动态调试更实用的方法是“爆破”或模式串。我们可以在用户态编写程序发送一个有规律的模式串如cyclic(200)来自pwntools触发崩溃后内核会打印出Oops信息其中会包含触发错误的指令地址RIP以及可能包含栈的部分内容。通过检查RIP的值我们可以用cyclic_find(RIP)来反推出覆盖返回地址的精确偏移量。假设我们通过动态调试计算出偏移量是72。即local_buf到返回地址之间有72字节。那么我们的攻击载荷结构就清晰了Payload 72字节的填充数据 8字节的ROP链起始地址4.2 ROP链的构造提权与返回我们的目标是1) 将当前进程的权限提升为root2) 安全地返回到用户态继续执行我们的shellcode。因此ROP链需要按顺序完成以下任务执行提权函数调用commit_creds(prepare_kernel_cred(0))。这需要prepare_kernel_cred的参数为0表示用0号进程的cred作为模板即root。所以我们需要一个pop rdi; ret的gadget将rdi设为0。调用prepare_kernel_cred(0)返回值新的cred结构体指针通常在rax。将rax的值作为参数传给commit_creds。所以我们需要一个mov rdi, rax; ret或者pop rdi; ret加上一个能将rax值存入rdi的gadget如xchg rdi, rax; ret。恢复用户态执行环境执行swapgs; ret恢复用户态的GS。执行iretq从栈上弹出返回用户态所需的五个寄存器RIP, CS, RFLAGS, RSP, SS。我们需要提前在内核栈上布置好这五个值。其中CS和SS是段寄存器用户态的值通常是0x23和0x2b在64位Linux中。RFLAGS可以是一个固定值比如0x246。RIP应该设置为我们用户态shellcode的地址。RSP应该设置为我们用户态栈的一个可写地址比如通过mmap分配的一块内存。因此完整的ROP链在栈上的布局从覆盖的返回地址开始可能看起来像这样地址均为示例--------------------- | pop rdi; ret | - 覆盖 saved RIP 后控制流首先跳到这里 --------------------- | 0x0 | - rdi 0作为 prepare_kernel_cred 参数 --------------------- | prepare_kernel_cred | --------------------- | mov rdi, rax; ret | - 将返回值新cred移动到rdi --------------------- | commit_creds | --------------------- | swapgs; ret | --------------------- | iretq | --------------------- | user_shellcode_addr | - iretq 弹出的 RIP --------------------- | 0x23 | - CS --------------------- | 0x246 | - RFLAGS --------------------- | user_stack_addr | - RSP --------------------- | 0x2b | - SS ---------------------4.3 用户态着陆点的准备当内核通过iretq返回到我们设置的user_shellcode_addr时CPU已经回到了用户态。我们需要提前在这个地址布置好获得root shell的代码。通常的做法是在用户态攻击程序中使用mmap分配一块可执行的内存区域。将一段简单的execve(“/bin/sh”, 0, 0)的shellcode写入这块内存。由于我们已经提权这个新启动的shell就是root shell。将这块内存的地址作为user_shellcode_addr同时也作为user_stack_addr或者另外分配一块栈空间。一个典型的shellcode汇编如下mov rax, 59 ; syscall number for execve lea rdi, [ripbinsh] ; point rdi to “/bin/sh” mov rsi, 0 mov rdx, 0 syscall binsh: db ‘/bin/sh’, 0在C语言编写的exploit中我们可以直接用内联汇编或者调用system(“/bin/sh”)。但更常见的是用execve系统调用因为system依赖于环境。我们可以这样准备着陆点void get_shell() { if (getuid() 0) { printf(“[] Got root!\n”); system(“/bin/sh”); } else { printf(“[-] Failed to get root\n”); exit(-1); } }然后在ROP链的user_shellcode_addr处填入函数get_shell的地址。注意因为iretq返回后栈指针RSP被切换到了我们设置的user_stack_addr所以我们需要确保这个地址是可写的并且不会破坏我们的shellcode。5. 完整Exploit编写与调试技巧5.1 Exploit代码结构一个完整的利用程序exploit.c通常包含以下部分与漏洞设备交互open(“/dev/vuln”, O_RDWR)。准备ROP链根据找到的gadget和函数地址构建一个字节数组rop_chain。构建最终Payloadpayload padding rop_chain。触发漏洞write(fd, payload, sizeof(payload))。等待shell可能还需要一些同步比如sleep(1)然后检查是否提权成功。这里有一个非常重要的细节内核栈上的数据需要是物理地址吗不需要。当CPU在内核态执行时它使用的是内核页表。我们通过漏洞覆盖的内核栈内容是内核线性地址空间中的虚拟地址。我们填入的ROP gadget地址、函数地址都是内核镜像加载后的虚拟地址。我们填入的user_shellcode_addr和user_stack_addr是用户空间的虚拟地址。当内核执行iretq时它会切换回用户页表这些用户空间地址在新的上下文下就是有效的。5.2 调试技巧与常见问题排查即使理论清晰第一次实战也大概率会失败。以下是一些关键的调试技巧利用Oops信息如果内核崩溃它会打印详细的Oops信息。关注RIP、RSP的值以及栈回溯Call Trace。这能告诉你程序在哪个地址崩溃以及是如何执行到那里的。用cyclic_find(RIP)可以快速验证偏移量是否正确。GDB单步跟踪在QEMU启动时加上-s参数用GDB连接。你可以在device_write函数入口、返回前以及你期望的第一个gadget地址处下断点b *0xffffffffc0000000。单步执行ni,si观察寄存器和栈的变化确保ROP链被正确执行。检查SMEP/SMAP如果你的commit_creds调用了但没提权成功或者直接崩溃了检查是否SMEP在起作用。如果SMEP开启内核态跳转到用户态地址比如你的shellcode地址会触发崩溃。此时需要纯内核ROP链并在最后用iretq返回用户态而不能在ROP链中跳转到用户态代码。栈对齐某些函数或Gadget可能对栈指针RSP有对齐要求如16字节对齐。如果iretq时发生错误可以尝试在ROP链中插入一个ret指令来微调RSP。用户态地址有效性确保传递给iretq的user_stack_addr是有效的、可写的用户态地址。最简单的方式是在exploit中mmap一块内存并用这个地址。实操心得在构造ROP链时我习惯先用Python脚本配合pwntools快速组装和测试payload结构将ROP链的每个地址打印出来在GDB中手动验证其效果。特别是iretq帧的五个值务必确保CS和SS是正确的用户态段选择子0x23和0x2bRFLAGS不要设置保留位或陷阱标志。一个常见的错误是把RIP设成了system函数的地址但iretq返回后环境已经是用户态应该跳转到我们自己的、已经部署好shellcode的用户态函数。5.3 从“基础”到“进阶”的思考完成这道基础题后你实际上已经掌握了内核漏洞利用最核心的范式控制流劫持 - 内核ROP提权 - 恢复上下文返回用户态。后续更复杂的题目都是在这个范式上增加障碍开启KASLR你需要先通过某个漏洞如信息泄漏获取一个内核指针计算出基址偏移。开启SMEP你不能在ROP链中跳转到用户态代码必须全程使用内核gadget。commit_creds和prepare_kernel_cred的地址是已知的通过泄漏但你的shellcode需要放在内核空间吗不最终仍然通过iretq返回用户态执行。SMEP限制的是内核态“执行”用户页但不限制“返回”到用户页。栈空间不足如果溢出空间很小不足以放下长ROP链就需要进行“栈迁移”。通常利用一个leave; ret的gadget。leave指令等价于mov rsp, rbp; pop rbp。如果我们能控制RBP就能将RSP迁移到RBP指向的位置。因此攻击分为两步1) 用少量溢出覆盖saved RBP和saved RIP其中saved RIP指向leave; retgadgetsaved RBP指向我们可控的用户态内存区作为新栈。2) 在我们可控的内存区提前布置好完整的ROP链。当函数返回时执行leave; ret就会完成栈迁移并开始执行新栈上的ROP链。这道“十分基础”的题目就像一把钥匙打开了内核漏洞利用的大门。它剥离了复杂的现实干扰让你专注于最本质的流程。理解它反复练习它直到你能在不看任何参考的情况下从零开始完成环境搭建、逆向分析、偏移计算、gadget搜索、ROP链构建、exploit编写和调试。之后当你面对更真实、更复杂的内核漏洞时你看到的将不再是混沌而是由这些基础构件组合而成的、清晰的攻击路径。
内核漏洞利用入门:从用户态到内核态的完整提权链分析
1. 项目概述从一道题看内核漏洞利用的基石最近在整理资料时翻到了一个非常经典的入门级内核pwn题目。说它“十分基础”是因为它几乎涵盖了从用户态程序漏洞利用转向内核态漏洞利用时所有必须跨越的第一个门槛。对于习惯了栈溢出、堆利用的CTF选手或者安全研究员来说内核pwn初看像是一座大山但其实只要理解了几个核心的“开关”就会发现其底层逻辑是相通的。这道题就是一个完美的“开关”演示器它不涉及复杂的堆风水、不要求你对内核内存管理有极深的理解但它要求你必须清晰地知道当程序从用户态User Land进入内核态Kernel Land后游戏规则发生了哪些根本性的变化。这道题通常被设计为一个可加载的内核模块LKM它向用户空间暴露了一个设备文件比如/dev/vuln。用户程序通过标准的文件操作接口open,read,write,ioctl与这个模块交互。而漏洞就藏在某个操作的处理函数里最常见的就是一个简单的栈缓冲区溢出。是的内核里也有栈也会溢出。但内核栈溢出能做什么怎么做这和用户态程序gets溢出一个char buffer[64]有天壤之别。通过解这道题你会第一次亲手完成通过用户态程序触发内核漏洞 - 劫持内核执行流 - 提权到root并返回用户态的完整链条。这个过程里你会接触到commit_creds(prepare_kernel_cred(0))这个“万能”提权公式会理解为什么需要swapgs和iretq这样的指令来安全地“回家”也会第一次配置带有符号的调试环境。所以别被“内核”二字吓到我们一步步来。2. 核心漏洞原理与内核环境认知2.1 用户态与内核态权限的鸿沟在用户态我们写的程序运行在一个受限制的“沙箱”里。它不能直接访问物理内存的所有区域不能执行特权指令如修改页表、关闭中断其能访问的内存地址空间也是有限的。这是操作系统为了保证安全性和稳定性而设立的基本规则。当程序需要执行一些特权操作比如读写文件、申请内存、进行网络通信时就必须通过“系统调用”System Call这个唯一的门户请求内核代为执行。此时CPU会从“用户态”切换至“内核态”。这个切换不仅仅是权限的提升更伴随着执行环境的彻底改变栈指针RSP会从用户栈切换到内核栈使用的内存地址空间也从用户页表切换到了内核页表。这意味着在内核态执行的代码其视角里的内存布局和用户程序看到的完全不同。内核拥有整个系统最高权限可以读写任何内存执行任何指令。因此一旦我们能控制内核的执行流就等同于控制了整个机器。这道基础题目的漏洞模型通常就是一个内核模块在处理来自用户态的数据时没有进行正确的边界检查。例如模块的write操作函数可能长这样static ssize_t device_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { char local_buf[64]; copy_from_user(local_buf, buf, len); // 危险len 可能大于 64 // ... 其他处理逻辑 return len; }copy_from_user函数负责将数据从用户空间拷贝到内核空间。如果len参数由用户控制且大于local_buf的大小64字节就会发生内核栈溢出。这和用户态的strcpy溢出在形式上一样但破坏力和利用方式截然不同。2.2 内核漏洞利用的特殊挑战与机遇在内核态利用漏洞我们面临几个核心挑战也正是这道题要教会我们的SMAP/SMEP保护现代CPU和内核支持SMAP管理模式访问保护和SMEP管理模式执行保护。简单说SMEP禁止内核态去执行用户态内存页里的代码SMAP禁止内核态去访问读/写用户态内存页的数据。这意味着传统的“把shellcode放在用户态缓冲区然后跳过去执行”的思路行不通了。我们需要在内核空间里寻找可用的代码片段即Gadget来构建ROP链。KASLR内核地址空间布局随机化和用户态的ASLR类似内核的代码、数据地址在每次启动时是随机化的。我们需要通过某种方式泄漏一个内核指针来计算出基址从而知道我们想跳转的函数的真实地址。栈迁移与稳定性内核栈通常比较小可能是8KB或16KB溢出空间有限。而且内核在执行过程中会频繁使用栈我们的ROP链可能会被破坏。因此常常需要先将栈指针RSP迁移到一个我们可控的、更稳定的内存区域比如用户态通过mmap分配的一块内存虽然SMAP禁止访问但我们可以通过swapgs和iretq在ROP链的最后阶段回到用户态所以迁移的目标通常是这块用户态内存的某个区域但需要注意利用阶段。然而也有机遇丰富的Gadget内核镜像本身就是一个巨大的、包含大量有用指令序列的二进制文件。找到形如pop rdi; ret、mov [rdi], rax; ret这样的gadget比在用户态程序里容易得多。强大的原语一旦控制流被劫持我们就能以最高权限调用任何内核函数。最经典的就是调用commit_creds(prepare_kernel_cred(0))这个组合会生成一个权限为root的凭证结构体并将其赋给当前进程。固定的返回路径内核在完成系统调用或中断处理必须返回用户态。这个返回过程是标准化的为我们提供了稳定的利用出口。我们不需要自己发明如何降权返回只需要模仿这个标准过程即可。这道基础题往往会关闭KASLRnokaslr内核启动参数并且可能关闭SMEP/SMAP通过nosmep,nosmap或CPU不支持让我们先专注于理解利用链条本身。环境配置通常是这样的使用QEMU启动一个定制内核并携带-append “nokaslr consolettyS0 oopspanic panic1”等参数。3. 实验环境搭建与核心工具链3.1 内核调试环境配置手动从头编译内核和构建文件系统对于新手来说过于繁琐。因此最推荐的方法是使用已经集成好的开源项目例如kernelCTF提供的入门题目模板或者使用qemu busybox构建最小系统。这里我们侧重于理解流程假设我们已经有了一个可运行的环境漏洞模块vuln.ko即包含漏洞的内核模块。启动脚本start.sh一个包含QEMU启动命令的脚本它指定了内核镜像bzImage、内存大小、文件系统rootfs.cpio以及重要的调试参数。一个典型的带调试的启动脚本start.sh内容如下#!/bin/bash qemu-system-x86_64 \ -m 128M \ -kernel ./bzImage \ -initrd ./rootfs.cpio \ -append consolettyS0 nokaslr quiet panic1 \ -monitor /dev/null \ -nographic \ -no-reboot \ -cpu qemu64,smep \ -s关键参数解读-append “nokaslr”关闭内核地址随机化简化利用。-cpu qemu64,smep启用SMEP保护。在基础题中我们可能先不加smep来关闭它后续再挑战开启的情况。-s这是一个快捷方式等价于-gdb tcp::1234在1234端口开启GDB调试服务。-initrd ./rootfs.cpio指定初始内存文件系统里面包含了我们的漏洞模块、测试程序等。3.2 调试与探索获取关键符号地址启动QEMU后我们需要在另一个终端使用GDB连接进行调试。这里推荐使用gdb-multiarch或gdb配合pwndbg/peda/gef等插件它们能提供更好的反汇编和搜索体验。gdb-multiarch ./vmlinux # vmlinux是带有调试符号的内核镜像 target remote localhost:1234连接成功后我们就可以像调试普通程序一样调试内核了。首先我们需要获取几个核心函数的地址这是构建ROP链的基础# 查找提权函数的地址 p/x commit_creds p/x prepare_kernel_cred # 查找漏洞模块的地址通常其代码段在 .text 节我们可以找到其 write 函数 # 首先需要加载模块的符号如果gdb没有自动加载 add-symbol-file vuln.ko [模块加载地址] # 模块加载地址可以在QEMU启动后的内核日志中看到或者通过 cat /proc/modules 查看 # 然后查找设备写函数 p/x device_write注意在实际做题时出题人可能会直接提供vmlinux和漏洞模块的.ko文件。vmlinux包含了所有内核符号而模块的符号需要我们知道其加载基址后才能导入GDB。获取基址的方法是在启动的系统里执行cat /proc/modules或lsmod查看vuln模块对应的地址。除了函数地址我们还需要寻找ROP gadget。我们可以用GDB插件搜索也可以提前用ROPgadget或ropper这样的工具在vmlinux文件中搜索并保存结果备用。# 使用 pwndbg 搜索 gadget ropper --search “pop rdi; ret” ropper --search “swapgs; ret” ropper --search “iretq”swapgs和iretq是安全返回用户态的关键。swapgs指令用于交换GS段寄存器的当前值和MSR中的值在系统调用入口和出口时用于切换内核与用户态的数据结构如current_task。在内核利用中我们通常需要执行一次swapgs来恢复用户态的GS值。iretq则是从中断返回的指令它会从栈上依次弹出RIP,CS,RFLAGS,RSP,SS从而彻底切换回用户态。4. 漏洞利用链的详细拆解与构建4.1 溢出点分析与栈布局测绘首先我们需要逆向分析漏洞模块确定溢出点的确切位置和可控数据。假设通过逆向我们确认device_write函数如下char local_buf[64]; size_t len; copy_from_user(len, buf, 8); // 先拷贝8字节的长度值 if (len 64) { printk(“len too long\n”); return -EINVAL; } copy_from_user(local_buf, buf8, len); // 再拷贝数据看起来有长度检查不这里有一个经典的逻辑错误它先从一个用户可控的缓冲区buf里读了一个len然后用这个len去做检查。但问题在于第一个copy_from_user拷贝的len本身也是用户可控的攻击者可以在buf的前8字节放置一个很大的数比如200这个值会被写入内核栈上的变量len。虽然接下来的检查if (len 64)会通过但第二个copy_from_user会使用这个被篡改的、很大的len值去拷贝数据到只有64字节的local_buf从而导致溢出。接下来我们需要知道从local_buf开始到函数返回地址saved RIP之间有多少字节的偏移。这可以通过静态分析结合动态调试获得。静态分析看汇编计算local_buf的栈地址与RBP/返回地址的偏移。动态调试更实用的方法是“爆破”或模式串。我们可以在用户态编写程序发送一个有规律的模式串如cyclic(200)来自pwntools触发崩溃后内核会打印出Oops信息其中会包含触发错误的指令地址RIP以及可能包含栈的部分内容。通过检查RIP的值我们可以用cyclic_find(RIP)来反推出覆盖返回地址的精确偏移量。假设我们通过动态调试计算出偏移量是72。即local_buf到返回地址之间有72字节。那么我们的攻击载荷结构就清晰了Payload 72字节的填充数据 8字节的ROP链起始地址4.2 ROP链的构造提权与返回我们的目标是1) 将当前进程的权限提升为root2) 安全地返回到用户态继续执行我们的shellcode。因此ROP链需要按顺序完成以下任务执行提权函数调用commit_creds(prepare_kernel_cred(0))。这需要prepare_kernel_cred的参数为0表示用0号进程的cred作为模板即root。所以我们需要一个pop rdi; ret的gadget将rdi设为0。调用prepare_kernel_cred(0)返回值新的cred结构体指针通常在rax。将rax的值作为参数传给commit_creds。所以我们需要一个mov rdi, rax; ret或者pop rdi; ret加上一个能将rax值存入rdi的gadget如xchg rdi, rax; ret。恢复用户态执行环境执行swapgs; ret恢复用户态的GS。执行iretq从栈上弹出返回用户态所需的五个寄存器RIP, CS, RFLAGS, RSP, SS。我们需要提前在内核栈上布置好这五个值。其中CS和SS是段寄存器用户态的值通常是0x23和0x2b在64位Linux中。RFLAGS可以是一个固定值比如0x246。RIP应该设置为我们用户态shellcode的地址。RSP应该设置为我们用户态栈的一个可写地址比如通过mmap分配的一块内存。因此完整的ROP链在栈上的布局从覆盖的返回地址开始可能看起来像这样地址均为示例--------------------- | pop rdi; ret | - 覆盖 saved RIP 后控制流首先跳到这里 --------------------- | 0x0 | - rdi 0作为 prepare_kernel_cred 参数 --------------------- | prepare_kernel_cred | --------------------- | mov rdi, rax; ret | - 将返回值新cred移动到rdi --------------------- | commit_creds | --------------------- | swapgs; ret | --------------------- | iretq | --------------------- | user_shellcode_addr | - iretq 弹出的 RIP --------------------- | 0x23 | - CS --------------------- | 0x246 | - RFLAGS --------------------- | user_stack_addr | - RSP --------------------- | 0x2b | - SS ---------------------4.3 用户态着陆点的准备当内核通过iretq返回到我们设置的user_shellcode_addr时CPU已经回到了用户态。我们需要提前在这个地址布置好获得root shell的代码。通常的做法是在用户态攻击程序中使用mmap分配一块可执行的内存区域。将一段简单的execve(“/bin/sh”, 0, 0)的shellcode写入这块内存。由于我们已经提权这个新启动的shell就是root shell。将这块内存的地址作为user_shellcode_addr同时也作为user_stack_addr或者另外分配一块栈空间。一个典型的shellcode汇编如下mov rax, 59 ; syscall number for execve lea rdi, [ripbinsh] ; point rdi to “/bin/sh” mov rsi, 0 mov rdx, 0 syscall binsh: db ‘/bin/sh’, 0在C语言编写的exploit中我们可以直接用内联汇编或者调用system(“/bin/sh”)。但更常见的是用execve系统调用因为system依赖于环境。我们可以这样准备着陆点void get_shell() { if (getuid() 0) { printf(“[] Got root!\n”); system(“/bin/sh”); } else { printf(“[-] Failed to get root\n”); exit(-1); } }然后在ROP链的user_shellcode_addr处填入函数get_shell的地址。注意因为iretq返回后栈指针RSP被切换到了我们设置的user_stack_addr所以我们需要确保这个地址是可写的并且不会破坏我们的shellcode。5. 完整Exploit编写与调试技巧5.1 Exploit代码结构一个完整的利用程序exploit.c通常包含以下部分与漏洞设备交互open(“/dev/vuln”, O_RDWR)。准备ROP链根据找到的gadget和函数地址构建一个字节数组rop_chain。构建最终Payloadpayload padding rop_chain。触发漏洞write(fd, payload, sizeof(payload))。等待shell可能还需要一些同步比如sleep(1)然后检查是否提权成功。这里有一个非常重要的细节内核栈上的数据需要是物理地址吗不需要。当CPU在内核态执行时它使用的是内核页表。我们通过漏洞覆盖的内核栈内容是内核线性地址空间中的虚拟地址。我们填入的ROP gadget地址、函数地址都是内核镜像加载后的虚拟地址。我们填入的user_shellcode_addr和user_stack_addr是用户空间的虚拟地址。当内核执行iretq时它会切换回用户页表这些用户空间地址在新的上下文下就是有效的。5.2 调试技巧与常见问题排查即使理论清晰第一次实战也大概率会失败。以下是一些关键的调试技巧利用Oops信息如果内核崩溃它会打印详细的Oops信息。关注RIP、RSP的值以及栈回溯Call Trace。这能告诉你程序在哪个地址崩溃以及是如何执行到那里的。用cyclic_find(RIP)可以快速验证偏移量是否正确。GDB单步跟踪在QEMU启动时加上-s参数用GDB连接。你可以在device_write函数入口、返回前以及你期望的第一个gadget地址处下断点b *0xffffffffc0000000。单步执行ni,si观察寄存器和栈的变化确保ROP链被正确执行。检查SMEP/SMAP如果你的commit_creds调用了但没提权成功或者直接崩溃了检查是否SMEP在起作用。如果SMEP开启内核态跳转到用户态地址比如你的shellcode地址会触发崩溃。此时需要纯内核ROP链并在最后用iretq返回用户态而不能在ROP链中跳转到用户态代码。栈对齐某些函数或Gadget可能对栈指针RSP有对齐要求如16字节对齐。如果iretq时发生错误可以尝试在ROP链中插入一个ret指令来微调RSP。用户态地址有效性确保传递给iretq的user_stack_addr是有效的、可写的用户态地址。最简单的方式是在exploit中mmap一块内存并用这个地址。实操心得在构造ROP链时我习惯先用Python脚本配合pwntools快速组装和测试payload结构将ROP链的每个地址打印出来在GDB中手动验证其效果。特别是iretq帧的五个值务必确保CS和SS是正确的用户态段选择子0x23和0x2bRFLAGS不要设置保留位或陷阱标志。一个常见的错误是把RIP设成了system函数的地址但iretq返回后环境已经是用户态应该跳转到我们自己的、已经部署好shellcode的用户态函数。5.3 从“基础”到“进阶”的思考完成这道基础题后你实际上已经掌握了内核漏洞利用最核心的范式控制流劫持 - 内核ROP提权 - 恢复上下文返回用户态。后续更复杂的题目都是在这个范式上增加障碍开启KASLR你需要先通过某个漏洞如信息泄漏获取一个内核指针计算出基址偏移。开启SMEP你不能在ROP链中跳转到用户态代码必须全程使用内核gadget。commit_creds和prepare_kernel_cred的地址是已知的通过泄漏但你的shellcode需要放在内核空间吗不最终仍然通过iretq返回用户态执行。SMEP限制的是内核态“执行”用户页但不限制“返回”到用户页。栈空间不足如果溢出空间很小不足以放下长ROP链就需要进行“栈迁移”。通常利用一个leave; ret的gadget。leave指令等价于mov rsp, rbp; pop rbp。如果我们能控制RBP就能将RSP迁移到RBP指向的位置。因此攻击分为两步1) 用少量溢出覆盖saved RBP和saved RIP其中saved RIP指向leave; retgadgetsaved RBP指向我们可控的用户态内存区作为新栈。2) 在我们可控的内存区提前布置好完整的ROP链。当函数返回时执行leave; ret就会完成栈迁移并开始执行新栈上的ROP链。这道“十分基础”的题目就像一把钥匙打开了内核漏洞利用的大门。它剥离了复杂的现实干扰让你专注于最本质的流程。理解它反复练习它直到你能在不看任何参考的情况下从零开始完成环境搭建、逆向分析、偏移计算、gadget搜索、ROP链构建、exploit编写和调试。之后当你面对更真实、更复杂的内核漏洞时你看到的将不再是混沌而是由这些基础构件组合而成的、清晰的攻击路径。