-
-
[分享]2026 KCTF 第十题「卯时·曦光初现」(Writeup · Pwn)
-
发表于: 1天前 391
-
一门自定义 DSL 解释器的类型混淆 —— 从"没有任何数值输出"到稳定 getshell
类型混淆 · 任意堆读写 · 信息泄露 oracle · 虚表自覆盖 · one_gadget
TL;DR
题目是一门自定义脚本语言的解释器。赋值时数值被掩码到 48 位,而二元运算的结果却不掩码 —— 这处不对称构成类型混淆:可以用纯算术拼出一个"高 16 位带 tag"的值,让解释器把它当成对象指针 / 超大对象头来解引用,从而得到任意堆读写。
程序没有任何数值输出,于是把"某条命令是否报 runtime error"当作布尔 oracle,逐位把内存搬回客户端,单连接内三源交叉泄露 KEY / libc / pool。最后在堆里布下一张伪虚表,用一条命令让 AST 根节点覆盖自己的虚表指针,在 main 尾部的析构虚调用里跳进 one_gadget,execve("/bin/sh") 收工。
无 seccomp ⇒ 拿到 RIP 后可直接 execve("/bin/sh"),无需 ORW / SROP,one_gadget 即可收工。
全文所有 RVA(相对二进制基址)、libc 偏移、堆偏移均由 objdump / Ghidra / gdb 实测得到;ASLR 由运行时泄露处理,与具体机器无关。
main(RVA 0x2d09)是一个 REPL:逐行读入、解析、执行一门迷你脚本语言(命名空间 interpreter)。它不是常见的 "1.add / 2.delete / 3.edit" 堆菜单 —— 读懂语言语义才是全题的钥匙。
二元运算方向(反汇编 + gdb 除零实测):$N op $M ⇒ context[N] = context[N] op context[M],结果存回 N;N 是左操作数兼目的,M 是右操作数。
方向判据:$5=0; $6=5; $6/$5 ⇒ 5/0 触发 SIGFPE 断连,而 $5/$6 不崩 ⇒ 被除数是第一个操作数 N。减 / 除等非对易运算的方向弄错,后面的构造就会写反。
context[N] 槽里的"值"靠高 16 位 tag 区分两种类型:
导航一个"对象"前有门控:读它的 header,校验 (*header ^ KEY) >> 48 == 0xbeef,否则 runtime error。这个门控本意是防乱指 —— 但它拦不住类型混淆。
核心不对称:
于是可以用纯算术造出高 16 位为任意 tag 的值 —— 这正是漏洞的杀伤力。两种用法:
把这个 childCount 撑到 0x8000 的假 beef 头写进池内某个合法对象的 child 槽后,再经该对象导航,那颗"超大子对象"就允许索引远远越界 —— 受控地址的任意堆读 / 任意堆写成立。
gdb 实证:导航一个高16=0x1337、低48=0 的伪指针 ⇒ 解引用地址 0 ⇒ SIGSEGV;而合法 0xbeef 头(哪怕 childCount 被改成 0x8000)⇒ 门控放行、正常越界导航。类型混淆坐实。
二元运算的实现开头同时检查两个操作数 context[N] 与 context[M],任一高 16 == 0x1337 ⇒ runtime error。
后果:无法"先乘出 0x1337<<48 中间值、再加低位" —— 中间值一旦带上 0x1337 tag,下一步运算立刻 RTE。这条门控贯穿整个利用:后面构造任意 64 位数、逐位 oracle、写伪指针,每一步都要绕它。
导航函数是从 beef 头向前线性跳 child(idx==0 返回 header+8;否则逐个跳 idx 个 child)。致命细节:若沿途遇到池内真实的嵌套对象(自身也是 beef 头),它会递归下降跳整棵子树,打乱线性偏移。
所以布局必须让"承载假头的宿主对象"排在所有真实对象之后,假头的 children 区落在池的零区(从未 serialize、全 0),这样线性导航畅通无阻:
此后一切读写走 host 的 2 级路径 $0[0][i],叶子地址 = pool+0x20 + 8*i,可线性覆盖整个池及其后的堆区。
范围限制:导航是 O(idx) 线性跳,idx 若大到跨越 heap↔libc 的未映射空洞会 SIGSEGV。因此不能直接对 libc 任意地址写(经典的写 __free_hook = system 在这里走不通)。控制流劫持必须走堆内机制 —— 这直接决定了后面用"自覆盖虚表"而非改 hook。
程序没有数值输出,怎么把读到的 64 位值搬回客户端?利用 SetField 的门控做布尔 oracle:
对某位 bit_k,把它缩放到 0x1337<<48(对象 tag),再拿去做 SetField 的源 —— 源若是"对象"就 RTE。于是:
逐位扫 64 次,即可把任意读到的一个 8 字节值完整搬回。
bit0 特判(致命坑):直接把 0x1337<<48 放进寄存器,这个中间值本身带 tag,后续乘法会因 §2.1 门控 RTE ⇒ bit0 恒判 0,污染结果(尤其污染 KEY)。修复:bit0 改成 *2 再 *(0x1337<<47),两操作数均无 tag,结果才是 0x1337<<48。
这些残留指针来自池块之后紧邻的一个 _IO_FILE(初始化时读 /dev/urandom 用过的 filebuf,已废弃,但结构体里的 libc 指针还在),经上百条命令 churn 仍稳定。
自校验(任一不过 ⇒ 本连接作废、重连,不做暴力):
main 对每条命令的 AST 根节点 R 依次做(objdump 实证):
关键:析构处 *rbx 是重新读取的。 只要在 eval 阶段把 R+0(根节点自身的虚表指针字段)改写成伪虚表 V,析构就会 call [V+0x10],且 rdi = R(一个堆地址)。一条命令内闭环,无需跨命令、无需写 libc。
选触发命令为一条 SetField $0[0][idx] = $src。gdb 实测:经固定的前置命令序列(布局 + leak)之后,该 SetField 的根节点 R 稳定落在 pool+0x102f0(chunk 0x40)。
原因:每条命令 parse 期大量 alloc/free 都是平衡的(AST 命令结束后全部 free 回 tcache),堆状态每命令后复位 ⇒ R 偏移确定、可硬编码,不必动态泄露。
SetField 节点体(相对 R):+0x00=虚表、+0x08=src 寄存器号、+0x10=dst 寄存器号、+0x18/0x20=path 向量。其虚表 [+0x00]=eval、[+0x10]=析构。
第 ③ 步里,SetField 的 eval 沿 host 2 级路径把 context[40]^KEY = V 写到叶子 = pool+0x102f0 = R+0,把根节点自身的虚表覆盖成 V;eval 用的是改写之前已取到 rax 的真虚表,所以正常返回真;随后析构 call [*R+0x10] = [V+0x10] = one_gadget,rdi = R ⇒ execve("/bin/sh") ⇒ getshell。
为何走 host 2 级路径 $0[0][idx] 而非直接 forge 一个 0x1337 指针? forge 路径在写路径的导航里,对大 idx 会返回坏地址并崩溃;而 2 级路径经 host 稳定导航,还彻底绕开了构造 0x1337 tag 时的门控坑(§2.1)。这是从"崩 SIGSEGV"到"稳定 getshell"的关键一步。
题目 libc(2.27-3ubuntu1.6)的 4 个候选:
析构调用点 call [rax+0x10] 刚 push 过返回地址,rsp 是 8 错位(rsp & 0xf == 8),因此要求 rsp & 0xf == 0 的两个 gadget 失败;而只要求某个栈槽为 NULL 的 0x4f302 / 0x10a2fc 满足。脚本默认 0x4f302,并对 4 个 gadget 逐个回退。
"推理自洽"和"真实 getshell"之间,隔着两个必须靠真值(gdb / 真实交互)才能发现的坑。
坑一:构造带 tag 的值撞门控。 造任意 64 位数字的 set64 用 hi<<32 + lo 拼装。当目标值本身高 16 == 0x1337(正是伪造对象指针的情形)时,中间值 hi<<32 的高 16 已经是 0x1337,下一步 + lo 因 §2.1 的双操作数门控直接 RTE ⇒ 低 32 位丢失 ⇒ 写出坏地址 ⇒ 触发命令在写入例程里 SIGSEGV。
这也是"崩 SIGSEGV"的真凶,曾被误判为偏移错。修复有两条路:① 触发改走 host 2 级路径(§5.3),根本不构造 0x1337 指针;② 若确需构造,先造 0x1336|addr(高 16 非 0x1337,安全)再 + (1<<48),两个操作数都不带 tag。本题采用 ①,更稳。
坑二:目标环境极精简,没有 cat。 首发虽然 getshell 成功,但 cat /flag 直接 not found,读不出内容。改用 shell(dash)内建 read(不依赖任何外部命令),并处理"flag 可能无尾换行"的边界:
|| [ -n "$L" ] 是关键:文件无尾换行时,最后一次 read 遇 EOF 返回非 0,但 $L 里已填好内容 —— 少了这个兜底就会漏掉最后一行,恰好把 flag 吞掉。
内存池关键布局(相对 pool 基址):
完整脚本 solve.py(约 190 行,本地 / 远端双模,内置 leak 自校验重连、one_gadget 回退、自动读 flag)。下面摘出两段最能说明利用链的核心。
约定:binop(N,op,M) 发 $N op $M(= ctx[N] = ctx[N] op ctx[M]);set64(N,C) 用 hi<<32 + lo 造任意 64 位数字(要求 C 高 16 ≠ 0x1337);getf/setf 走 host 2 级路径 $0[0][i]。
关键常量:R_OFF = 0x102f0、VT_OFF = 0x1000、OG = 0x4f302(默认;回退 0x4f302 / 0x10a2fc / 0x4f29e / 0x4f2a5)。全部地址均运行时泄露后计算。
拿到 shell 后,若目标环境精简、没有 cat,可用 shell 内建读 flag(注意"文件可能无尾换行"):
Flag(远端真实 getshell 读取,非附件诱饵;本文赛后发布):
字节校验:len = 42;hexdump 尾部 … 7d 0a,无隐藏字符;re.fullmatch(r'flag\{[^}]*\}') 为真;UUID 段长 8-4-4-4-12。
这题赢在哪
给出题 / 防御方的启示
方法可复现,结论有真值支撑。愿每道试炼都迎来自己的曦光。
卯时·曦光初现
看雪 · 2026 KCTF · 第十题 · Pwn
一门自定义 DSL 解释器的类型混淆 —— 从"没有任何数值输出"到稳定 getshell
类型混淆 · 任意堆读写 · 信息泄露 oracle · 虚表自覆盖 · one_gadget
| 项 | 值 |
|---|---|
| 方向 | Pwn |
| 附件 | pwn(题目二进制,C++)、libc-2.27.so |
| 架构 | ELF 64-bit PIE,x86-64,stripped |
| libc | Ubuntu GLIBC 2.27-3ubuntu1.6 |
| 保护 | Full RELRO / Canary / NX / PIE,无 seccomp |
| 交互 | 行式 REPL,提示符 > |
| flag | flag{<UUID>}(42 字符) |
| 形式 | 含义 |
|---|---|
$N = <num> |
赋数字。存入前掩码到 48 位(& 0xFFFFFFFFFFFF) |
$N = [a, b, [c, d], …] |
赋对象。在内存池里把嵌套列表序列化成扁平树 |
$N = $M[i][j]… |
取字段(GetField):沿路径导航到叶子,读回 *ptr ^ KEY |
$N[i][j] = $M |
写字段(SetField):导航到叶子,*leaf = context[M] ^ KEY |
$N op $M |
二元运算(+ - * / % ^ | &) |
| 类型 | 高 16 位 | 低 48 位 |
|---|---|---|
| 数字 | 0x0000 |
裸值 |
| 对象引用 | 0x1337 |
池内绝对地址 |
bit_k |
缩放后 | SetField | 判定 |
|---|---|---|---|
1 |
带 0x1337 tag |
runtime error |
该位为 1 |
0 |
无 tag | 成功 | 该位为 0 |
| 源 | 位置 | 含义 |
|---|---|---|
| KEY | pool+0x40(零区) |
*ptr = 0 ⇒ 读回 0 ^ KEY = KEY 明文 |
| libc(主) | pool+0x10230 |
残留 _IO_wfile_jumps = libc + 0x3e7d60 |
| libc(校验) | pool+0x10078 |
残留 _IO_2_1_stderr_ 区 = libc + 0x3ec680 |
| pool base | pool+0x10098 |
残留堆指针,- 0x100f0 得 pool 基址 |
| 偏移 | 语义 | 约束 | 析构点 |
|---|---|---|---|
0x4f29e |
execve("/bin/sh", rsp+0x40, environ) |
rsp & 0xf == 0 且 rcx == NULL |
✗ |
0x4f2a5 |
同上 | rsp & 0xf == 0 且 rcx == NULL |
✗ |
0x4f302 |
execve("/bin/sh", rsp+0x40, environ) |
[rsp+0x40] == NULL |
✅ |
0x10a2fc |
execve("/bin/sh", rsp+0x70, environ) |
[rsp+0x70] == NULL |
✅ |
方法可复现,结论有真值支撑。愿每道试炼都迎来自己的曦光。
RELRO Full RELRO
Stack Canary found
NX NX enabled
PIE PIE enabled
Seccomp (none)
对象头 header = (childCount | 0xbeef<<48) ^ KEY ; 高 16 位 tag = 0xbeef
数字字段 = value ^ KEY
子对象 = 递归展开,header 紧邻
; 造假 beef 头(本题落地就用这个):
$h = 0xbeef0000 ; 纯数字
$t = 1<<32
$h * $t → 0xbeef_0000_0000_0000 ; 结果不掩码,tag 就位
$h + 0x8000 ; childCount 撑到 0x8000