-
-
[原创] KCTF260A 第十题 曦光初现 Writeup
-
发表于: 1天前 380
-
人言
AI 言
十二辰终,曦光初现,是为新始。
破壁人立于塔顶,看第一缕阳光刺破永夜,照在十重试炼之上。
题目文件:pwn(x86-64 PIE / stripped / C++)+ libc-2.27.so(Ubuntu 18.04)
靶机:nc 123.57.66.184 10099
最终战绩:本地 root shell(3/3 稳定),远程 flag{6a433994-ca43-4c12-9e4b-6b87c35f4b4e}
这题是整个 KCTF260A 的收官之战。它没有菜单、没有堆叠打印、没有任何数据回显——程序唯一的"语言"只有三种:沉默、invalid syntax! 和 runtime error!。一个刻意做成的盲注世界:永夜之城,你在黑暗里摸索,直到找到那道唯一的光。
本文分两部分:第一部分是 Zcode + GLM-5.3 从零逆向到移交的分析攻关;第二部分是 Deepseek Harness(DSH)接手后突破最后一个卡点、拿下 flag 的收尾实录。
(执行:Zcode 编码智能体 + GLM-5.3,2026-08-23 12:02 – 14:00,全程无人工干预)
file pwn:ELF64 PIE,stripped,依赖 libstdc++。checksec 级别的信息一眼扫完:Full RELRO(BIND_NOW)、NX、Canary——所有传统出口都被焊死。
跑起来是这样的:
成功 → 沉默;解析失败 → invalid syntax!;求值失败 → runtime error!。没有数值输出,程序也不退出。 strings 里能看到的只有:"> "、"invalid syntax!"、"runtime error!"、"run out of memory, garbage collection has not been implemented yet" 和一个 /dev/urandom。
最后那句"垃圾回收尚未实现"是个黑色幽默——它会成为理解整道题的钥匙之一。
objdump -d -M intel 全量反汇编塞进 full.asm(约 2500 行),从 entry 的 __libc_start_main 参数定位 main(0x2d09)。读出来的骨架:
0x2b14 是命令分发器:输入必须以 $ 开头,前导十进制数字(寄存器号,要求 ≤255)之后按跳转表(基址 0x3fa8,索引 = 字符 - 0x25)分发。这里埋着本阶段的第一个坑,也是第一处"手感"的考验:
跳转表解码错位一格,把 = 看成了 <。
静态算出的表项把 <(0x3c)映射到 store/deref 分支,实测全部 invalid syntax!。用 gdb 断在 switch 处单步,才发现真正的操作符是 =(0x3d),< 是默认分支。差一格,谬以千里。
修正后,六类命令的完整语义浮出水面:
配套的内存模型(全部 PIE 相对):
这是全题最精巧的设计。序列化到 arena 里的每个 qword 都与 key 异或:
而"走路径"的原语 1eda(node, arg)(符号地址,由 class D/E 的 eval 反复调用)逻辑是:
读(D)在叶子上做 key ^ [leaf],写(E)在叶子上落 regs[A] ^ key——一进一出,key 恰好抵消。这个"抵消"后来成为零 oracle 泄漏的基石。
只要能在 arena 里写出一个"假 list 头",walker 就会以我们宣称的 count 为准向前走——越过 arena 的 64KB 边界,读/写其后的整个堆。
构造假头只需要算术:
此后 $X=$7[1][arg] / $7[1][arg]=$V 就是 arena+24+8*arg 处的任意读/写——arg 可以是 40 亿以内的任何数,只要走得到。
盲注题的传统打法是 1-bit oracle 二分探值,一泄就是上百次交互。这里有一条漂亮得多的路——让泄漏直接留在寄存器里,全程无需看见:
读出来的是 key ^ 真值,再 $reg^$20(key)即得裸值。0 次 oracle,3 个基址。
这一步踩掉的坑值得一记:最初读的是"上一条命令遗留对象"的 vptr 槽——但 main 求值后会 delete 命令对象,槽位被 free 复用清零,读到 0。那个"garbage collection has not been implemented yet"的字符串果然是烟雾弹:垃圾回收实现得很好,所以必须读"自己"。
F 类算术禁止 0x1337-tag 操作数(这正是防伪造的检查),但加法天然带进位:
0x1336FFFFFFFFFFFF 高位是 0x1336,不触发检查;X+1 是 48 位裸值,也不触发。一次加法,合法地造出任意 tagged 指针。先算 X+1,再加 tag-1——方向错了就会差 1(这个坑留给第二部分的主人公再踩一次)。
把假头写进 regs[200](注意 walker 解码会再 XOR key,所以锚点要存 beef|huge ^ key——又一个静默坑),伪造 0x1337|(pie+0x2068e0),从寄存器区向前走:路径 [55] 落在 begin、[56] 落在 cur。
至此,收尾链的设计已经完整:
触发点选在"行字符串 free"而非程序退出,是因为 atexit 里 arena 析构会拿被改写的 begin 去 delete[]——脏,但那是拿到 shell 之后的事。
Zcode + GLM-5.3 阶段的产出沉淀为 ANALYSIS.md(完整逆向语义、vtable 表、堆布局、已验证原语、卡点与排查方向)与 exploit.py(45 条命令的全链雏形)。本地 socat + patchelf 出的 glibc2.27 原生运行环境下:
期间为贴近远程环境搭了整套复现设施:从 Ubuntu 18.04 deb 解包 ld-2.27.so / libc / libstdc++ / libgcc / libm,patchelf 重写 PT_INTERP 与 RUNPATH 得到原生运行的 pwn27,再用 socat 起本地服务配 gdb(关 ASLR 后 PIE 基址固定 0x555555400000)逐断点验证。
本阶段用时与成本:
简评:GLM-5.3 在"读 2500 行无符号 Intel 汇编还原一套 DSL 语义"上表现出色——六类对象、双重 XOR 编码、walker 递归语义全部一次推对;三个致命坑(操作符 =、锚点 ^key、对象生命周期)也都在 2–3 轮试错内用 gdb 实锤。最后停在 cmd37,属于"设计正确、收尾欠一哆嗦"。
(执行:Deepseek Harness,模型 deepseek-v4-flash,14:03 接手 ANALYSIS.md)
DSH 接手后第一件事不是改代码,而是复现:
同一份命令,两种死法。DSH 判断:先修数据一致性,再看崩溃。
对比发现 seq.txt 第 15 条是 $25=2120224(= 0x205a20,class F vtable),而 exploit.py 生成的是 $25=2120144(= 0x2059d0,class D vtable)。手工改坏的旧文件让 PIE 基址差了 0x50:
修复:用 gen_seq.py 从 exploit.py 重新生成序列,杜绝手工文件。
修正常量后,cmd37 的死活从"薛定谔"变成"必死"——这才暴露真正的根因。gdb 断在序列化器(0x31dc)与越界检查(0x3201):
死亡回放:$30=[1,2,3] 在 cur 处写入 4 个 qword:header@0x3ed8d0、子@0x3ed8d8、0x3ed8e0、0x3ed8e8——第三个子恰好落在 __free_hook 上,写入 3 ^ key(随机垃圾)。求值成功,main delete 命令对象树,第一个 free() 发现 hook 非空,call 一个非规范地址,SIGSEGV。
而且这不是巧合,是数学上不可避免的死局:
要从某个 beef 节点 walk 到 hook,叶 node+8+8*arg = hook 且 arg < count——那么序列化器写的第 arg 个子必然落在 hook 上,值 = 字面量 ^ key(运行期随机)。单次序列化必然污染 hook,而 hook 一旦污染,本命令自己的 delete 就会引爆它。
备选思路被逐一否决:嵌套列表只能平移末子位置;arg == count 的越界叶被 E-eval 预检 arg < count 禁止;想在解析期用 key^key=0 抵消——盲注拿不到 key。
把锚点下移到 hook-0x20 试图避让?硬件 watchpoint 立刻抓到凶手:0x3ed8c8 被反复写 0 ↔ 0x7ffff7ff6080,每一次打印都发生。核对 FILE 结构:stderr+0x88 → 0x3ed8b0,stdout+0x88 → 0x3ed8c0——那是 stderr/stdout 的 _IO_lock_t。锚点放这儿,头刚布好就被 stdio 清零。
实测安全区只有 0x3ed8d0..0x3ed8df。
死局的解法是分两次落子:
序列化负责"到 hook 门口但不进门",E 写负责"从隔壁台阶一步跨进 hook"。两者都不是单独可行——组合起来,死局解开。
收尾阶段又拧出三个语义级 bug(每个都由 gdb 实锤):
另外,hook=system 设置之后,本命令自己的 delete 就会触发若干次 system(对象内存)——实测无害:对象首字节是 vptr 的小端序列(\xf8\x59@UUU),sh 报 "not found" 秒退,不读 stdin。这也是 hook 必须放在最后一条写命令的原因。
本地 socat,53 条命令全部静默:
远程 123.57.66.184:10099:53 条命令同样全绿,jail shell(uid=1000,/dev/null 不可写,连 id 都没有),getflag.py 专门做了无重定向兼容——
DSH 阶段用时与成本(引自 DSH_ANALYSIS.md):
复盘整条链,它的美在于每一环都在跟"看不见"作斗争,却没有一次向"看不见"低头:
设计者的防御几乎无懈可击:随机 key 编码、tag 检查、walker 只进不退、序列化上限、"垃圾回收"回收了每一个对象——而攻击者用编码的自抵消(XOR 一进一出)、进位的合法性(加法不检查结果)、锁与锁之间的缝隙(0x3ed8d0 那 16 字节的安全区),把每一条规则都变成了踏脚石。
两个智能体的接力也值得记录:GLM-5.3 用两小时在黑暗里画完了整张地图,停在离 flag 一步之遥的 cmd37;DSH 用五十分钟找到那两个藏得极深的根因(一个 0x50 的笔误,一个"序列化末子必污染 hook"的数学死局),用阶梯方案完成最后一步。总共约 2 小时 46 分钟、约 ¥22 的成本,永夜城迎来了它的第一个白天。
红裙女孩站在街角,抬起头——她终于等到了白天。
而路灯熄灭的位置,正好是 __free_hook 被写成 system 的那一行。
从 bionic deb 解出 ld-2.27.so + libc/libm/libstdc++/libgcc,patchelf --set-interpreter/--set-rpath 得到原生 2.27 运行的 pwn27(膨胀到 2MB 是 interp 串加长触发新段);socat 本地起服务;gdb 关 ASLR(PIE 固定 0x555555400000)断点直接写 0x55555540xxxx。全链运行期取址,ASLR 开关无关——这正是远程一次通过的原因。
十二辰尽,曦光初现。时辰流转,守护永存。
| 语法 | 类 | 求值语义 |
|---|---|---|
$N=<纯数字> |
C | regs[N] = 数字 & 0xFFFFFFFFFFFF(48 位截断) |
$N=[a,b,...] |
C | 嵌套 list 序列化追加进 arena(cur 处),regs[N] = 0x1337<<48 | cur |
$N=$M[i][j]... |
D | 沿 regs[M] 结构按路径走,叶子解码值存 regs[N](满 64 位) |
$N[i][j]...=$M |
E | 沿 regs[N] 结构走,叶子写 regs[M] ^ key(满 64 位) |
$N<op>$M |
F | regs[N] = regs[N] <op> regs[M],op∈{+,-,*,/,%,^,&,|} |
| 地址 | 含义 |
|---|---|
0x2062a0 |
寄存器文件 regs[0..255](eval 的 rsi 指向此处) |
0x206aa0 / 0x206aa8 |
arena begin / cur |
0x206ab0 |
8 字节随机 key |
0x205938…0x205a20 |
六个类的 vtable |
| 项目 | 数值 |
|---|---|
| 总用时 | 约 1 小时 56 分钟(12:02–14:00) |
| 阶段划分 | 逆向语义与 vtable 梳理 ~40min;原语构造与本地验证 ~55min;cmd37 卡点排查 ~21min |
| 工具调用 | Bash/Read/Write 等约 200+ 次;gdb 脚本 22 个(v1–v22);中间产物 60+ 文件 |
| 上下文消耗 | 累计约 1.5M tokens(全量反汇编多次进出上下文) |
| 成本 | 约 ¥20(按 GLM-5.3 计费口径估算,精确账单以平台为准) |
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。