首页
社区
课程
招聘
[原创] 看雪·2026 KCTF 第十题:卯时·曦光初现
发表于: 1天前 383

[原创] 看雪·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::execruntime 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 #...") 执行输出。

题目目录:

主要文件:

pwnpwn_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内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 1天前 被mb_mgodlfyn编辑 ,原因:
收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回