-
-
[原创] 看雪·2026 KCTF 第十题:卯时·曦光初现 WP (AI)
-
发表于: 1天前 389
-
本题由 GPT-5.5 (xhigh effort) + Codex 完成, 用时约为 1 小时 10 分钟
GPT 的 Cyber 甲真是越来越厚了, 5.5 都开始给我爆 Cyber 了qwq
今年的 T 恤又没了呜呜呜 ~( TロT)σ
附件:
checksec:
保护全开,常规 GOT 改写不可用。题目本质是一个自定义表达式解释器,利用点在对象序列化格式和数组索引逻辑。
远程:
最终 flag:
程序启动后循环读取一行表达式,提示符为:
支持的语法大致如下:
样例 input.txt 中也能看到这些语法:
主循环逻辑:
逆向后能看到几个类名残留在 RTTI 中:
程序维护 256 个 64-bit VM 变量,执行 $0=1、$1=$0 等语句时会写入该变量区。
变量值有两种类型:
判断对象的逻辑等价于:
启动时程序分配一个 0x10000 大小的缓冲区作为自定义对象堆:
sub_344C 从 /dev/urandom 读取 8 字节到 qword_206AB0,作为全局 xor_key。
对象会被序列化到自定义堆中。
数字对象:
数组对象:
因此一个数组 [1,2] 在自定义堆中近似为:
数组在 VM 变量中保存为:
数组索引核心函数为 sub_1EDA,逻辑等价于:
注意:只要某个元素解码后高 16 bit 是 0xbeef,程序就会把它当作数组头递归解析。
利用 SetField 可以把一个普通数字写进数组元素:
实际写入内存的是:
之后访问:
流程为:
这就形成了一个受 xor_key 编码保护的 OOB read/write primitive。
程序不会直接打印变量值,所以需要通过 side channel 泄露。
构造假数组头后:
$0[0][0] 读到的是假数组头后面的第一个 qword。该位置通常为 0,所以经过程序解码后:
接下来要把 $1 的 64 bit 泄露出来。
二元运算 BinOp 会拒绝 object 类型参与运算。如果某个变量被构造成:
再参与运算就会触发:
所以可以用如下方式判断变量是否为 0:
若原本 $tmp == 0:
最后一步会触发 runtime error!。
若原本 $tmp == 1:
高 16 bit 不是 0x1337,不会触发错误。
先把待泄露变量复制到 $SHIFT,每轮:
这样 64 轮即可泄露一个 qword。相比最开始按 byte 从 0..255 爆破,速度快很多。
在 glibc-2.27 环境中,构造完 fake object 后,OOB 区域中有稳定指针。
脚本使用:
其中:
解码后再 xor 回去得到 heap_base + 0x10,于是可以算出 fake header 地址 H。
libc 泄露使用:
题目 libc 为 Ubuntu glibc 2.27,对应关键偏移:
理论上,通过 fake array 可以写任意位置:
SetField 写入时会执行:
也就是说,如果想让内存里出现 target_raw,VM 变量中应放:
但直接写 libc 地址需要极大的 idx,sub_1EDA 会线性扫描,远程不可接受。因此改为攻击附近的 heap/tcache 结构。
先用一条故意语法错误的数组表达式制造并释放多个 0x20 chunk:
末尾的 x 会导致 invalid syntax!,解析过程中分配出的 AST/Number/Object 被析构并释放,从而填充 0x20 tcache bin。
经过调试,后续 set_const 之后:
于是写:
由于程序会再异或一次 xor_key 写入内存,实际 tcache fd 变为:
触发语句:
解析过程中会多次 operator new(0x10) 分配 Number 对象。通过前面的 tcache poisoning,最后的 Number(system) 会被分配到:
Number 对象布局为:
因此 Number(system) 写对象时:
成功将:
__free_hook 被写成 system 后,只要让程序执行一次:
就会变成:
主循环每次读入一行后会构造 std::string。当输入长度超过 SSO 阈值时,字符串内容会放在 heap 上,析构时释放该 buffer。
发送:
# 后面作为 shell 注释,前面执行 /bin/sh -i,得到 shell。
最终脚本位于:
核心代码如下:
完整版以仓库里的 exp.py 为准。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。