Linux 内核漏洞

环境搭建

一个完整的 Kernel Pwn 环境通常包含以下三个核心文件:

文件 作用
bzImage 压缩后的 Linux 内核镜像文件
rootfs.cpio 精简的文件系统(包含基本的 Linux 命令和你的 Pwn 漏洞程序)
vmlinux 未压缩的、带有调试符号的内核文件(用于 GDB 调试时加载符号和源码)

接下来我们将一步步完成环境的搭建。

第一步,更新系统并安装编译内核、构建文件系统和运行 QEMU 所需的依赖:

1
2
3
sudo apt-get update
sudo apt-get install -y build-essential libncurses-dev bison flex libssl-dev libelf-dev bc git curl
sudo apt-get install -y qemu-system-x86 gdb cpio

第二步,我们开始编译Linux内核,我们可以前往 Index of /pub/linux/kernel/ 网站下载你所需的Linux内核。

下载并解压源码。(此过程可能会有点久)

1
2
3
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.15.109.tar.xz
tar -xvf linux-5.15.109.tar.xz
cd linux-5.15.109

配置编译选项。

1
2
3
4
5
# 生成默认的 x86_64 配置
make x86_64_defconfig

# 进入图形化配置界面
make menuconfig

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
2
3
4
5
6
7
8
9
wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2
tar -xjf busybox-1.36.1.tar.bz2
cd busybox-1.36.1

# 进入配置界面,勾选 Settings -> Build static binary (no shared libs)
make menuconfig

make -j8
make install # 生成 _install 目录,里面就是基本的文件系统骨架

接着在 _install 目录基础上补全目录结构,并编写 init 脚本。init 是内核启动后执行的第一个用户态程序,负责挂载虚拟文件系统、加载漏洞驱动、以普通用户身份启动 shell:

1
2
3
4
5
6
7
8
cd _install
mkdir -p proc sys dev etc/init.d

# 最小化的用户配置
echo "root:x:0:0:root:/root:/bin/sh" > etc/passwd
echo "user:x:1000:1000:user:/home/user:/bin/sh" >> etc/passwd
echo "root:x:0:" > etc/group
echo "user:x:1000:" >> etc/group

init 脚本内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev

# 加载漏洞驱动模块并放行设备节点权限
insmod /vuln.ko
chmod 666 /dev/vuln

# 以 uid=1000 的普通用户身份启动 shell
setsid cttyhack setuidgid 1000 sh

# 退出 shell 后关机
poweroff -d 0 -f

别忘了给它加上执行权限,然后将整个目录打包为 cpio 格式的 rootfs:

1
2
chmod +x init
find . | cpio -o --format=newc > ../rootfs.cpio

后续每次修改 exp 或驱动,只需把新文件放进该目录重新执行打包命令即可。

第四步,编写 QEMU 启动脚本 run.sh:

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
qemu-system-x86_64 \
-m 256M \
-kernel bzImage \
-initrd rootfs.cpio \
-append "console=ttyS0 nokaslr quiet" \
-cpu qemu64,-smep,-smap \
-smp 1 \
-nographic \
-monitor /dev/null \
-s

各参数要点:

  • -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
2
3
4
5
(gdb) target remote localhost:1234     # 连接 QEMU 的调试服务
(gdb) lx-symbols # 加载内核 GDB 脚本,自动识别模块符号
(gdb) b start_kernel # 在内核启动入口下断点
(gdb) b vuln_ioctl # 也可以直接断漏洞驱动的函数
(gdb) c

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
2
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);

一个典型的最小漏洞驱动骨架如下,其中 vuln_ioctl 里的 copy_from_user 把用户数据拷入固定大小的内核栈缓冲区而长度由用户控制,形成内核栈溢出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>

#define VULN_DEVICE "vuln"

static long vuln_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
char buf[64]; // 内核栈上的固定缓冲区
// cmd=1 时把用户数据拷入 buf,长度未校验 —— 漏洞点
if (cmd == 1)
copy_from_user(buf, (void __user *)arg, 0x400);
return 0;
}

static struct file_operations vuln_fops = {
.unlocked_ioctl = vuln_ioctl,
};

static int __init vuln_init(void) {
register_chrdev(233, VULN_DEVICE, &vuln_fops);
return 0;
}

static void __exit vuln_exit(void) {
unregister_chrdev(233, VULN_DEVICE);
}

module_init(vuln_init);
module_exit(vuln_exit);
MODULE_LICENSE("GPL");

用户态交互代码形如:

1
2
int fd = open("/dev/vuln", O_RDWR);
ioctl(fd, 1, payload); // 触发 copy_from_user,payload 超长即溢出

提权原理

Linux 中每个进程的权限由 task_struct 指向的 cred 结构体描述,里面是 uid、gid、euid、egid 等一组 ID,全为 0 即 root。因此提权的标准动作是把当前进程的 cred 替换为 root 凭证,内核提供了两个现成函数:

1
2
3
4
5
6
// 方式一:prepare_kernel_cred 构造一份 root cred,commit_creds 应用之
// 新内核(5.19+ 及回移植版本)参数传 NULL 即表示以 init 的 cred 为模板
commit_creds(prepare_kernel_cred(NULL));

// 方式二:直接套用 init 进程的静态 cred
commit_creds(&init_cred);

漏洞利用中获得内核态任意代码执行(或 ROP)后调用上述函数,再返回用户态执行 system("/bin/sh"),拿到的就是 root shell。

返回用户态

利用代码在内核态执行完后必须安全返回用户态,否则内核立即 panic,提权成果归零。标准的返回路径是内核函数 swapgs_restore_regs_and_return_to_usermode:它负责恢复 GS、切换页表(KPTI 场景),最终以 iretq 返回用户态,需要我们在栈上依次布置好用户态的 rip、cs、rflags、rsp、ss 五个值。

因此 exp 开头要先用内联汇编保存进入内核前的用户态现场:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>
#include <stdlib.h>

size_t user_cs, user_ss, user_rflags, user_sp;

void save_state() {
__asm__(
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
}

void get_shell() {
if (getuid() == 0) {
system("/bin/sh");
}
}

int main() {
save_state();
// ... 触发漏洞、内核 ROP 调用 commit_creds(prepare_kernel_cred(NULL)) ...
// 返回用户态后 rip 指向 get_shell
get_shell();
return 0;
}

栈上布置的五元组中,rip 指向 get_shell,cs/ss/rflags/sp 用 save_state 保存的值即可。这样内核利用代码执行完毕、iretq 一响,进程就以 root 身份回到用户态弹 shell,整个提权流程闭环。