b01lers CTF 2026 Writeup 教学文档
概述
本文档旨在详细解析 b01lers CTF 2026 比赛中的部分赛题,涵盖 MISC、Crypto、Reverse、Jail 和 Pwn 等多个类别。每个题解都包含对题目背景、核心思路、解题步骤和关键技巧的详尽分析,旨在为读者提供完整的学习路径。
MISC 类题目
humanity-check
描述:这是一道签到题,但具有一定迷惑性。
解决:题目本身并不复杂,但需要一定的尝试和观察。经过大约十分钟的探索,即可得到 flag。
Flag:bctf{krauq_blocked}
badyuri
描述:比较 yuri.txt 和 yuri_1.txt 两个文件,它们长度相同,但存在 30 处单字符差异。
核心思路:
- 对每一处差异,计算
ord(修改后字符) - ord(原字符)的差值。 - 将这些差值转换为对应的 ASCII 字符。
- 将转换得到的字符按顺序连接,即可形成 flag。
解题步骤:
- 使用
tarfile库安全地解压提供的yuri.tar.gz文件。 - 分别读取
yuri.txt和yuri_1.txt的内容。 - 逐字符比较两个字符串,记录差异位置、原字符、新字符以及
ord(新字符) - ord(原字符)的差值。 - 将每个差值转换为对应的字符(
chr(delta))。 - 将所有转换得到的字符连接,即为 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)
Flag:bctf{w3_l0ve_yur1_rB4DN8aULH9}
bctf-infra
描述:一个涉及基础设施竞态条件(Race Condition)的题目,包含三个挑战(chal1, chal2, chal3)。目标是读取 chal3 目录下的 flag.txt,但每个挑战运行在独立的、权限受限的系统用户下。
环境与限制:
chal1:允许执行的 Python 代码字符集受限(小写字母、标点、数字,但禁止字符e和o),最后执行exec(inp)。chal2:允许的字符集更小(string.ascii_lowercase + "._[]; "),难以直接利用。chal3:只允许空白字符,几乎无法直接执行代码。- 关键文件
flag.txt存在于chal3目录,权限为700,理论上chal1用户无法访问。
漏洞根源:
在 challenge_server.py 的 run_challenge 函数中,使用 shutil.copytree() 将挑战目录复制到临时目录 /tmp/<随机名>/app。这个过程不是原子的:
- 创建目标目录并复制文件(此时
flag.txt已被复制)。 - 最后将源目录的权限和元数据同步到目标目录(将权限设置为
700)。
在这两个步骤之间存在一个短暂的“竞态窗口”,在此期间,目标目录的权限尚未被收紧,其他用户(如chal1进程)有可能读取到其中的flag.txt。
利用链构建:
- 获得代码执行:首先连接到
chal1。由于字符集限制,不能直接使用包含e或o的字符串(如open,exec,__import__)。解决方法是使用八进制转义(\xxx)来构造敏感字符串,绕过过滤。例如,open可以写成\x6f\x70\x65\x6e。 - 突破字符限制:在
chal1中执行一个外层 Payload,其核心目标是启动一个不受字符限制的 Python 子进程。这可以通过__builtins__.__dict__[...]获取__import__,动态导入subprocess模块,并调用subprocess.Popen来实现。 - 发起竞态攻击:在启动的、无字符限制的子进程中,并发执行以下两个任务:
- 扫描线程:不断扫描
/tmp/*/app/flag.txt文件,尝试在竞态窗口内打开并读取。 - 触发线程:不断连接本地服务(
127.0.0.1:1337)并选择chal3挑战,以高频触发copytree操作,增大命中竞态窗口的概率。
- 扫描线程:不断扫描
- 提取真实 Flag:读取到的文件内容可能包含占位符
fake{fake_flag},需要过滤并识别以bctf{开头的真实 flag。
技术要点:
- 竞态条件(TOCTOU):利用文件系统操作的时间差进行攻击。
- 受限字符集下的代码执行:通过字符串编码(八进制、十六进制)和动态属性访问绕过过滤。
- 本地服务交互:在
chal1的沙箱内,可以连接同一环境下的本地监听服务来触发其他挑战。
Crypto 类题目
ecb-lasagna
描述:题目基于 AES-ECB 模式,对 flag 进行了一种自定义的链式加密操作。
加密过程简述:
- 将 flag 的每个字符重复两次,形成一个新序列。
- 对于序列中的每个位置
i,计算F[i] = AES(flag[i] * 16)(即用 flag 的单个字符填充一个 16 字节块进行 AES 加密)。 - 将
F[i]这个 16 字节块“环形平移”到位置i后,与之前的总结果进行异或,得到最终输出。
解密思路:
由于每个输出块只依赖于前 8 个字符(因为原字符被复制了两次),且已知 flag 格式以 bctf{ 开头,因此可以:
- 利用已知前缀,逐步推导后续字符。
- 对后续未知字符进行有限爆破(例如 3 个字符)。
- 根据推导和爆破结果,结合加密算法的线性(异或)特性,唯一确定整个 flag。
Flag:bctf{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 位。
服务端流程:
- 生成 256 位随机
master_key。 - 用
master_key的整数形式作为种子,初始化一个random.Random()实例(MT19937)。 - 加密 flag 得到
(nonce, ciphertext, tag)并输出。 - 允许用户进行最多 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)。
- 随机生成一个 16 字节的
攻击原理:
- 线性方程:在 GF(2) 上,
GHASH(H, C)函数关于H是线性的。因此,每次查询泄露的tag_lo可以写成关于H的 128 个比特和fault_value的 64 个比特的线性方程。 - 状态恢复:MT19937 的内部状态是 624 个 32 位字。每次查询消耗 2 个 32 位输出(即 64 位)。704 次查询足以获取超过一轮(624*32=19968 比特)的状态输出。
- 构建方程组:利用前 624 次查询,可以建立
624 * 64 = 39936个线性方程,未知数是H的 128 比特和 MT19937 状态的 19968 比特,方程数远超未知数,可以求解。 - 逆向 PRNG:求解方程组后,得到
H和 624 个连续的 MT19937 输出。通过untemper操作恢复内部状态,然后逆向random.Random(int.from_bytes(master_key, "big"))的初始化过程,最终恢复出原始的master_key整数,即密钥本身。 - 解密 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 统计。要求z和x基下各有至少 2 轮有效检查,且总有效检查轮数qber_checked >= 4。 - Info 轮:当我们的测量基与 Alice 的测量基一致时,该轮被标记为
info_keep = yes。我们可以提交一个测量计划(measurement_plan)来测量量子位 b 或 c,并猜测 secret bit。猜对则info_correct += 1。要求info_rounds >= 16且raw_info_accuracy >= 0.60。 - 最终通过条件:除了满足上述各指标下限外,还需满足
mutual_info_empirical >= 0.5、frontier_abs_dev_bits <= 0.25、announce_balance <= 0.50以及mutual_info_empirical > raw_mutual_info_empirical。
攻击策略核心:
- 绕过协议,专注评分:目标不是实现协议,而是构造一个能在评分函数下“骗过”检查的攻击策略。
- 平衡宣告:预先公布的测量基不能总是
z或总是x。可以采用循环模式(例如 64 轮z,32 轮x)来满足announce_balance <= 0.50,并确保 test 轮中两种基都有机会被检查。 - 构造酉矩阵:利用题目中 Bell 态的四种逻辑输入(S0:
|phi->,|psi+>; S1:|Phi->,|Psi+>)。选择其中一对逻辑基(如|0_L> = |phi-> ⊗ |0>,|1_L> = |psi+> ⊗ |0>)进行编码。构造一个 8x8 的酉矩阵,将这两个输入态映射到一对有利于后续测量的 3-qubit 码字上。矩阵的其余部分用正交基补全,确保其整体是酉矩阵。 - 自适应测量:在 info 轮且
info_keep=yes时,服务端会告知当前是 S0 还是 S1。我们可以据此自适应地选择测量计划:- 如果是 S0,测量量子位 b 的 z 分量。
- 如果是 S1,测量量子位 b 的 x 分量。
- 根据测量结果猜测 secret bit(例如,结果 0 猜 0,结果 1 猜 1)。
- 参数调优:通过调整预先公布的
outcome与测量策略的对应关系,以及酉矩阵的具体参数,使得最终结果满足所有评分条件,特别是 QBER 和互信息量的要求。
Flag:bctf{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] 满足该等式。
数学推导与化简:
- 设
a = g * c。可以证明:(g*c)^x = s_{g, phi}(x) * c^x = h * c^x。 - 设
c的阶为m(c^m = 1),由于c的阶很小(题目中最多为 8),可以枚举r = x mod m,r ∈ [0, m-1]。 - 令
x = r + m*t。代入上式得:a^{r + m*t} = h * c^r。 - 等式两边右乘
a^{-r}:(a^m)^t = (h * c^r) * a^{-r}。
解题步骤:
- 确定阶 m:通过不断与
c相乘,直到结果为 1,得到c的阶m,并计算出c^0, c^1, ..., c^{m-1}。 - 计算 a:
a = mul(g, c)。 - 枚举余数 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)高效求解。 - 得到 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。
程序逻辑:
- 初始化和检查:程序开始会检查 CPU 是否支持 AMX,不支持则报错退出。还会检查
flag.txt和假 flag。 - 状态矩阵:程序维护一个大小为 0x300 字节(可能是 3x(16x16) 的块)的状态矩阵。三轮挑战各有不同的初始状态(存储在
0x40f800)。 - 矩阵运算:对于每个输入的 token:
- 根据 token 的两个字符,从硬编码在二进制中的选择矩阵(
0x409000)和辅助矩阵(0x40a000)中选择一组矩阵。 - 使用
ldtilecfg,tileloadd,tdpbssd,tilestored,tilerelease等 AMX 指令,对当前状态矩阵和选中的矩阵进行块矩阵乘法运算,生成下一个状态矩阵。
- 根据 token 的两个字符,从硬编码在二进制中的选择矩阵(
- 约束检查:每轮结束后,程序检查状态矩阵:
- 稀疏性约束:将状态矩阵的前 0x240 字节(36行,每行16字节)按行求和,要求每行的和小于 2(即每行最多只有一个“激活”位置)。
- 目标位检查:检查状态矩阵中一个固定的偏移位置(
0x412380 - 0x412270 = 0x110)的值是否为 1。 - 只有同时满足稀疏性约束和目标位为 1,该轮才算通过。
解题思路:
- 逆向 AMX 逻辑:将
0x40142b ~ 0x401ae7的 AMX 指令序列翻译成高级语言(如 Python/NumPy)中的矩阵运算函数step(state, token)。这一步需要理解 AMX 的 tile 配置、加载、乘加和存储操作。 - 转化为图搜索问题:
- 状态:程序的完整状态矩阵(0x300 字节)。由于稀疏性约束很强,实际有效状态空间远小于理论值。
- 动作:64 种可能的 token。
- 转移:
next_state = step(current_state, token)。 - 起点:每一轮给定的初始状态。
- 终点:满足稀疏性约束且目标位为 1 的状态。
- 目标:对每一轮,找到从起点到终点的最短 token 序列(即输入字符串)。
- 搜索算法:由于状态空间大但约束强,可以使用 BFS 并配合剪枝(例如,丢弃违反稀疏性约束的状态)。每轮独立求解。
- 得到输入:将三轮搜索得到的最短 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)。
解题步骤:
- 解析程序:编写脚本,将
code.bin.gz中的机器码流识别并翻译成上述高层操作列表。 - 逆向计算:对于给定的每组输出
(A, X, Y),从后往前遍历操作列表,对每个操作执行其逆操作。 - 得到输入:执行完所有逆操作后,得到的就是对应的输入
(A0, X0, Y0)。 - 提交:将 20 组输入依次提交给远程服务,获取 flag。
关键点:
- 程序虽然长,但结构简单,全是可逆的微操作。
- 核心难点在于从海量字节码中准确识别出有限几种操作模式。
indirect-memory-access
描述:一个 Game Boy Advance (GBA) ROM 逆向题。程序接收一系列按钮输入,将其编码为比特流,然后通过一个由 DMA(直接内存访问)副作用实现的“硬件”组合逻辑电路进行校验。校验通过后,将按钮序列作为 flag 的一部分输出。
输入编码:
- 程序从 GBA 的按键寄存器读取输入。每个按键对应一个位掩码(如 A 键为
1<<0)。 - 按键被编码为一元码(Unary Code):对于一个按键掩码
1<<k,它被编码为k个0比特后跟一个1比特。因此,一个按键编码后的比特长度为其索引k+1。 - 程序内置了一个字符映射表(地址
0x08008ba4):a,b,s,S,R,L,U,D,r,l分别对应索引 0 到 9。因此,段长为 1 的编码对应字符a,段长为 2 对应b,...,段长为 10 对应l。 - 程序读取总计 128 个比特的输入。
校验逻辑:
- 组合逻辑网络:在地址
0x08000310的函数中,程序从输入比特流缓冲区(0x030000a0)每次取两个比特,调用一个“黑盒函数”,然后将结果与后续的中间结果进行组合,形成一个多层的组合逻辑电路。 - DMA 门电路:
bl指令跳转到的地址(0x080015b2)只是一个bx r5。r5被设置为0x03000025,这是一段在启动时从 ROM 复制到 IWRAM(内部高速 RAM)的代码。这段代码操作 DMA 控制寄存器(如0x040000ba,0x040000c6,0x040000de),利用 DMA 传输的副作用来实现一个 2 输入 1 输出的布尔逻辑门功能。这正是标题“间接内存访问”的由来。 - 最终检查:整个网络最终产生一个输出。如果输出符合预期,则校验通过。
解题步骤:
- 静态分析:从
0x08000310的函数中,提取出所有对“黑盒函数”的调用,以及它们之间的数据依赖关系,重建出整个布尔逻辑电路的 DAG(有向无环图)。 - 逆向电路:从电路的最终输出(应为逻辑“真”,即 1)开始,反向推导每个逻辑门的输入,直到回溯到 128 个原始输入比特。这类似于 SAT 求解或逆向逻辑电路。
- 解码输入:得到 128 个输入比特后,按照一元编码规则进行解码:寻找连续的
0直到遇到一个1,这段长度L就对应一个字符(L=1->a,L=2->b, ...,L=10->l)。 - 获取 Flag:ROM 中明确提示了 flag 格式:
bctf{<above chars>}。将解码出的字符序列填入花括号内即可。
最终按钮序列:解码后得到 SbaRaaRlabSabaS,其总长度恰好为 128 比特。
Flag:bctf{SbaRaaRlabSabaS}
Jail 类题目
build-a-builtin
描述:一个 Python 沙箱逃逸题目。内置命名空间被清空,但提供了一个可以往 builtins 中添加属性的后门函数 set_builtin。目标是在禁用点号(.)的情况下,获取代码执行并读取 flag。
环境限制:
builtins.__dict__.clear()
def set_builtin(name, value):
builtins.__dict__[name] = value
input()只允许一行输入。- 输入中不能包含字符
.。 builtins字典被清空,大部分内置函数和类型不可用。
利用链:
- 获取
__globals__:set_builtin是一个函数对象,它拥有__globals__属性,指向其定义所在模块(chall.py)的全局命名空间。但无法直接使用.访问。 - 伪造
__import__:利用set_builtin可以设置builtins中的属性。我们可以设置一个假的__import__函数,当被调用时,返回我们指定的对象(这里返回set_builtin函数本身)。set_builtin('__import__', lambda *a: set_builtin) - 利用
import语句:Python 的import语句会调用__import__函数。我们可以构造import语句来“导入”一个模块,并指定要导入的属性为__globals__。
执行这行代码时,解释器会调用我们伪造的g = __import__('x', globals(), locals(), ('__globals__',), 0).__globals____import__(返回set_builtin),然后尝试获取其__globals__属性,并将其赋值给g。这样我们就获得了chall.py模块的全局字典g。 - 执行任意代码:
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。
背景:
In和Out是两种不同的结构体类型,不能直接转换。- 必须使用 Safe Rust,不能使用
unsafe或std::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
}
解释:
bad函数利用了 HRTB 的生命周期推导漏洞,将一个局部函数的引用&F强制转换成了&'static F,尽管该函数的实际生命周期并非'static。这是一个悬垂引用(dangling reference)。let rf: &'static fn(In) -> Out = bad(&f);使得rf指向了第一个局部变量f(其类型为fn(In) -> Out)。let f = id as fn(In) -> In;重新声明了一个同名的局部变量f,其类型为fn(In) -> In。由于栈复用,这个新的f很可能覆盖了之前那个f的内存位置。let out = rf(input);通过悬垂引用rf调用函数。此时rf指向的内存位置已经被新的fn(In) -> In函数指针(即id函数)覆盖。因此,实际执行的是id(input),它返回input(类型为In)。- 然而,调用点期望的返回类型是
Out。Rust 编译器在此处进行了不安全的类型擦除,将In类型的返回值直接当成了Out类型。由于In和Out可能有不同的内存布局,这可能导致未定义行为,但在此题中,服务端只是按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 字节写
// 函数返回
}
利用步骤:
- 从一次写到无限写:目标是修改函数末尾的指令,使其不返回,而是跳转回函数内部继续执行,形成循环。
- 目标指令:函数结尾通常是
leave; ret;。leave的机器码是0xc9,ret是0xc3。 - 修改:将
leave(0xc9) 修改为push rcx(0x51)。此时,rcx寄存器中可能保存着一个有用的地址(例如,指向chall函数开头附近的某个地址)。 - 效果:执行流变为
push rcx; ret;。ret会弹出栈顶(即刚刚压入的rcx的值)并跳转到该地址,从而实现循环。
- 目标指令:函数结尾通常是
- 稳定循环,避免栈溢出:第一次跳转回
chall开头会重新建立栈帧,导致栈不断增长。需要第二次修改,将rcx指向的地址改为函数中一个已过栈帧建立的指令地址(例如0x40114e),这样后续循环就不会再push rbp,形成稳定的“读输入-检查-写字节”循环。 - 绕过地址检查:当前的写操作被限制在
[chall, main)区间。需要修改检查条件跳转指令(jae或jb)的一个字节,使其变成一个几乎总是跳转或总是不跳转的指令(例如,将jae改为jmp),从而绕过检查,允许向main函数及之后的代码区写入。 - 写入 Shellcode:在
main函数中找一个合适的位置(例如call chall之后的代码区),利用无限写的能力,将open/read/write的 shellcode 逐字节写入。 - 修复控制流,执行 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_t和compile_def,但没有检查referenced_by计数,导致 UAF。
漏洞利用:
- 制造悬挂指针:
- 定义单词
B。 - 定义单词
A,在其定义中引用B(此时A.compile_def中保存了指向B.word的指针)。 - 执行
forget B。这会释放B.word(0x30 chunk) 和B.compile_def(0x90 chunk),但A.compile_def中的指针仍然指向已释放的B.word。
- 定义单词
- 堆风水控制:
- 通过定义和释放特定长度的名字,整理
tcache中 0x30 和 0.90 大小的堆块链表,确保接下来分配特定大小的堆块会复用我们释放的内存。
- 通过定义和释放特定长度的名字,整理
- 覆盖
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指向dosys,param仍指向原来的B.compile_def(一个已被释放的 0x90 chunk)。
- 定义一个名字长度为 30 的新单词。
- 覆盖
compile_def为命令字符串:- 定义一个名字长度为 127 的新单词。
compile_name会分配一个 0x90 的 chunk,我们希望它复用被释放的B.compile_def。 - 在这个长名字中,写入我们要执行的命令字符串,例如
cat flag*; exit;后跟填充字符。 - 这样,之前伪造的
word_t的param现在指向了一片我们控制的、包含命令字符串的内存。
- 定义一个名字长度为 127 的新单词。
- 执行:执行单词
A。A的定义会执行其compile_def中保存的单词指针,即我们伪造的word_t。这相当于调用dosys(param)。由于dosys可能是system的包装,而param指向我们的命令字符串,最终实现了命令执行。
关键点:
- 利用
forget不检查referenced_by导致的 UAF。 - 精确控制堆布局,使不同大小的分配落到预期的已释放内存块上。
- 通过覆盖
word_t的code指针和param指针所指向的内存内容,实现system(command)调用。