Linux内核栈溢出漏洞利用:从信息泄露到ROP提权实战
1. 项目概述与背景
“强网杯”是国内网络安全领域一项高水平的实战竞赛,其赛题往往紧跟前沿,对选手的内功要求极高。2018年强网杯的这道
core
赛题,就是一个非常经典的Linux内核模块漏洞利用挑战。它模拟了一个存在栈溢出漏洞的内核模块,要求选手在没有任何符号信息、开启了内核保护机制(如KASLR、SMEP、SMAP)的现代Linux系统上,完成从信息泄露到权限提升(提权)的全过程。这道题之所以经典,是因为它几乎涵盖了内核pwn的所有核心考点:驱动逆向、漏洞定位、内存布局泄露、ROP链构造以及绕过现代内核防护。对于想深入二进制安全,尤其是内核漏洞利用领域的朋友来说,这道题是一个绝佳的“磨刀石”。今天,我就带大家从头到尾,手把手地拆解这道题,不仅复现利用过程,更关键的是讲清楚每一步背后的“为什么”,分享一些实战中容易踩坑的细节。
2. 环境搭建与题目分析
2.1 解题环境准备
工欲善其事,必先利其器。内核漏洞利用的环境比用户态程序要复杂得多,一个稳定、可复现的环境是成功的第一步。我推荐使用
qemu-system-x86_64
来模拟运行题目提供的内核镜像、文件系统和驱动模块。配套的调试工具主要是
gdb
,并通过
gdb
的
target remote
功能连接
qemu
启动的
gdbserver
。这里有一个关键点:
必须使用与目标内核版本匹配的
vmlinux
文件(带调试符号的内核映像)
,否则在
gdb
中无法解析内核符号,调试将寸步难行。通常题目会提供,或者可以从编译内核的源码中获取。
启动脚本大概长这样:
#!/bin/bash
qemu-system-x86_64 \
-m 256M \
-kernel ./bzImage \
-initrd ./core.cpio \
-append "console=ttyS0 nokaslr root=/dev/ram rdinit=/sbin/init" \
-cpu kvm64,+smep,+smap \
-nographic \
-gdb tcp::1234 \
-S
注意
-append
参数中的
nokaslr
,这在初期分析时非常重要,它关闭了内核地址空间布局随机化,让每次运行的内核.text段基址固定,便于我们定位函数。
-cpu
参数中的
+smep,+smap
则开启了题目意图考察的保护机制。
2.2 题目逆向与漏洞定位
题目通常会给一个名为
core.ko
的内核模块文件。我们的第一步就是把它扔进逆向工具(如 IDA Pro 或 Ghidra)进行分析。这个模块会通过
module_init
注册一个设备(比如
/proc/core
)或一个
ioctl
接口,为用户态程序提供通信通道。
逆向的重点是寻找那些从用户态拷贝数据到内核态的函数,特别是使用了
copy_from_user
但长度控制不当的地方。在这道
core
题中,经过分析,我们会发现一个类似如下的关键函数(伪代码):
static long core_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
char local_buf[0x40]; // 一个位于内核栈上的缓冲区
...
switch(cmd) {
case 0x6677889A:
if (copy_from_user(local_buf, (void __user *)arg, user_controlled_size) != 0) {
return -EFAULT;
}
// 对 local_buf 进行一些操作...
break;
...
}
}
漏洞点
就在于
copy_from_user
的第三个参数
user_controlled_size
。通过逆向可以发现,这个大小是由用户通过
arg
传递的结构体中的某个字段控制的,并且没有进行有效的边界检查。而目标缓冲区
local_buf
的大小是固定的(例如 0x40 字节)。如果用户传递的大小超过了
local_buf
的容量,就会导致数据溢出到内核栈上,覆盖掉函数返回地址等关键数据,这就是典型的
栈溢出漏洞
。
注意 :内核栈的大小通常很小(在x86_64上一般是16KB或8KB),且与当前进程的内核线程共享。溢出不仅可能覆盖返回地址,还可能破坏栈上保存的寄存器、局部变量,甚至影响到相邻的栈帧,导致内核崩溃(oops或panic)。
3. 利用思路设计与挑战分析
拿到一个栈溢出漏洞,在用户态我们可能直接覆盖返回地址跳转到
shellcode
就行了。但在现代内核环境下,这条路被堵得死死的。我们需要设计一个迂回的策略。
3.1 现代内核保护机制与绕过思路
-
SMEP (Supervisor Mode Execution Prevention)
:CPU级保护,禁止内核态执行用户态内存页的代码。这意味着即使我们把
shellcode放在用户态并通过溢出跳转过去,也会立刻触发崩溃。-
绕过思路
:既然不能执行用户态代码,那我们就执行内核态已有的代码。这就是
ROP (Return-Oriented Programming)
技术。我们需要在内核的
.text段寻找一系列以ret指令结尾的“代码片段”(gadget),将它们串联起来,达到修改权限、执行提权操作的目的。
-
绕过思路
:既然不能执行用户态代码,那我们就执行内核态已有的代码。这就是
ROP (Return-Oriented Programming)
技术。我们需要在内核的
-
SMAP (Supervisor Mode Access Prevention)
:CPU级保护,禁止内核态访问用户态内存页的数据。这会影响我们使用用户态缓冲区来存储ROP链或中间数据。
-
绕过思路
:将ROP链直接布置在能够被内核访问的内存区域,比如
内核栈本身
(通过溢出写入),或者通过某些漏洞先泄露一个内核堆/栈地址,然后将数据写入那个已知的内核地址。另一种方法是利用内核中像
swapgs和iretq这样的特殊指令序列,在返回用户态前完成状态切换,但这需要更精密的控制。
-
绕过思路
:将ROP链直接布置在能够被内核访问的内存区域,比如
内核栈本身
(通过溢出写入),或者通过某些漏洞先泄露一个内核堆/栈地址,然后将数据写入那个已知的内核地址。另一种方法是利用内核中像
-
KASLR (Kernel Address Space Layout Randomization)
:内核镜像加载的基址每次启动都会随机化。我们不知道
gadget的具体地址。-
绕过思路
:
必须先实现一次信息泄露
。我们需要利用驱动提供的另一个功能(比如一个可以读取内核数据的
ioctl命令),泄露出一个内核指针,例如内核栈地址或内核代码段指针。通过这个泄露的地址与已知偏移,我们可以计算出当前运行内核的基址,从而推算出所有gadget的绝对地址。
-
绕过思路
:
必须先实现一次信息泄露
。我们需要利用驱动提供的另一个功能(比如一个可以读取内核数据的
3.2 整体利用步骤规划
基于以上分析,我们的攻击链可以规划为以下四步:
-
信息泄露
:利用驱动合法的读功能,泄露出一个关键的内核地址(如
canary或某个函数指针)。 -
计算基址
:根据泄露的地址和已知的偏移,计算出内核镜像加载基址,进而得到
commit_creds、prepare_kernel_cred等目标函数以及ROP gadget的地址。 -
构造并注入ROP链
:利用栈溢出漏洞,将精心构造的ROP链写入内核栈。ROP链的目标是执行
commit_creds(prepare_kernel_cred(0)),将当前进程的权限提升为root。 - 状态恢复与返回用户态 :提权成功后,需要恢复被破坏的栈帧,平衡栈指针,并安全地返回到用户态,最终启动一个root权限的shell。
4. 关键环节实现与细节剖析
4.1 信息泄露:获取内核地址
在
core.ko
中,通常还存在另一个
ioctl
命令(例如
0x6677889B
),用于将内核中的某些数据拷贝回用户态。我们需要仔细逆向这个功能。
case 0x6677889B:
if (copy_to_user((void __user *)arg, some_kernel_data, sizeof(some_kernel_data)) != 0) {
return -EFAULT;
}
break;
这里的
some_kernel_data
可能就是包含栈
canary
或某个函数指针的局部变量或结构体。通过调用这个命令,我们可以获得这个值。
关键技巧
:在调试时,可以在
copy_to_user
处下断点,查看即将被拷贝的数据是什么,确认其是否是一个有价值的泄露点(比如,一个指向内核.text段的地址,或者一个在栈上的地址)。
假设我们泄露了一个
canary
和一个
内核栈地址
。
canary
用于在溢出时保持栈保护不被触发,而
内核栈地址
让我们能准确定位当前溢出栈帧的位置。
4.2 ROP链构造:以
commit_creds(prepare_kernel_cred(0))
为目标
这是内核提权最经典的“函数组合拳”。
prepare_kernel_cred(0)
创建一个root权限的
cred
结构体,
commit_creds()
则将这个结构体应用到当前进程。
在x86_64架构下,函数调用的前六个参数通过寄存器
rdi, rsi, rdx, rcx, r8, r9
传递。因此,我们的ROP链需要:
-
将
0放入rdi寄存器(prepare_kernel_cred的参数)。 -
跳转到
prepare_kernel_cred函数地址。其返回值(新的cred结构指针)会存放在rax寄存器中。 -
将
rax的值移动到rdi寄存器(commit_creds的参数)。 -
跳转到
commit_creds函数地址。
我们需要寻找以下gadget:
-
pop rdi; ret:用于控制第一个参数。 -
mov rdi, rax; ... ; ret或xchg rdi, rax; ret等:用于传递返回值。有时可能找不到直接的mov rdi, rax,可能需要通过其他寄存器中转,例如pop rdx; mov rdi, rdx; ret配合一个存储rax值的栈位置。
使用
ROPgadget
或
ropper
这样的工具在
vmlinux
中搜索。例如:
ROPgadget --binary ./vmlinux | grep "pop rdi ; ret"
找到gadget后,结合泄露出的内核基址,计算出它们的绝对地址。
4.3 栈布局设计与溢出数据构造
这是最精细的一步。我们需要精确计算溢出数据在栈上的布局。通过调试,在触发溢出的函数入口下断点,查看
local_buf
的地址与返回地址保存位置的偏移。
假设
local_buf
距离栈帧底部(即保存的
RBP
和
RET
地址)有
offset
个字节。那么我们的payload结构如下:
[ 0x40 bytes of filler ] + [ canary ] + [ saved RBP ] + [ ROP Chain ]
-
filler:填满local_buf的0x40字节,可以是任意值。 -
canary:我们之前泄露出来的值,必须原封不动地写回去,否则函数返回前检查canary时会发现被修改,触发stack-protector导致崩溃。 -
saved RBP:可以覆盖为一个可控的、不会导致崩溃的值,通常可以覆盖为0或者一个用户态地址(但要注意SMAP)。有时为了简化,也可以填充为无意义数据。 -
ROP Chain:从返回地址的位置开始写入。第一个地址就是pop rdi; retgadget的地址。
一个具体的payload构造示例(假设偏移已计算好):
from pwn import *
# 假设通过泄露计算出的地址
kernel_base = 0xffffffff81000000
pop_rdi_ret = kernel_base + 0xdeadbeef
prepare_kernel_cred = kernel_base + 0xbeefdead
commit_creds = kernel_base + 0xcafebabe
# 假设找到了一个 `mov rdi, rax; pop rbp; ret` 的gadget
mov_rdi_rax_pop_rbp_ret = kernel_base + 0x12345678
# 构造payload
payload = b'A' * 0x40 # 填充 local_buf
payload += p64(canary) # 正确的 canary
payload += p64(0) # 覆盖 saved rbp (可忽略)
payload += p64(pop_rdi_ret) # gadget 1: pop rdi; ret
payload += p64(0) # rdi = 0
payload += p64(prepare_kernel_cred) # 调用 prepare_kernel_cred(0)
payload += p64(mov_rdi_rax_pop_rbp_ret) # gadget 2: 将rax(cred)移到rdi, 并pop rbp
payload += p64(0) # 填充 pop rbp 所需的栈空间
payload += p64(commit_creds) # 调用 commit_creds(rdi)
payload += p64(0xffffffff81e00b10) # 一个常见的 `swapgs; ret` gadget地址,用于恢复状态
payload += p64(0xffffffff810202d8) # 一个常见的 `iretq` gadget地址
# iretq 需要从栈上弹出: RIP|CS|RFLAGS|SP|SS
# 这里需要布置返回用户态后的地址,例如一个执行 system("/bin/sh") 的用户态函数地址
payload += p64(userland_shell) # RIP: 用户态shell代码地址
payload += p64(0x33) # CS: 用户态代码段选择子
payload += p64(0x246) # RFLAGS: 中断标志,一般保持原样或设为0x202
payload += p64(userland_stack) # RSP: 用户态栈指针
payload += p64(0x2b) # SS: 用户态数据段选择子
4.4 状态恢复与返回用户态
执行完
commit_creds
后,内核当前处于异常上下文。我们不能简单地
ret
回一个地址,因为栈可能已经不平衡,且CPU仍处于内核模式。我们需要借助两个特殊的gadget:
-
swapgs; ret:swapgs指令交换GS寄存器的内核基址和用户态基址。在内核入口时,GS被设置为指向内核数据结构;在返回用户态前,需要交换回来。 -
iretq; ret:iretq(中断返回)指令用于从中断或系统调用处理程序返回到用户态。它会从栈上依次弹出RIP, CS, RFLAGS, RSP, SS。我们需要在栈上提前布置好这些值,其中RIP指向我们用户态准备好的获取shell的代码(比如一个调用system("/bin/sh")的函数),RSP指向一个有效的用户态栈地址。
5. 完整利用脚本与调试技巧
5.1 利用脚本框架
一个完整的利用脚本(使用
pwntools
)会包含以下部分:
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
context.log_level = 'debug'
# 1. 连接与初始化
io = process(['./start.sh']) # 或 remote('靶机', 端口)
# 或者通过题目给的启动脚本启动
# 2. 信息泄露
def leak():
# 调用泄露功能的ioctl
# 接收数据并解析出内核地址和canary
global kernel_base, canary, kernel_stack_addr
# ... 具体操作
pass
# 3. 计算gadget地址
def calc_gadgets():
# 基于泄露的地址计算基址,然后加上gadget偏移
# ... 具体计算
pass
# 4. 构造并发送溢出payload
def exploit():
payload = construct_payload()
# 调用存在溢出漏洞的ioctl,发送超长payload
io.send(payload)
# 5. 返回用户态后获取shell
def get_shell():
# 布置用户态的shellcode,例如通过mmap分配可执行内存
shellcode = asm(shellcraft.amd64.linux.sh())
# 将shellcode地址布置到iretq帧的RIP中
# ...
if __name__ == '__main__':
leak()
calc_gadgets()
exploit()
# 触发漏洞后,进程会跳转到我们布置的用户态shellcode
io.interactive()
5.2 调试技巧与常见问题
-
内核崩溃分析
:如果利用失败导致内核
panic,qemu会输出崩溃信息。关注RIP寄存器指向的地址和调用栈(Call Trace)。这能帮你判断是跳转到了错误地址,还是执行了非法指令。 -
GDB调试技巧
:
-
lx-symbols:在gdb中加载内核符号后,使用lx-symbols命令可以自动加载所有内核模块的符号,对于分析core.ko非常有用。 -
pt-regs:当断点命中在内核函数时,可以使用pt-regs命令打印出当前中断帧的所有寄存器,这对于理解函数调用上下文至关重要。 -
在溢出点下断点
:在
copy_from_user调用之前下断点,单步执行,观察栈布局,精确计算偏移。
-
-
Canary验证失败
:这是最常见的问题。确保你泄露的
canary是正确的,并且在payload中放置的位置完全对应它在栈上的原始位置。canary的最低字节通常是\x00(空字节),用于防止字符串操作误泄 ,在构造payload时要注意不能截断。 -
SMEP/SMAP违规
:如果
dmesg或崩溃信息中出现smep或smap相关提示,说明你的ROP链尝试执行了用户态内存(SMEP)或者访问了用户态数据(SMAP)。检查你的gadget地址是否都在内核.text段,以及ROP链操作的数据是否都在内核地址空间。 -
栈对齐问题
:x86_64架构在调用函数时,要求栈指针
RSP在调用call指令前是16字节对齐的(即RSP % 16 == 0)。在构造ROP链时,如果ret到的gadget不是通过call进入的,可能不需要严格对齐,但为了稳定,最好在关键函数调用前通过ret到一个只包含ret指令的gadget来调整栈对齐。
6. 总结与经验之谈
这道“强网杯core”赛题,虽然只是一个CTF题目,但它完美地浓缩了现实世界中Linux内核栈溢出漏洞利用的核心技术链条。从信息泄露绕过KASLR,到ROP构造绕过SMEP/SMAP,再到精细的栈布局和状态恢复,每一步都考验着漏洞利用工程师的基本功。
在实际漏洞挖掘和利用中,情况往往更复杂:漏洞可能不是简单的线性溢出,可能是竞争条件、整数溢出导致的大小计算错误;目标系统可能还有
KPTI
(页表隔离)、
CFI
(控制流完整性)等更高级的防护;可用的gadget可能非常有限,需要更巧妙的链式构造。
我个人的体会是,内核漏洞利用就像在瓷器店里打斗,力量大了(覆盖错了数据)整个系统就崩了,力量小了(利用不完整)又达不到效果。它要求你对计算机体系结构、操作系统原理有深刻的理解,同时又要具备像外科医生一样的精细操作能力。多调试、多分析崩溃日志、多阅读优秀的漏洞利用案例(如CVE分析),是提升这项技能的唯一途径。最后, 永远在隔离的虚拟机或专用硬件中进行实验 ,内核崩溃是家常便饭,不要在你的开发机上直接尝试。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)