-
-
[原创] 看雪·2026 KCTF 第十题:卯时·曦光初现
-
发表于: 1天前 383
-
一血在半小时左右出现,不过以题目的复杂度,感觉人类顶级pwn手在逆清楚后半小时应该也能出,算上逆向,一小时差不过也够了。
如今glibc已经演进到了2.44,猛然看到基于2.27的题目,结合题目的难度设定,恍如回到5年前的古法时代。这题其实蛮适合人类练习的,虽然AI能完全自己解出来,但少了手工一点点探索和调试的过程,读着最终的writeup味同嚼蜡。
今天我的网页ChatGPT一直被路由到GPT 5.5-mini(悲,手上能换的节点都试了;X和Reddit都有人在反馈,看Tibo怎么处置了);本地 Codex GPT-5.5 High ,wsl环境,耗时将近2小时完成,消耗了20%的Plus周额度;触发过几次Cyber拒答,但可以fork新对话改变措辞让它继续。
今年的KCTF抵达重点。然一日三秋,明年此刻的天下又将是什么格局?
人类
分隔线
AI
本题是一个 64 位 Linux PIE ELF Pwn 题。程序表面上不是菜单堆题,而是一个自定义表达式解释器:用户每次输入一行表达式,解释器解析成 AST,执行后释放 AST 相关对象。
最终确认的核心漏洞位于对象字段寻址函数:对象字段访问时长度检查允许 index == len,导致 one-past-end qword 读写。借助程序自身的对象编码规则,可以把这个 one-past 写扩展成从 arena 内部向后连续的 qword 读写能力,再覆盖 arena 后方的 tcache freed chunk fd 指针,最终把一次 Number 对象分配导向 __free_hook-8。
本地 ASLR 场景下,程序没有直接打印变量值的语法,也不能直接把解释器内部计算出的 system 地址作为 object literal 元素使用。最后使用 BinOp::exec 的 runtime error! 输出构造 bit oracle,逐 bit 泄漏解释器内部已经算出的 system 地址,再作为十进制 literal 写入 __free_hook。触发方式是发送一行非法语法但长度超过 SSO 的字符串,使 std::string 析构时 free(controlled_string) 进入 system。
最终本地 no-maps PoC:
验证输出:
这里 invalid syntax! 是故意触发 free 的副产物;关键证据是 Q10PWNED 已由 system("echo Q10PWNED #...") 执行输出。
题目目录:
主要文件:
pwn 与 pwn_raw 是同一个 64 位 Linux PIE ELF:
保护情况:
题目自带 libc:
关键 libc 符号偏移:
为了在本地稳定复现题目 glibc 环境,已提取 Ubuntu 18.04 对应运行库,并生成 patched 副本:
它使用题目目录内的 ld-2.27.so 和 glibc 2.27 运行库,避免系统 glibc 差异影响堆行为。
程序是一个小型解释器,不是传统菜单堆题。
交互行为很简单:
每次读取一行输入,解析为 AST,执行后释放。EOF 时主函数返回,并释放全局 arena。
RTTI 和字符串中可见的核心类名:
已确认支持的语法形态:
literal 支持:
程序可输出的信息面很窄,主要只有:
没有正常的“打印变量值”或“打印对象字段”语法,这也是后面必须构造 bit oracle 的原因。
程序启动后初始化一组全局运行时状态:
这里地址是 PIE 内偏移。
变量值分为数字和对象两类。对象变量使用高 16 位 tag:
对象在 arena 中序列化保存。对象头:
对象字段保存数字时:
字段读取时:
也就是说,arena 中对象头和普通数字字段都被同一个 cookie 编码。
Number::value() 会取低 48 位:
这点很重要,因为对象指针和普通数字都在一个 qword 里流动,高 16 位既被用作 tag,也会被 BinOp 检查。
对象字段寻址辅助函数位于 PIE 偏移:
其逻辑可概括为:
这里的错误是长度检查:
它只拒绝 index > len,却允许:
合法字段下标本应是:
实际允许访问:
因此 GetField / SetField 都可以触发 one-past-end qword 读写。
这个漏洞乍看只是一格越界,但由于字段内容本身可以被解释成对象头,并且对象头格式可由普通数字运算构造,最终可以扩展为大范围越界读写。
构造 fake object 的关键是写出一个明文对象头:
虽然 BinOp::exec 会拒绝高 16 位为 0x1337 的对象指针参与运算,但不会拒绝 0xbeef。因此可以用普通算术构造:
再加上需要的长度:
将这个值通过 SetField 写入对象字段时,程序会自动写入:
所以 arena 中得到的正好是合法的 encoded object header。
使用的典型对象布局:
arena 中大致为:
把 outer field[1] 改为 fake header 后:
会把 obj20+0x18 当作一个超长对象头,字段起点变成:
当前最终布局中,经过前置表达式后:
因此:
这就把 one-past-end 写扩展成了从 arena 后部向后的连续 qword 读写。
由于字段写入默认会 xor cookie:
如果想写入任意 raw qword X,就需要传入:
当目标位置初值为 0 时,可以先读该字段:
读取结果是:
随后:
写入内存:
如果目标位置不是 0,也可以通过读取当前值获得:
再利用解释器运算构造需要写入的 encoded value。
这个原语已通过 q10_raw_write_probe.in 验证,曾确认写入:
arena 本体是一个大 heap chunk:
arena 后面紧跟解释器解析输入时产生的 AST / Literal 等小对象分配。每行表达式执行结束后,AST 相关对象会被释放,形成 tcache 链。
关键观察:
glibc 2.27 tcache entry user data 形态:
因此当前策略是:
如果 ptr = __free_hook-8,则:
只要这个 literal 是 system 地址,就能完成 __free_hook = system。
在 fake object 读范围内,可以读到一个稳定 libc 指针:
表达式:
随后在解释器内部计算:
偏移关系:
本地 gdb 仅用于校验时确认:
至此,解释器内部已经有了两个关键动态地址。
到这里还有一个问题:
但 Number 构造函数写入 __free_hook 时,值来自 object literal 中的十进制数字:
而 object literal 不支持把变量作为元素:
字段下标也不支持变量:
程序也没有正常输出变量值的语法。因此不能简单地说“解释器内部算出了 system,就直接把它写到 __free_hook”。必须想办法把 env[10] 的值泄漏回 PoC 脚本,或者找到别的动态搬运机制。
尝试过的支线包括:
这些路线要么会在错误清理路径中 free 非法指针,要么缺少自然调用点,要么仍然绕不开动态值写入问题。
最终可行路线是构造输出侧信道。
BinOp::exec 对两个操作数做 tag 检查:
这意味着如果能把 system 的某一 bit 转成:
再执行一次二元运算,就可以通过是否输出:
泄漏这一 bit。
具体构造第 k 位:
然后把 0/1 扩展为 tag:
为什么要拆成两段?
因为如果直接使用:
作为乘数,这个数本身高 16 位就是 0x1337,会被 BinOp::exec 当作对象 tag 拒绝。拆成:
两个非对象 tag 数字,分别乘以 bit 后相加,即可在 bit=1 时得到:
最后执行:
如果 bit=1,则 BinOp 检测到对象 tag,输出 runtime error!;如果 bit=0,则普通运算成功且无额外输出。
由于 userland canonical address 在本题环境中只需要低 48 位,泄漏 48 bit 即可恢复 system 地址。q10_oracle_poc.py 每 8 位打印一次进度。
oracle 会发送数百条表达式,导致 tcache 链状态和早期 gdb 固定 PoC 不同。
早期链路中可使用:
但 oracle 之后,直接这样做会让目标过早进入分配序列,导致 object literal 构造期间崩溃。
本地 --inspect-tcache 观察 oracle 之后的 0x20-bin 常见链为:
关键 fake object index:
最终采用的 poison 表达式:
最终稳定链效果是把后续 0x20 分配路径调整到:
然后使用:
使第三个 Number literal 的构造落在 __free_hook-8:
这里的 system 不是硬编码固定地址,而是前面 bit oracle 泄漏出的当前进程实际地址。
写入:
后,需要触发一次:
程序每次读入一行会使用 C++ std::string。如果输入长度超过 SSO,小字符串优化不再使用栈内缓冲,而是走 heap buffer。发送一行非法语法时,parser 输出 invalid syntax!,随后该行 std::string 析构,释放 heap buffer。
本地验证用命令:
# 后面只是保证字符串长度超过 SSO,并让 shell 执行时把后续内容当作注释。最终 free(buffer) 进入:
输出:
随后程序本身还会输出:
这是正常现象。
最终本地 PoC 文件:
主要函数:
完成:
完成:
循环泄漏 48 位 system 地址。
完成 tcache 链修正,将一次 Number 分配导向 __free_hook-8。
最后:
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。