首页
社区
课程
招聘
[Original] # kctf2026 Question 10 — Mao Hour · Dawn Breaks
发表于: 19小时前 391

[Original] # kctf2026 Question 10 — Mao Hour · Dawn Breaks

19小时前
391

保护:Full RELRO / PIE / NX / Canary / Stripped

题目给了一个 stripped 的 64 位 C++ PIE 二进制,跑起来是个交互式解释器,支持变量赋值、数组、算术运算和字段读写。附带 libc-2.27.so 和 ld。保护全开。

核心思路:利用解释器内部的类型混淆,伪造对象头实现 OOB 读写,泄露 XOR 密钥和 libc 地址,通过侧信道逐位恢复 libc 基址,最后 tcache poisoning 打 __free_hook 拿 shell。

跑一下二进制,输入几条命令摸清语法。解释器读 cin.getline(buf, 0x100),支持以下操作:

支持的运算符:+ - * / % ^ | &,全部是无符号整数运算(乘法用 imul),结果写回左操作数。

坑: 解释器不支持复合表达式$6=$5^$4 并不会计算 $5 XOR $4 然后赋给 $6,实际结果是错的。必须拆成两步:先 $6=$5$6^$4。这个 bug 卡了很久。

逆向 RTTI 可以还原出完整的类型体系。解释器用 64 位 tagged value 存储所有值:

Object 在 arena 内的布局:

程序启动时从 /dev/urandom 读取一个 64 位随机密钥。arena 中存储的所有值都经过 XOR 加密——写入时 val ^ key,读出时 stored ^ key 还原。

OOB 读出来的值是加密的,需要先拿到 key 才能解密,写入时也要先加密。

解释器用 malloc(0x10000) 的 64KB 堆块作为 arena,bump allocator,没有 GC。所有 Object 节点数据在 arena 里,AST 节点通过 new 分配在普通堆上。

各节点的 malloc 大小(后面要用):

BinOp 的 eval() 做完整的 64 位整数运算,结果直接存回寄存器,不检查不截断。这就能构造任意 64 位值——特别是合法的 0xBEEF 头:

SetField 写值的时候只检查目标地址是否在 arena 范围内,不检查写入的值本身。所以可以把伪造的 0xBEEF_0000_0000_FFFF 写入 arena 的元素位置。

把伪造的 BEEF 头装到 $0[0] 后,通过 $4=$0[0][N] 读"第 N 个元素"。GetField 读 arena 中的 BEEF 头,解析出 count = 0xFFFF,只要 N < 0xFFFF 就放行。

但实际 Object 只有 5 个元素,arena 只有 64KB。N 足够大时,读写地址越过 arena 边界,落到后面的堆元数据区域——包括 tcache 结构体libc 指针

arena 是 malloc(0x10000) 返回的,glibc 2.27 下实测 arena 区域初始全零。

读取 $0[0][4] 对应 arena+0x30。因为该位置是 0,而 GetField 做 stored ^ key,读出来就是 0 ^ key = key

通过 LD_PRELOAD hook 扫描堆内存,找到 _IO_file_jumps 指针在 arena + 0x100e8 处。换算 OOB 索引:(0x100e8 - 0x10) / 8 = 8219

$6 里现在是 libc 基址——但只在寄存器里,没法直接读出来。

整个利用中最有意思的部分。libc_base 在寄存器里,需要"读"到 Python 脚本中。但解释器没有 print 功能。

思路是利用 0x1337 标签做侧信道:当一个值的高 16 位恰好是 0x1337 时,解释器把它当 Object 指针处理。如果"指针"指向的不是合法对象,触发 "runtime error!" 输出——这就是 1-bit 信息泄露通道。

构造方法:

如果原始 bit 是 1:

如果原始 bit 是 0:

检查每次操作后是否出现 "runtime error!" 就能逐位恢复 36 个有效位。每位 6 条命令,36 位共 216 条,远程大概一两分钟。

拿到 libc_base 后就是经典打法。通过 OOB 写修改 tcache[0x30] 的 fd 和 chunk size:

为什么改 chunk size?需要先消耗 tcache[0x30] 的旧头部 entry。$9=[0] 创建 Object(malloc(32) → 0x30 chunk)弹出旧 entry,free 时按 size 0x41 放入 tcache[0x40] 而不是 0x30,避免污染毒化后的链表。

关键细节: glibc 2.27 的 tcache 分配检查的是 entries[tc_idx] != NULL不是 counts > 0。反汇编 __libc_malloc(偏移 0x971a0)确认了这一点。counts 为 0 也不影响分配。

坑:所有 BinOp 运算必须在 OOB 写之前完成。 BinOp 是 malloc(32) → 0x30 chunk,毒化 tcache[0x30] 之后再执行 BinOp 会弹出毒化的 entry,破坏利用链。SetField 是 malloc(48) → 0x40 chunk,不影响。

tcache[0x30] 头部现在指向 __free_hook - 24。发送一行超过 15 字符的输入(避开 SSO),解释器主循环:

payload 结构:

strlen 遇到 p64(system_addr) 中的 \x00 停止,strlen = 30。malloc(31) → 0x30 chunk → 从 tcache 弹出 __free_hook - 24

_M_construct 把 payload 复制过去,system_addr 的 6 个非零字节落在 offset 24 = __free_hook

string 析构 free(buf)__free_hooksystem("cat /f*;AAAA...") → flag。

后续命令也能执行: __free_hook 设为 system 后,之后每轮循环的 string 析构都触发 system(input)。短命令用 # + padding 补到 16 字符以上(# 是 shell 注释)就行。

最致命的坑。$6=$5^$4 看起来应该是"5XOR4 赋给 6",实测4=3, 5=5,6=99,执行后 $6` 变成 3 而不是 6。parser 压根不支持复合写法。

修复:拆两步 $6=$5$6^$4。发现之前侧信道泄露的 libc_base 一直是错的,调了很久才定位到。


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

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