环境搭建
一个完整的 Kernel Pwn 环境通常包含以下三个核心文件:
| 文件 | 作用 |
|---|---|
bzImage |
压缩后的 Linux 内核镜像文件 |
rootfs.cpio |
精简的文件系统(包含基本的 Linux 命令和你的 Pwn 漏洞程序) |
vmlinux |
未压缩的、带有调试符号的内核文件(用于 GDB 调试时加载符号和源码) |
接下来我们将一步步完成环境的搭建。
第一步,更新系统并安装编译内核、构建文件系统和运行 QEMU 所需的依赖:
1 | sudo apt-get update |
第二步,我们开始编译Linux内核,我们可以前往 Index of /pub/linux/kernel/ 网站下载你所需的Linux内核。
下载并解压源码。(此过程可能会有点久)
1 | wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.15.109.tar.xz |
配置编译选项。
1 | # 生成默认的 x86_64 配置 |
在 menuconfig 界面中,你需要开启调试信息,并关闭一些干扰初学者调试的保护机制。具体涉及的选项如下:
| 配置项 | 设置 | 作用 |
|---|---|---|
CONFIG_DEBUG_INFO |
y | 生成调试符号,GDB 调试内核时可以看到源码行号和变量 |
CONFIG_GDB_SCRIPTS |
y | 启用内核自带的 GDB 辅助脚本(lx-symbols 等命令) |
CONFIG_RANDOMIZE_BASE |
n | 关闭 KASLR(内核地址随机化),调试时地址固定 |
CONFIG_STACKPROTECTOR |
n | 关闭内核栈金丝雀,方便练习内核栈溢出 |
CONFIG_PAGE_TABLE_ISOLATION |
n | 关闭 KPTI 页表隔离(新版本内核为 CONFIG_MITIGATION_PAGE_TABLE_ISOLATION) |
注意 SMEP、SMAP 不是编译选项,它们由 CPU 特性决定,在后面 QEMU 启动参数中通过 -cpu qemu64,-smep,-smap 关闭。
开始编译,可以调用多个内核加速编译:
1 | make -j8 |
编译完成后,你会在以下路径找到我们需要的文件:
- 带有符号的内核二进制:源码根目录下的
vmlinux - 可启动的压缩镜像:
arch/x86/boot/bzImage
将上述两个文件拷贝到你的工作目录,或者说是实验环境。
第三步,构建精简文件系统。首先编译 busybox,它是构建精简文件系统最常用的工具,一个静态二进制即可提供 sh、mount、cp 等几乎所有基础命令:
1 | wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2 |
接着在 _install 目录基础上补全目录结构,并编写 init 脚本。init 是内核启动后执行的第一个用户态程序,负责挂载虚拟文件系统、加载漏洞驱动、以普通用户身份启动 shell:
1 | cd _install |
init 脚本内容如下:
1 |
|
别忘了给它加上执行权限,然后将整个目录打包为 cpio 格式的 rootfs:
1 | chmod +x init |
后续每次修改 exp 或驱动,只需把新文件放进该目录重新执行打包命令即可。
第四步,编写 QEMU 启动脚本 run.sh:
1 |
|
各参数要点:
-kernel/-initrd:指定我们编译好的内核镜像与文件系统。-append:内核启动参数,console=ttyS0把控制台重定向到串口(配合 -nographic),nokaslr关闭 KASLR。-cpu qemu64,-smep,-smap:关闭 SMEP/SMAP,练习阶段先拆掉这两道防线。-s:在 1234 端口开启 GDB 调试服务,等价于-gdb tcp::1234。-S(大写,可选):启动时冻结 CPU,等 GDB 连接后才开始执行,调试内核启动早期代码时使用。
第五步,使用 GDB 调试内核。保持 QEMU 运行,另开终端:
1 | gdb ./vmlinux |
1 | (gdb) target remote localhost:1234 # 连接 QEMU 的调试服务 |
lx-symbols 需要编译内核时开启 CONFIG_GDB_SCRIPTS。若要调试驱动模块内部的代码,可在 insmod 后用 add-symbol-file vuln.ko <模块加载基址> 手动加载符号,基址通过 cat /proc/modules 或 lx-symbols 自动获取。
至此,一个可编译、可运行、可调试的 Kernel Pwn 环境就搭建完成了。
内核漏洞利用基础
内核态与用户态
x86-64 通过特权级隔离代码权限:用户态运行在 ring3,内核运行在 ring0。用户态程序通过 syscall 指令陷入内核,内核在 ring0 下以全部权限执行请求后返回。Linux 的虚拟地址空间也因此一分为二:
- 用户态:
0x0000000000000000~0x00007fffffffffff,普通进程的代码、堆栈都在这里。 - 内核态:
0xffff800000000000以上的高端地址,所有进程共享同一份内核映射。
内核漏洞利用的目标,就是借助漏洞让内核在 ring0 下执行我们期望的操作——最常见的是篡改当前进程的凭证实现提权。
内核保护机制
内核有一系列与用户态类似(且更严格)的防护机制,搭环境时我们将其关闭,实际利用时则需要逐一绕过:
| 机制 | 防护点 | 关闭/对抗方式 |
|---|---|---|
| KASLR | 内核镜像与模块地址每次启动随机化 | nokaslr 启动参数关闭;实战中需信息泄露获取基址 |
| SMEP | 禁止内核直接执行用户态页面的代码 | nosmep / -cpu 参数关闭;实战中需内核 ROP 或 ret2dir 等绕过 |
| SMAP | 禁止内核直接读写用户态页面的数据 | nosmap / -cpu 参数关闭;绕过思路与 SMEP 类似 |
| KPTI | 用户态与内核态页表隔离(Meltdown 缓解) | nopti 关闭;返回用户态时需借助 trampoline 切页表 |
| STACKPROTECTOR | 内核栈金丝雀,检测栈溢出 | 编译选项关闭;实战中需先泄露 canary |
| SLUB 加固 | freelist 指针加密、随机化(FREELIST_HARDENED/RANDOM) | 增加堆喷与 UAF 利用难度 |
用户态与内核态数据交互
内核漏洞题通常以驱动模块(.ko)形式给出,用户态通过打开设备节点并调用 ioctl 与驱动交互。驱动读写用户态数据必须使用专用接口:
1 | unsigned long copy_from_user(void *to, const void __user *from, unsigned long n); |
一个典型的最小漏洞驱动骨架如下,其中 vuln_ioctl 里的 copy_from_user 把用户数据拷入固定大小的内核栈缓冲区而长度由用户控制,形成内核栈溢出:
1 |
|
用户态交互代码形如:
1 | int fd = open("/dev/vuln", O_RDWR); |
提权原理
Linux 中每个进程的权限由 task_struct 指向的 cred 结构体描述,里面是 uid、gid、euid、egid 等一组 ID,全为 0 即 root。因此提权的标准动作是把当前进程的 cred 替换为 root 凭证,内核提供了两个现成函数:
1 | // 方式一:prepare_kernel_cred 构造一份 root cred,commit_creds 应用之 |
漏洞利用中获得内核态任意代码执行(或 ROP)后调用上述函数,再返回用户态执行 system("/bin/sh"),拿到的就是 root shell。
返回用户态
利用代码在内核态执行完后必须安全返回用户态,否则内核立即 panic,提权成果归零。标准的返回路径是内核函数 swapgs_restore_regs_and_return_to_usermode:它负责恢复 GS、切换页表(KPTI 场景),最终以 iretq 返回用户态,需要我们在栈上依次布置好用户态的 rip、cs、rflags、rsp、ss 五个值。
因此 exp 开头要先用内联汇编保存进入内核前的用户态现场:
1 |
|
栈上布置的五元组中,rip 指向 get_shell,cs/ss/rflags/sp 用 save_state 保存的值即可。这样内核利用代码执行完毕、iretq 一响,进程就以 root 身份回到用户态弹 shell,整个提权流程闭环。