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 | $ checksec /challenge/integration-cimg-screenshot-sc |
- 无 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 | ; rbx = 跳转表基址 (0x403478) |
notrack 前缀绕过了 IBT(不需要 endbr64
目标),这为劫持提供了条件。
3. 漏洞:handle_1337 栈溢出
handle_1337 从帧缓冲区截取 w * h
字节到栈缓冲区:
1 | sub rsp, 0x98 ; 分配 152 字节栈帧 |
栈布局(从低地址到高):
1 | rsp+0x00: 参数区(sprite_id, x, y, w, h 等) |
当截屏宽度 w >= 137 时,写入超出缓冲区边界,覆盖
saved rbx。
覆盖 saved rbx 的效果:下一个指令通过
notrack jmp rax 分发时,rbx 已被我们控制。由于
rax = [rbx + rcx*4] + rbx,我们可以让分发跳转到任意地址。
1 | notrack jmp rax ; rax = [rbx + rcx*4] + rbx |
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 | display[0] = 253, delta = 253 |
实际上存在更复杂的 skip 行为,但对于 exploit 中使用的偏移范围(18-142),2 字节旋转模型足够精确。
5. Payload 布局
利用指令 7(sleep)触发跳转:指令 7 对应跳转表索引
7 - 2 = 5,读取 [rbx + 5*4] = [rbx + 20]。
在 file 中的布局:
1 | file[0:18]: NOP sled (0x90) |
旋转补偿计算:
- 指令 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 | def make_cat_shellcode(path=b"/flag"): |
7. 调试历程
7.1 栈偏移校准
通过读取 /proc/self/stat 泄露
startstack,然后在本地和远程分别校准栈偏移:
1 | handle_1337 buffer = startstack - 0x11d0 |
关键:用于泄露和用于 exploit 的 .cimg
文件名必须相同(/tmp/exploit.cimg),因为不同的文件名长度会影响
argv 在栈上的布局。
7.2 旋转验证
填充 0xCC(INT3),在不同宽度下触发溢出:
1 | w=140: SIGSEGV ← 4 字节溢出已触及 saved rbx 低位 |
所有宽度都崩溃,确认 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 | # 错误: |
修复后 flags 到手。
8. 完整 Exploit
1 | $ ssh hacker@dojo.pwn.college 'python3 /tmp/exploit_final.py' |
完整 exploit 脚本
1 | #!/usr/bin/env python3 |
9. 关键要点
数据流不透明时,实证测试优先于静态分析——handle_4 的渲染旋转无法仅通过阅读反汇编代码完全理解,但用标记 payload 验证只需要几分钟。
mov al, N——在 x86-64 上设置 syscall 号时,必须先用xor eax, eax清零,或使用mov eax, N(32 位 mov 自动清零高 32 位)。SHSTK + IBT 绕过——
notrack jmp前缀同时绕过间接分支追踪和影子栈检查。覆盖 saved rbx 而非返回地址是正确的选择。利用文件名影响栈布局——泄露和利用使用相同的文件名,避免 argv 差异导致栈偏移变化。