DawgCTF2026 Just Print It
一道只给源码、不给二进制的格式化字符串题。本地自行编译后秒通,但远程 win 地址和本地编译产物对不上 —— 用同一个格式化字符串漏洞的任意读,把远程 .text 和 .got.plt 整段 dump 下来,找到真实地址后改写 puts@GOT 拿到 flag。
题目概述
题目只给了一个交互式服务 nc nc.umbccd.net 8925,没有提供二进制,提供的是源码 in.c,编译命令在文件头注释里:
1 | gcc -fno-stack-protector -no-pie -z execstack -g -Wno-implicit-function-declaration in.c -o out |
1 | void win() { |
保护:-no-pie(地址固定)、-fno-stack-protector(无 canary)。
漏洞分析
printf(buffer) 格式化字符串漏洞
思路把 puts 的 GOT 表项 (0x404000) 改写成 win 的地址,下一次 puts 就会跳进 win(),打印 flag。
格式串在栈上的参数偏移为 6,用pwntools生成payload:
1 | fmtstr_payload(6, {puts: win}) |
踩坑:远程地址和本地对不上
题目没给二进制,于是本地用 in.c 自行 gcc 编译出 out 来调试。本地很快打通:
1 | # 本地 |
但同样的脚本打远程一直失败。根本原因:本地自行编译的产物和远程的二进制函数布局不同。虽然 -no-pie 地址固定,但两次编译(编译器版本/选项细节差异)会让各函数的偏移不一样:
- 本地
out:win入口在0x401216 - 远程:
win入口在0x401190
照搬本地的 0x401216 去打远程,自然改写到的是错误的地址。
解决:用同一个漏洞 dump 远程内存
既然没有远程二进制,就用格式化字符串的任意读把远程关键段整段拉下来。
(参考XSWCTF2025的题目nofile)
思路:把要读的地址放在栈偏移 9,用 %9$s 读出该地址处直到 \x00 的字节;格式串本身放在偏移 6,填充到 24 字节(= 偏移 6、7、8 三个 qword)后紧跟 p64(addr):
1 | fmt = b'LEAK:%9$s:END' # 14 bytes |
逐字节推进(遇到 \x00 跳过 1 字节)即可 dump 任意范围。脚本 dump 了两段:
.text:0x401000 – 0x401400→remote_text.bin.got.plt:0x403fe8 – 0x404040→remote_got.bin
dump.py
1 | from pwn import * |
从 dump 里找到真实 win
扫描 remote_text.bin 里的函数入口(endbr64 = f3 0f 1e fa):
1 | 0x401000, 0x4010b0, 0x4010e0, 0x401160, 0x401190, 0x4012dc |
注意 0x401190 处的字节是 f3 0f 1e fa | eb 8a | 55 48 89 e5 ...,即 endbr64; jmp 0x401120 的一个桩,并不是 win。真正的 win 函数体——push rbp; mov rbp,rsp; sub rsp,0x90 后用 lea 加载 "flag.txt" 字符串——从 0x401196 开始(与本地 out 中 win 的 prologue 序列一致,只是整体偏移不同)。payload 直接跳到 0x401196,打远程成功。
额外收获:GOT 里的 libc 地址
解析 remote_got.bin(起始 0x403fe8):
| 地址 | 内容 | 含义 |
|---|---|---|
| 0x403fe8 | 0x403df8 | _DYNAMIC |
| 0x403ff0 | 0x7ffff7ffe5f0 | link_map (GOT[1]) |
| 0x403ff8 | 0x7ffff7fda340 | _dl_runtime_resolve (GOT[2]) |
| 0x404000 | 0x401036 | puts@plt+6(未解析) |
| 0x404008 | 0x401046 | fclose@plt+6(未解析) |
| 0x404010 | 0x7ffff7e4e920 | setbuf(已解析,libc) |
| 0x404018 | 0x7ffff7e20900 | printf(已解析,libc) |
| 0x404020 | 0x7ffff7e45670 | fgets(已解析,libc) |
| 0x404028 | 0x7ffff7e45350 | fflush(已解析,libc) |
| 0x404030 | 0x401096 | fopen@plt+6(未解析) |
| 0x404038 | 0x4010a6 | exit@plt+6(未解析) |
dump 发生在 main 的 printf 处:此前已调用过 setbuf×3 / fflush / fgets / printf,故这些是已解析的 libc 真实地址;而 puts / fopen / exit / fclose 尚未被调用,仍指向各自 PLT stub。
这意味着即使没有 win,也能由此泄露 libc base、走 system("/bin/sh") / one_gadget 路线。但本题直接改 puts → win 最省事。
最终利用
1 | from pwn import * |
发送 payload 后,printf 把 puts@GOT 改写为 win,紧接着的 puts("\nGoodbye!") 跳入 win(),打印 flag。
总结 / 经验
- 格式化字符串 = 任意读 + 任意写,一个漏洞既能写 GOT 劫持控制流,也能
%s把远程内存整段 dump 下来。 - 题目不给二进制 + 自行编译时,绝不能假设本地地址等于远程地址。即便
-no-pie,不同编译产物的函数偏移也会变。务必用泄露/ dump 拿到远程真实地址。 - dump GOT 还能顺带白嫖 libc 基址,是没有
win时的通用后备方案。