首页
社区
课程
招聘
[原创] 第十题:卯时·曦光初现
发表于: 2026-8-24 11:27 219

[原创] 第十题:卯时·曦光初现

2026-8-24 11:27
219

保护与程序结构

目标是 64 位 PIE 程序,开启了 Full RELRO、NX 和栈保护,运行环境为 Ubuntu glibc 2.27。程序没有直接的栈溢出点,攻击面位于自定义表达式解释器及其对象序列化逻辑。

主循环每次读取一行不超过 255 字节的输入,将其解析成 AST,调用第一个虚函数执行,最后调用析构虚函数释放 AST。

解释器支持以下核心语法:

$0=123          标量赋值
$0=[1,2,3]      数组赋值
$1=$0[2]        字段读取
$0[2]=$1        字段写入
$0+$1           原地算术或位运算

变量编号在执行时限制为 0..255。二元运算包括加、减、乘、除、取模、异或、或、与。

初始化函数申请一个 0x10000 字节的连续区域作为对象 arena,并从 /dev/urandom 读取 8 字节随机数作为编码密钥。数组序列化格式如下:

header = key ^ (0xBEEF000000000000 | element_count)
data   = 递归序列化后的元素

变量中的数组引用使用另一种标签:

0x1337000000000000 | arena_pointer_low48

普通数字会被限制在低 48 位。数组字段读取时,程序用密钥异或内存中的 qword;若结果的高 16 位是 0xBEEF,该 qword 就被当作嵌套数组头处理。

漏洞成因

字段写入只拒绝高 16 位为 0x1337 的数组引用,却允许写入任意其他 64 位标量。二元运算又能从低 48 位常量构造完整的 64 位值,例如:

0xBEEF0000 * 0x100000000 = 0xBEEF000000000000

再加上一个可控长度,便能得到伪造数组头:

0xBEEF000000004000

字段写入会自动执行 key ^ value,因此将这个数写进真实数组的首元素后,字段读取器解码出的内容恰好是合法的 0xBEEF 数组头。递归遍历函数只相信头部携带的元素数量,没有再次确认地址仍处于 arena 内。后续索引因此可以越过 0x10000 字节边界,读写 arena 后方的 glibc 堆块。

构造过程为:

$0=[0]
$1=3203334144
$2=4294967296
$1*$2
$3=16384
$1+$3
$0[0]=$1

此时 $0[0] 已成为长度 0x4000 的伪造数组。

获取编码密钥与 libc 地址

arena 尚未使用的区域全部为零。读取伪造数组中的第一个越界零 qword 时,解释器返回:

0 ^ key = key

这样即可把随机密钥保存到普通变量。

在精确匹配的 glibc 2.27 和同代 libstdc++ 环境中,arena 后方偏移 0x10078 处稳定残留 _IO_2_1_stderr_ 指针。越界读取获得的是:

raw_stderr_pointer ^ key

再与刚得到的密钥异或,即可恢复真实指针。减去 _IO_2_1_stderr_ 的符号偏移 0x3EC680,解释器变量中便保存了 libc 基址。

程序没有打印数值的功能,因此需要建立一个确定性的单比特输出通道。二元运算会拒绝高 16 位为 0x1337 的操作数,并打印 runtime error!。对于 libc 基址的第 i 位,计算:

x = libc_base & (1 << i)
x = x * (1 << (48 - i))
x = x | (0x1336 << 48)

原位为 1 时,x 的高 16 位变成 0x1337,下一次二元运算会报错;原位为 0 时,高 16 位保持 0x1336,运算正常完成。逐位处理第 12 至 47 位即可精确恢复当前连接的 libc 基址。低 12 位由页对齐确定,整个过程不依赖地址猜测。

tcache 投毒

解释器初始化和短命 AST 对象在 arena 后方形成三个稳定的 0x20 tcache 块。它们的用户区相对 arena 位于:

0x10270
0x10290
0x102B0

不同 AST 会交换链表前两项,Exp 按每条语句的实际分配和释放顺序同步追踪当前链表。

最终目标为:

target = __free_hook - 8

字段写入会附加一次密钥异或,所以先在解释器内部计算 target ^ key,再把它写入空闲块的 next 字段,内存中的原始值就会是 target

带两个索引的字段写入会为索引向量依次申请两个 0x20 块。第一次扩容得到的块会在 AST 执行前释放,成为当时的 tcache 头;第二个块在 AST 析构时释放到链首。对第一个块执行越界写后,链表变成:

第二个正常块 -> 第一个正常块 -> (__free_hook - 8)

覆盖 __free_hook

最后发送一条长度严格为 23 字节的输入:

$0=0<system 的十五位十进制地址>;sh#

开头额外的 0 使数字部分长度达到 16 字节,同时不改变 strtoul 得到的数值。该输入依次产生三个 0x20 分配:

  1. 主循环保存整行输入的 std::string 缓冲区;
  2. 数字解析时创建的 16 字节临时字符串;
  3. 大小为 0x10 的 Number 对象。

前两个分配消耗正常块,第三个分配返回 __free_hook - 8。Number 构造函数在对象偏移 0 写入合法虚表,在偏移 8 写入数值,于是 system 地址正好覆盖 __free_hook

数字后的分号使整条表达式解析失败,Number 随即被释放并触发 system。之后发送命令字符串即可通过后续释放执行命令。远程读取结果为:

flag{c42a0365-6ddb-456e-b6e7-9f75462e43f7}

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

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