b01lers CTF 2026 wp
字数 14344
更新时间 2026-04-22 13:49:19

b01lers CTF 2026 Writeup 教学文档

概述

本文档旨在详细解析 b01lers CTF 2026 比赛中的部分赛题,涵盖 MISC、Crypto、Reverse、Jail 和 Pwn 等多个类别。每个题解都包含对题目背景、核心思路、解题步骤和关键技巧的详尽分析,旨在为读者提供完整的学习路径。


MISC 类题目

humanity-check

描述:这是一道签到题,但具有一定迷惑性。
解决:题目本身并不复杂,但需要一定的尝试和观察。经过大约十分钟的探索,即可得到 flag。
Flagbctf{krauq_blocked}

badyuri

描述:比较 yuri.txtyuri_1.txt 两个文件,它们长度相同,但存在 30 处单字符差异。
核心思路

  1. 对每一处差异,计算 ord(修改后字符) - ord(原字符) 的差值。
  2. 将这些差值转换为对应的 ASCII 字符。
  3. 将转换得到的字符按顺序连接,即可形成 flag。

解题步骤

  1. 使用 tarfile 库安全地解压提供的 yuri.tar.gz 文件。
  2. 分别读取 yuri.txtyuri_1.txt 的内容。
  3. 逐字符比较两个字符串,记录差异位置、原字符、新字符以及 ord(新字符) - ord(原字符) 的差值。
  4. 将每个差值转换为对应的字符(chr(delta))。
  5. 将所有转换得到的字符连接,即为 flag。

关键代码逻辑

for i, (a, b) in enumerate(zip(s1, s2)):
    if a != b:
        delta = ord(b) - ord(a)
        flag_chars.append(chr(delta))
flag = "".join(flag_chars)

Flagbctf{w3_l0ve_yur1_rB4DN8aULH9}

bctf-infra

描述:一个涉及基础设施竞态条件(Race Condition)的题目,包含三个挑战(chal1, chal2, chal3)。目标是读取 chal3 目录下的 flag.txt,但每个挑战运行在独立的、权限受限的系统用户下。

环境与限制

  • chal1:允许执行的 Python 代码字符集受限(小写字母、标点、数字,但禁止字符 eo),最后执行 exec(inp)
  • chal2:允许的字符集更小(string.ascii_lowercase + "._[]; "),难以直接利用。
  • chal3:只允许空白字符,几乎无法直接执行代码。
  • 关键文件 flag.txt 存在于 chal3 目录,权限为 700,理论上 chal1 用户无法访问。

漏洞根源
challenge_server.pyrun_challenge 函数中,使用 shutil.copytree() 将挑战目录复制到临时目录 /tmp/<随机名>/app。这个过程不是原子的:

  1. 创建目标目录并复制文件(此时 flag.txt 已被复制)。
  2. 最后将源目录的权限和元数据同步到目标目录(将权限设置为 700)。
    在这两个步骤之间存在一个短暂的“竞态窗口”,在此期间,目标目录的权限尚未被收紧,其他用户(如 chal1 进程)有可能读取到其中的 flag.txt

利用链构建

  1. 获得代码执行:首先连接到 chal1。由于字符集限制,不能直接使用包含 eo 的字符串(如 open, exec, __import__)。解决方法是使用八进制转义(\xxx)来构造敏感字符串,绕过过滤。例如,open 可以写成 \x6f\x70\x65\x6e
  2. 突破字符限制:在 chal1 中执行一个外层 Payload,其核心目标是启动一个不受字符限制的 Python 子进程。这可以通过 __builtins__.__dict__[...] 获取 __import__,动态导入 subprocess 模块,并调用 subprocess.Popen 来实现。
  3. 发起竞态攻击:在启动的、无字符限制的子进程中,并发执行以下两个任务:
    • 扫描线程:不断扫描 /tmp/*/app/flag.txt 文件,尝试在竞态窗口内打开并读取。
    • 触发线程:不断连接本地服务(127.0.0.1:1337)并选择 chal3 挑战,以高频触发 copytree 操作,增大命中竞态窗口的概率。
  4. 提取真实 Flag:读取到的文件内容可能包含占位符 fake{fake_flag},需要过滤并识别以 bctf{ 开头的真实 flag。

技术要点

  • 竞态条件(TOCTOU):利用文件系统操作的时间差进行攻击。
  • 受限字符集下的代码执行:通过字符串编码(八进制、十六进制)和动态属性访问绕过过滤。
  • 本地服务交互:在 chal1 的沙箱内,可以连接同一环境下的本地监听服务来触发其他挑战。

Crypto 类题目

ecb-lasagna

描述:题目基于 AES-ECB 模式,对 flag 进行了一种自定义的链式加密操作。

加密过程简述

  1. 将 flag 的每个字符重复两次,形成一个新序列。
  2. 对于序列中的每个位置 i,计算 F[i] = AES(flag[i] * 16)(即用 flag 的单个字符填充一个 16 字节块进行 AES 加密)。
  3. F[i] 这个 16 字节块“环形平移”到位置 i 后,与之前的总结果进行异或,得到最终输出。

解密思路
由于每个输出块只依赖于前 8 个字符(因为原字符被复制了两次),且已知 flag 格式以 bctf{ 开头,因此可以:

  1. 利用已知前缀,逐步推导后续字符。
  2. 对后续未知字符进行有限爆破(例如 3 个字符)。
  3. 根据推导和爆破结果,结合加密算法的线性(异或)特性,唯一确定整个 flag。

Flagbctf{y0u'v3_h349d_0f_5p4gh3tt1_c0d3,_8ut_d1d_y0u_kn0w_l454gn4_c0d3_4150_3x15t5?_1t_m34n5_y0u_c4n't_m4k3_4_ch4ng3_50m3wh3r3_w1th0ut_m4k1n6_4_ch4ng3_1n_m4n7_0th3r_p4rt5_0f_th3_c0d5.}

manytags

描述:一个基于 AES-GCM 的题目,但 Tag 被“污染”过。服务端使用固定的 master_key,并基于该密钥初始化了一个确定性的伪随机数生成器(PRNG)来干扰 Tag 的低 64 位。

服务端流程

  1. 生成 256 位随机 master_key
  2. master_key 的整数形式作为种子,初始化一个 random.Random() 实例(MT19937)。
  3. 加密 flag 得到 (nonce, ciphertext, tag) 并输出。
  4. 允许用户进行最多 704 次查询。每次查询,服务端:
    • 随机生成一个 16 字节的 message 并加密,得到标准的 GCM 认证标签 real_tag
    • 从上述 PRNG 中获取两个 32 位随机数,拼接成一个 64 位的 fault_value
    • 计算 GHASH(H, C),其中 H = E_K(0^128)C 是查询的密文。
    • 返回被污染的标签:tag_hi = real_tag_hi, tag_lo = low64(GHASH(H, C) ^ fault_value)

攻击原理

  1. 线性方程:在 GF(2) 上,GHASH(H, C) 函数关于 H 是线性的。因此,每次查询泄露的 tag_lo 可以写成关于 H 的 128 个比特和 fault_value 的 64 个比特的线性方程。
  2. 状态恢复:MT19937 的内部状态是 624 个 32 位字。每次查询消耗 2 个 32 位输出(即 64 位)。704 次查询足以获取超过一轮(624*32=19968 比特)的状态输出。
  3. 构建方程组:利用前 624 次查询,可以建立 624 * 64 = 39936 个线性方程,未知数是 H 的 128 比特和 MT19937 状态的 19968 比特,方程数远超未知数,可以求解。
  4. 逆向 PRNG:求解方程组后,得到 H 和 624 个连续的 MT19937 输出。通过 untemper 操作恢复内部状态,然后逆向 random.Random(int.from_bytes(master_key, "big")) 的初始化过程,最终恢复出原始的 master_key 整数,即密钥本身。
  5. 解密 Flag:使用恢复的 master_key 对服务端最初提供的 (nonce, ciphertext, tag) 进行标准的 AES-GCM 解密,即可获得 flag。

关键点

  • 将 GCM 标签污染问题转化为对线性方程组和 MT19937 状态恢复的问题。
  • 利用查询次数远超 MT19937 状态大小的特点,进行密钥恢复。

qss

描述:一个量子安全协议(QSS)相关的题目,需要构造一个量子攻击策略,使其能够通过服务端的多项严格评分标准,而非完全遵循原始协议。

协议与评分简述

  • 服务端允许我们提交一个 2 维辅助量子态(ancilla)和一个 8x8 的酉矩阵(unitary)。
  • 服务端运行 96 轮,每轮随机决定 secret bit、set bit、Alice 的测量基(z/x)以及该轮是否为测试轮(test round,概率 0.4)。
  • 在每轮开始,Bob(我们)需要预先公布一个测量基和结果({"basis": "...", "outcome": ...})。
  • Test 轮:服务端检查我们的预测与 Alice 的实际测量结果在特定基下的符合程度,计算量子比特错误率(QBER)。只有当我们的测量基与 set_bit 决定的 required_basis 一致时,该轮才被计入 QBER 统计。要求 zx 基下各有至少 2 轮有效检查,且总有效检查轮数 qber_checked >= 4
  • Info 轮:当我们的测量基与 Alice 的测量基一致时,该轮被标记为 info_keep = yes。我们可以提交一个测量计划(measurement_plan)来测量量子位 b 或 c,并猜测 secret bit。猜对则 info_correct += 1。要求 info_rounds >= 16raw_info_accuracy >= 0.60
  • 最终通过条件:除了满足上述各指标下限外,还需满足 mutual_info_empirical >= 0.5frontier_abs_dev_bits <= 0.25announce_balance <= 0.50 以及 mutual_info_empirical > raw_mutual_info_empirical

攻击策略核心

  1. 绕过协议,专注评分:目标不是实现协议,而是构造一个能在评分函数下“骗过”检查的攻击策略。
  2. 平衡宣告:预先公布的测量基不能总是 z 或总是 x。可以采用循环模式(例如 64 轮 z,32 轮 x)来满足 announce_balance <= 0.50,并确保 test 轮中两种基都有机会被检查。
  3. 构造酉矩阵:利用题目中 Bell 态的四种逻辑输入(S0: |phi->, |psi+>; S1: |Phi->, |Psi+>)。选择其中一对逻辑基(如 |0_L> = |phi-> ⊗ |0>, |1_L> = |psi+> ⊗ |0>)进行编码。构造一个 8x8 的酉矩阵,将这两个输入态映射到一对有利于后续测量的 3-qubit 码字上。矩阵的其余部分用正交基补全,确保其整体是酉矩阵。
  4. 自适应测量:在 info 轮且 info_keep=yes 时,服务端会告知当前是 S0 还是 S1。我们可以据此自适应地选择测量计划:
    • 如果是 S0,测量量子位 b 的 z 分量。
    • 如果是 S1,测量量子位 b 的 x 分量。
    • 根据测量结果猜测 secret bit(例如,结果 0 猜 0,结果 1 猜 1)。
  5. 参数调优:通过调整预先公布的 outcome 与测量策略的对应关系,以及酉矩阵的具体参数,使得最终结果满足所有评分条件,特别是 QBER 和互信息量的要求。

Flagbctf{phy51c5_1s_l1k3_s3x}

sporadiclogarithms

描述:一个基于群论和离散对数的黑盒计算题。给定一个群(运算为 mul,逆为 inv),一个自同构 phi(a) = c * a * c^{-1},以及等式 h = s_{g, phi}(x),其中 s_{g, phi}(x) = g * phi(g) * phi^2(g) * ... * phi^{x-1}(g)。目标是找到 x ∈ [0, 262144] 满足该等式。

数学推导与化简

  1. a = g * c。可以证明:(g*c)^x = s_{g, phi}(x) * c^x = h * c^x
  2. c 的阶为 mc^m = 1),由于 c 的阶很小(题目中最多为 8),可以枚举 r = x mod mr ∈ [0, m-1]
  3. x = r + m*t。代入上式得:a^{r + m*t} = h * c^r
  4. 等式两边右乘 a^{-r}(a^m)^t = (h * c^r) * a^{-r}

解题步骤

  1. 确定阶 m:通过不断与 c 相乘,直到结果为 1,得到 c 的阶 m,并计算出 c^0, c^1, ..., c^{m-1}
  2. 计算 aa = mul(g, c)
  3. 枚举余数 r:对每个 r ∈ [0, m-1]
    a. 计算 y = mul(h, c^r)mul(y, inv(a^r))。这里 a^r 可以通过快速幂计算。
    b. 计算 b = a^m
    c. 此时问题转化为求解离散对数:b^t = y,其中 t 的范围是 0 <= t <= floor((262144 - r) / m)
    d. 由于 m 很小,t 的最大值约为 262144/8 ≈ 32768,可以使用大步小步法(BSGS)高效求解。
  4. 得到 x:如果找到 t,则 x = r + m * t,提交 x 即可。

关键点

  • 利用 c 阶小的特性,将原问题转化为在小子群上的离散对数问题。
  • 通过枚举余数 r 和 BSGS 算法,将搜索空间大幅缩小。

Reverse 类题目

tiles + ai

描述:一个使用 Intel AMX(高级矩阵扩展)指令集实现的复杂状态机逆向题目。输入是由特定 token 组成的字符串,程序通过 AMX 指令进行矩阵运算来更新内部状态,最终检查状态矩阵的特定位置和稀疏性约束。

输入格式

  • 输入字符串每 2 个字符为一个 token。
  • 第一个字符是十六进制数字(0-9, a-f, A-F),第二个字符只能是 '0' 到 '3'。
  • 因此共有 16 * 4 = 64 种可能的 token。

程序逻辑

  1. 初始化和检查:程序开始会检查 CPU 是否支持 AMX,不支持则报错退出。还会检查 flag.txt 和假 flag。
  2. 状态矩阵:程序维护一个大小为 0x300 字节(可能是 3x(16x16) 的块)的状态矩阵。三轮挑战各有不同的初始状态(存储在 0x40f800)。
  3. 矩阵运算:对于每个输入的 token:
    • 根据 token 的两个字符,从硬编码在二进制中的选择矩阵(0x409000)和辅助矩阵(0x40a000)中选择一组矩阵。
    • 使用 ldtilecfg, tileloadd, tdpbssd, tilestored, tilerelease 等 AMX 指令,对当前状态矩阵和选中的矩阵进行块矩阵乘法运算,生成下一个状态矩阵。
  4. 约束检查:每轮结束后,程序检查状态矩阵:
    • 稀疏性约束:将状态矩阵的前 0x240 字节(36行,每行16字节)按行求和,要求每行的和小于 2(即每行最多只有一个“激活”位置)。
    • 目标位检查:检查状态矩阵中一个固定的偏移位置(0x412380 - 0x412270 = 0x110)的值是否为 1。
    • 只有同时满足稀疏性约束和目标位为 1,该轮才算通过。

解题思路

  1. 逆向 AMX 逻辑:将 0x40142b ~ 0x401ae7 的 AMX 指令序列翻译成高级语言(如 Python/NumPy)中的矩阵运算函数 step(state, token)。这一步需要理解 AMX 的 tile 配置、加载、乘加和存储操作。
  2. 转化为图搜索问题
    • 状态:程序的完整状态矩阵(0x300 字节)。由于稀疏性约束很强,实际有效状态空间远小于理论值。
    • 动作:64 种可能的 token。
    • 转移next_state = step(current_state, token)
    • 起点:每一轮给定的初始状态。
    • 终点:满足稀疏性约束且目标位为 1 的状态。
    • 目标:对每一轮,找到从起点到终点的最短 token 序列(即输入字符串)。
  3. 搜索算法:由于状态空间大但约束强,可以使用 BFS 并配合剪枝(例如,丢弃违反稀疏性约束的状态)。每轮独立求解。
  4. 得到输入:将三轮搜索得到的最短 token 序列连接起来,作为最终输入提交给远程服务。

最终输入:解题得到的 token 序列为 0b1a1d2b1e0b1d2b1a1e1c2d0c1e1d0e2a2d0b2b0d1c1c0e1d0b1a1d2b1e0b1d2b1a1e1c2d0c1e1d0e2a2d0b2b0d1c1c0e1d0b1a1d2b1e0b1d2b1a1e1c2d0c1e1d0e2a2d0b2b0d1c1c0e1d
Flag:提交后由远程程序输出。

Favorite Potato

描述:一个基于 6502 处理器仿真的逆向题。服务端运行一个“土豆机”(Potato)模拟器,执行一段很长的 6502 机器码。已知程序对输入的三字节 (A0, X0, Y0) 寄存器进行一系列操作后,输出最终的三字节 (A, X, Y)。目标是根据给定的 20 组输出,反推出对应的输入。

程序分析

  • 程序文件是 code.bin.gz,去压缩后,前两个字节是 PRG 加载地址,需要去掉。
  • 剩余的机器码非常长(超过 25 万条指令),但由大量重复的、简单的、可逆的微操作片段组成。

可逆操作识别
通过模式匹配,可以将机器码解析为以下高层操作序列:

  • swapAX: 交换 A 和 X 寄存器。
  • swapAY: 交换 A 和 Y 寄存器。
  • swapXY: 交换 X 和 Y 寄存器。
  • addX_A: X += A
  • addY_A: Y += A
  • addA_c: A += 常数
  • xorA_c: A ^= 常数
  • xorAY: A ^= Y
  • rorA_k: A 循环右移 k 位。

逆向求解
由于所有操作都是可逆的,可以从最终输出 (A, X, Y) 开始,逆向执行操作序列,即可得到初始输入 (A0, X0, Y0)

  • swap 操作:逆操作是其自身。
  • add 操作:逆操作是 sub(减法)。
  • xor 操作:逆操作是其自身(因为 a ^ b ^ b = a)。
  • rorA_k:逆操作是循环左移 k 位(rol)。

解题步骤

  1. 解析程序:编写脚本,将 code.bin.gz 中的机器码流识别并翻译成上述高层操作列表。
  2. 逆向计算:对于给定的每组输出 (A, X, Y),从后往前遍历操作列表,对每个操作执行其逆操作。
  3. 得到输入:执行完所有逆操作后,得到的就是对应的输入 (A0, X0, Y0)
  4. 提交:将 20 组输入依次提交给远程服务,获取 flag。

关键点

  • 程序虽然长,但结构简单,全是可逆的微操作。
  • 核心难点在于从海量字节码中准确识别出有限几种操作模式。

indirect-memory-access

描述:一个 Game Boy Advance (GBA) ROM 逆向题。程序接收一系列按钮输入,将其编码为比特流,然后通过一个由 DMA(直接内存访问)副作用实现的“硬件”组合逻辑电路进行校验。校验通过后,将按钮序列作为 flag 的一部分输出。

输入编码

  • 程序从 GBA 的按键寄存器读取输入。每个按键对应一个位掩码(如 A 键为 1<<0)。
  • 按键被编码为一元码(Unary Code):对于一个按键掩码 1<<k,它被编码为 k0 比特后跟一个 1 比特。因此,一个按键编码后的比特长度为其索引 k+1
  • 程序内置了一个字符映射表(地址 0x08008ba4):a, b, s, S, R, L, U, D, r, l 分别对应索引 0 到 9。因此,段长为 1 的编码对应字符 a,段长为 2 对应 b,...,段长为 10 对应 l
  • 程序读取总计 128 个比特的输入。

校验逻辑

  1. 组合逻辑网络:在地址 0x08000310 的函数中,程序从输入比特流缓冲区(0x030000a0)每次取两个比特,调用一个“黑盒函数”,然后将结果与后续的中间结果进行组合,形成一个多层的组合逻辑电路。
  2. DMA 门电路bl 指令跳转到的地址(0x080015b2)只是一个 bx r5r5 被设置为 0x03000025,这是一段在启动时从 ROM 复制到 IWRAM(内部高速 RAM)的代码。这段代码操作 DMA 控制寄存器(如 0x040000ba, 0x040000c6, 0x040000de),利用 DMA 传输的副作用来实现一个 2 输入 1 输出的布尔逻辑门功能。这正是标题“间接内存访问”的由来。
  3. 最终检查:整个网络最终产生一个输出。如果输出符合预期,则校验通过。

解题步骤

  1. 静态分析:从 0x08000310 的函数中,提取出所有对“黑盒函数”的调用,以及它们之间的数据依赖关系,重建出整个布尔逻辑电路的 DAG(有向无环图)。
  2. 逆向电路:从电路的最终输出(应为逻辑“真”,即 1)开始,反向推导每个逻辑门的输入,直到回溯到 128 个原始输入比特。这类似于 SAT 求解或逆向逻辑电路。
  3. 解码输入:得到 128 个输入比特后,按照一元编码规则进行解码:寻找连续的 0 直到遇到一个 1,这段长度 L 就对应一个字符(L=1 -> a, L=2 -> b, ..., L=10 -> l)。
  4. 获取 Flag:ROM 中明确提示了 flag 格式:bctf{<above chars>}。将解码出的字符序列填入花括号内即可。

最终按钮序列:解码后得到 SbaRaaRlabSabaS,其总长度恰好为 128 比特。
Flagbctf{SbaRaaRlabSabaS}


Jail 类题目

build-a-builtin

描述:一个 Python 沙箱逃逸题目。内置命名空间被清空,但提供了一个可以往 builtins 中添加属性的后门函数 set_builtin。目标是在禁用点号(.)的情况下,获取代码执行并读取 flag。

环境限制

builtins.__dict__.clear()
def set_builtin(name, value):
    builtins.__dict__[name] = value
  • input() 只允许一行输入。
  • 输入中不能包含字符 .
  • builtins 字典被清空,大部分内置函数和类型不可用。

利用链

  1. 获取 __globals__set_builtin 是一个函数对象,它拥有 __globals__ 属性,指向其定义所在模块(chall.py)的全局命名空间。但无法直接使用 . 访问。
  2. 伪造 __import__:利用 set_builtin 可以设置 builtins 中的属性。我们可以设置一个假的 __import__ 函数,当被调用时,返回我们指定的对象(这里返回 set_builtin 函数本身)。
    set_builtin('__import__', lambda *a: set_builtin)
    
  3. 利用 import 语句:Python 的 import 语句会调用 __import__ 函数。我们可以构造 import 语句来“导入”一个模块,并指定要导入的属性为 __globals__
    g = __import__('x', globals(), locals(), ('__globals__',), 0).__globals__
    
    执行这行代码时,解释器会调用我们伪造的 __import__(返回 set_builtin),然后尝试获取其 __globals__ 属性,并将其赋值给 g。这样我们就获得了 chall.py 模块的全局字典 g
  4. 执行任意代码g 中包含了原始的 exec 函数。我们可以用它来执行任意 Python 代码。由于输入过滤只在第一层,字符串内的点号不会被检查。我们可以构造一个字符串,如 "__import__('os').system('cat /app/flag*')",然后通过 g['exec'] 来执行它。

最终 Payload

set_builtin('__import__',lambda*a:set_builtin);g=__import__('x',globals(),locals(),('__globals__',),0).__globals__;g['exec']("__import__('os').system('cat /app/flag*')")

关键点

  • 利用 set_builtin 函数对象的 __globals__ 属性逃出沙箱。
  • 通过伪造 __import__ 并结合 import 语句的机制,间接获取 __globals__
  • 利用字符串内可包含点号的特性,绕过输入检查。

blazinglyfast

描述:一个 Rust 类型安全 Jail 题目。需要编写一个函数 jail(input: In) -> Out,函数体必须通过 Safe Rust 编译,并且能够将输入的 In 类型伪装成 Out 类型返回,使得服务端的校验通过并输出正确的 token。

背景

  • InOut 是两种不同的结构体类型,不能直接转换。
  • 必须使用 Safe Rust,不能使用 unsafestd::mem::transmute
  • 目标是通过 Safe Rust 中的类型系统漏洞,构造出假的内存布局,让服务端将 In 的字节解释为 Out

漏洞利用
利用 Rust 历史上有名的嵌套引用和隐含生命周期边界(implied bounds)的 soundness bug(issue #25860)。核心思路是通过高阶生命周期(higher-ranked trait bounds, HRTB)构造一个本不存在的 'static 引用。

Payload 构造

fn jail(input: In) -> Out {
    fn id<T>(x: T) -> T { x }
    fn bad<F>(f: &F) -> &'static F
    where
        for<'a> F: Fn(In) -> Out + 'a, // HRTB
    {
        f
    }
    let f: fn(In) -> Out = id;
    let rf: &'static fn(In) -> Out = bad(&f);
    let f = id as fn(In) -> In;
    let out = rf(input);
    out
}

解释

  1. bad 函数利用了 HRTB 的生命周期推导漏洞,将一个局部函数的引用 &F 强制转换成了 &'static F,尽管该函数的实际生命周期并非 'static。这是一个悬垂引用(dangling reference)。
  2. let rf: &'static fn(In) -> Out = bad(&f); 使得 rf 指向了第一个局部变量 f(其类型为 fn(In) -> Out)。
  3. let f = id as fn(In) -> In; 重新声明了一个同名的局部变量 f,其类型为 fn(In) -> In。由于栈复用,这个新的 f 很可能覆盖了之前那个 f 的内存位置。
  4. let out = rf(input); 通过悬垂引用 rf 调用函数。此时 rf 指向的内存位置已经被新的 fn(In) -> In 函数指针(即 id 函数)覆盖。因此,实际执行的是 id(input),它返回 input(类型为 In)。
  5. 然而,调用点期望的返回类型是 Out。Rust 编译器在此处进行了不安全的类型擦除,将 In 类型的返回值直接当成了 Out 类型。由于 InOut 可能有不同的内存布局,这可能导致未定义行为,但在此题中,服务端只是按 Out 的格式解析这些字节,如果 input 的字节恰好能通过校验,攻击就成功了。

关键点

  • 利用 Safe Rust 中 HRTB 的生命周期漏洞制造悬垂引用。
  • 利用栈变量覆盖,改变悬垂引用实际指向的函数。
  • 利用函数调用时的类型擦除,将一种类型的值伪装成另一种类型。

Pwn 类题目

transmutation

描述:一个二进制补丁题目。程序允许用户一次修改代码段([chall, main) 区间内)的任意一个字节。目标是通过有限的修改,逐步获得任意代码执行,并读取 flag。

初始能力

void chall() {
    unsigned long long where;
    unsigned long long what;
    read(0, &where, 8);
    read(0, &what, 8);
    if (where < (unsigned long long)chall || where >= (unsigned long long)main) {
        exit(1);
    }
    *(char*)where = (char)what; // 一次 1 字节写
    // 函数返回
}

利用步骤

  1. 从一次写到无限写:目标是修改函数末尾的指令,使其不返回,而是跳转回函数内部继续执行,形成循环。
    • 目标指令:函数结尾通常是 leave; ret;leave 的机器码是 0xc9ret0xc3
    • 修改:将 leave (0xc9) 修改为 push rcx (0x51)。此时,rcx 寄存器中可能保存着一个有用的地址(例如,指向 chall 函数开头附近的某个地址)。
    • 效果:执行流变为 push rcx; ret;ret 会弹出栈顶(即刚刚压入的 rcx 的值)并跳转到该地址,从而实现循环。
  2. 稳定循环,避免栈溢出:第一次跳转回 chall 开头会重新建立栈帧,导致栈不断增长。需要第二次修改,将 rcx 指向的地址改为函数中一个已过栈帧建立的指令地址(例如 0x40114e),这样后续循环就不会再 push rbp,形成稳定的“读输入-检查-写字节”循环。
  3. 绕过地址检查:当前的写操作被限制在 [chall, main) 区间。需要修改检查条件跳转指令(jaejb)的一个字节,使其变成一个几乎总是跳转或总是不跳转的指令(例如,将 jae 改为 jmp),从而绕过检查,允许向 main 函数及之后的代码区写入。
  4. 写入 Shellcode:在 main 函数中找一个合适的位置(例如 call chall 之后的代码区),利用无限写的能力,将 open/read/write 的 shellcode 逐字节写入。
  5. 修复控制流,执行 Shellcode:最后,修复第一步修改的指令(将 push rcx 改回正常的 leave 或修改 rcx 指向 shellcode 的地址),使得函数最终返回时,控制流跳转到我们写入的 shellcode,执行 cat /app/flag.txt

Exp 脚本:(略,需实现上述步骤的自动化字节写入)

spelling-bee

描述:一个自定义的 Forth 解释器,存在 UAF(释放后使用)漏洞。通过堆风水(Heap Feng Shui)控制释放后的内存布局,最终实现代码执行。

程序结构

  • 单词定义: <name> ... ; 会分配多个堆块:compile_name (存储名字), compile_def (存储单词引用数组), word_t (存储代码指针和参数), dict_t (字典链表节点)。
  • 单词删除forget <name> 会释放 word_tcompile_def,但没有检查 referenced_by 计数,导致 UAF。

漏洞利用

  1. 制造悬挂指针
    • 定义单词 B
    • 定义单词 A,在其定义中引用 B(此时 A.compile_def 中保存了指向 B.word 的指针)。
    • 执行 forget B。这会释放 B.word (0x30 chunk) 和 B.compile_def (0x90 chunk),但 A.compile_def 中的指针仍然指向已释放的 B.word
  2. 堆风水控制
    • 通过定义和释放特定长度的名字,整理 tcache 中 0x30 和 0.90 大小的堆块链表,确保接下来分配特定大小的堆块会复用我们释放的内存。
  3. 覆盖 word_t 结构
    • 定义一个名字长度为 30 的新单词。compile_name 会分配一个 0x30 的 chunk,我们希望它复用被释放的 B.word
    • 在这个名字中,前 24 字节任意,第 24-29 字节写入目标函数地址(如 dosys)的低 6 字节。因为 word_t 结构中,code 指针在偏移 24 处。strcpy 会自动在偏移 30 处写入 \x00,从而将 code 指针完整覆盖为我们想要的地址。param 指针在偏移 32 处,未被破坏。
    • 这样,我们就伪造了一个 word_t,其 code 指向 dosysparam 仍指向原来的 B.compile_def(一个已被释放的 0x90 chunk)。
  4. 覆盖 compile_def 为命令字符串
    • 定义一个名字长度为 127 的新单词。compile_name 会分配一个 0x90 的 chunk,我们希望它复用被释放的 B.compile_def
    • 在这个长名字中,写入我们要执行的命令字符串,例如 cat flag*; exit; 后跟填充字符。
    • 这样,之前伪造的 word_tparam 现在指向了一片我们控制的、包含命令字符串的内存。
  5. 执行:执行单词 AA 的定义会执行其 compile_def 中保存的单词指针,即我们伪造的 word_t。这相当于调用 dosys(param)。由于 dosys 可能是 system 的包装,而 param 指向我们的命令字符串,最终实现了命令执行。

关键点

  • 利用 forget 不检查 referenced_by 导致的 UAF。
  • 精确控制堆布局,使不同大小的分配落到预期的已释放内存块上。
  • 通过覆盖 word_tcode 指针和 param 指针所指向的内存内容,实现 system(command) 调用。
相似文章
相似文章
 全屏