首页
社区
课程
招聘
[分享]2026 KCTF 第十题「卯时·曦光初现」(Writeup · Pwn)
发表于: 1天前 391

[分享]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 $Mcontext[N] = context[N] op context[M],结果存回 N;N 是左操作数兼目的,M 是右操作数。

方向判据:$5=0; $6=5; $6/$55/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 == 0x1337runtime 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 = Rexecve("/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 失败;而只要求某个栈槽为 NULL0x4f302 / 0x10a2fc 满足。脚本默认 0x4f302,并对 4 个 gadget 逐个回退。

"推理自洽"和"真实 getshell"之间,隔着两个必须靠真值(gdb / 真实交互)才能发现的坑。

坑一:构造带 tag 的值撞门控。 造任意 64 位数字的 set64hi<<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 = 0x102f0VT_OFF = 0x1000OG = 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 == 0rcx == NULL
0x4f2a5 同上 rsp & 0xf == 0rcx == 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

传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回