-
-
KCTF 第十题:卯时·曦光初现 - PWN Writeup
-
发表于: 1天前 386
-
难度:中上 | 类型:VM/解释器 pwn | 环境:Ubuntu 18.04 + glibc-2.27
最终利用:23 行 VM 语句、零地址外泄、ASLR 免疫、单连接稳定 getshell
程序是一个交互式 REPL 解释器,提示符 > ,每行 getline(0x100) 读一条语句。C++ 编写,namespace interpreter,RTTI 泄露出类名:Literal / Number / Object / Ast / AssignLit / GetField / SetField / BinOp。
唯一输出只有三种:提示符 > 、invalid syntax!(解析失败)、runtime error!(求值失败),以及进程崩溃(连接断开)。没有任何数值回显通道——这是本题的核心难点。
注意 第二个虚调用槽是 +0x10(不是 +8),且 rdi = 根节点自身。这是最终利用的跳板。
裸表达式、$N[i]=字面量、多语句拼接均 invalid syntax。
64 位值,高 16 位是 tag:
arena 内每个 qword 都以 ^key 存储:对象头 = (0xbeef<<48|count)^key,数字字段 = value^key。GetField 读 [addr]^key,SetField 写 value^key——读写自洽,XOR 对 arena 内操作完全透明。
BinOp::eval @0x30de——漏洞所在:
字段游走 @0x1eda:从对象头地址线性扫描,每字段 8 字节;若字段值(^key 后)带 0xbeef tag 视为嵌套对象,递归跳过 count 个字段。头检查 count >= index。
GetField::eval @0x36e6:源寄存器必须 0x1337 tag;沿路径游走;最终读出的值若带 0xbeef tag 则拒绝(防读原始头)。
SetField::eval @0x3886:写入值(vars[src])不得带 0x1337 tag;目标对象(vars[dst])必须带;每层头检查;最终写入位置当前值(^key 后)带 0xbeef tag 则拒绝(防覆写头)。
乘法可以把任意值推入高 16 位。BinOp 只拦输入 0x1337,不拦输出,于是:
此后 $0[0][N](GetField)/$0[0][N]=$V(SetField)把 arena+8 当对象头游走:第 N 个字段在 arena+16+8N。count=8300 ⇒ 窗口 = arena+16 … arena+66416,覆盖整个 64KB arena 外加 arena 之后约 2KB 的堆——那里有每行解析分配的 AST 节点、locale 结构、以及稳定的运行时指针。
key、libc、heap 全是运行时随机值且无法打印。但根本不需要打印:
因此整条链天然 ASLR 免疫,也无需任何 oracle 逐位外泄。
在 docker ubuntu:18.04(系统 libc 与题目 buildid 完全一致)中用 gdb 逐槽 dump,确认 arena 之后的堆:
两个派生常数(仅依赖二进制+libc 版本,与运行环境无关):
注意:索引全部相对 arena,ASLR 只平移基址,不影响索引;但行序列会改变 tcache 状态 ⇒ payload 必须逐行原样发送(实测 23 行版与 65 行版各自稳定)。
最后一行语句的求值时序是关键:
写入发生在虚调用之前,而且写的就是"当前这行"的根节点 vtable 槽(索引 8280)——自引用闭环:
没装 one_gadget 工具,直接 objdump 扫 lea rdi,[rip+X](X 落在 /bin/sh 字符串 0x1b3d88)且 8 条指令内 call execve,得 4 个候选;逐一实测约束:
argv 是垃圾字节但 execve 只要求可读;sh 起来后继续从 socket stdin 读命令——直接就是交互 shell。
顺带一提为何不用 system:虚调用点 rdi=根节点,同一 qword 既要当 vtable 指针被解引用、又要当命令字符串——"/bin/sh\0" = 0x0068732f6e69622f 是不可映射地址,物理矛盾。故必须用不依赖 rdi 的 execve gadget。
题目本质是三层套娃:
三者缺一不可,最终 23 行语句稳定起 shell。
工具链:lief + capstone(静态)、docker ubuntu:18.04 + gdb(动态)、纯 socket(打点)。
| 语句 | 语义 |
|---|---|
$N=<十进制> |
vars[N] = 数字(仅低 48 位) |
$N=[1,2,3] |
建对象(inline 序列化进 arena),vars[N]=句柄 |
$N=$M |
寄存器拷贝 |
$N=$M[i][j]… |
GetField:沿路径读字段 |
$N[i][j]…=$M |
SetField:写字段(RHS 必须是寄存器,字面量 → invalid syntax) |
$N<op>$M |
BinOp:vars[N] = vars[N] op vars[M],op ∈ + - * / % ^ | & |
| top16 | 含义 | 低 48 位 |
|---|---|---|
0x1337 |
对象引用(句柄) | 绝对指针 |
0xbeef |
arena 内对象头 | 字段数 count |
| 其他 | 数字 | 数值(仅低 48 有效) |
| OOB 索引 | 地址(arena+) | 内容 | 稳定性 |
|---|---|---|---|
| 8193 | +0x10018 | heap+0x10(tcache 结构指针,启动时写入) |
✅ 恒定 |
| 8205 | +0x10078 | libstdcxx+0x2d680(locale 结构里的 facet vtable) |
✅ 恒定 |
| 8280 | +0x102D0 | 当前行 SetField AST 节点的 vtable 槽(tcache LIFO 复用同一 0x30 chunk) | ✅ 对固定 payload 恒定 |
| 候选 | 入口约束 | 实测 |
|---|---|---|
| 0x4f2c5/0x4f322 | do_system 内部,fork 失败路径,寄存器状态不满足 | ✗ 崩在 do_system+1094 |
| 0x10a308 | [rsp+0x70]==NULL |
✗ 栈非零 |
| 0x4f302 | rsi=rsp+0x40(栈上内容当 argv,可读即可),rdx=*environ |
✅ 成功 |
pwn ELF64 x86-64 PIE, stripped, C++, Full RELRO(BIND_NOW), NX, canary
libc-2.27.so Ubuntu 18.04 (buildid f7307432a8b162377e77a182b6cc2e53d771ec4b)
remote nc 123.57.66.184 10053
0x2e38 打印 "> " (ostream_insert)
0x2e84 getline(cin, buf, '\n')
0x2ea2 用 buf 构造 std::string(>15 字符则堆分配)
0x2ed5 call 0x3d38 ; 递归下降解析 → 返回 AST 根节点 rax
0x2df9 rbx=rax; 释放行字符串; 若 rbx==0 → "invalid syntax!"
0x2e25 call [rbx+0x00] ; ★ 虚调用 vtable 槽 0 = eval(rbx, vars)
0x2e27 test al,al → 0 → "runtime error!"
0x2e35 call [rbx+0x10] ; ★ 虚调用 vtable 槽 +0x10 = deleting dtor, rdi=根节点