fault_hash
给 halois 赞助了一道题用于 SCTF2026,没想到变成了0解题
(文章有使用 Agent 来辅助生成部分内容,因此可能存在细微区别)
fault_hash
其实出题的时候考虑到现在 ai 发展这么迅猛,会不会被打烂,结果一个解也没,还是非常惊讶的。(不过听 halois 说给的提示词足够好的话 30 min 就可以秒了不知道真的假的)
题目
1 | from md5 import md5 |
其中 md5.py
1 | from random import Random |
md5
想要解决这个题目,首先肯定要了解一下 md5 的结构。
MD5 处理消息时会先 padding。假设原消息长度是 bit,padding 之后长度会变成 的倍数,并且最后 bit 存原消息长度:
然后消息会被切成若干个 bit 的 block。每个 block 又会被拆成 16 个 32-bit word:
MD5 内部有 4 个 32-bit state:
初始值是固定的:
每个 block 会执行 64 个 step。一个 step 大概长这样:
这里 是 MD5 的四种布尔函数之一, 是当前 step 用到的消息 word, 和 是常数。注意这个形式很重要:一个 step 只会更新一个寄存器,其它三个寄存器在这一行里只是作为输入。
回到题目,其就是魔改了 md5 的 step 都存进了一个 table,然后在查询之前利用 seed 进行打乱。
如果只看 md5(s, seed),它像是一个普通的 hash oracle。但结合上面的 MD5 结构就会发现:只要能控制这 64 行 step 的顺序,就能让某些寄存器先动、某些寄存器后动,从而设计出局部方程。
所以这题真正要打的不是 MD5 的碰撞,而是:
如何把
seed变成 64 个 step 的可以相对于我们好利用的排列控制器?
shuffle
本质就是 MT19937 如何恢复 seed,这个网上文章也很多了,主要是看其如何调用的随机数代码:
1 | for i = 63 ... 1: |
其中 randbelow 来自 getrandbits(k),其实细节知不知道似乎也没啥区别,丢 ai 就好了 XD
拿到 seed 以后我们就解决了第一步,就是可以自己控制 step 的排列。
单块原像恢复
回到题目,我们发现 s 是 94 bytes 的也就是两块,而两块相比于一块的情况要较为复杂,再加上我们可以自己输入 leak 来获取 s 前面几 bytes 的情况。因此可以考虑先研究 1 块的情况。
考虑到为了最大限度的获得信息,我们取 leak = 55,所以题目第一步就变成了如何恢复单块原像恢复。(leak 大于 55 会因为padding 出现两块)
对其进行 padding 以后 的结构为:
可以发现其实我们需要恢复的只有 ,然后其中 是一个非常有用的 word。
回到前面我们讲的 step 的式子:
而且一个 step 只会更新一个寄存器,也就是只会改变 ,但是 是不动的。
然后又可以注意到对于每一个 都存在一个 step 只改变其的 ,因此我们如果构造这么两个排列:
其中 是使用 的那个更新 的 step, 是使用 的那个更新 的 step, 是剩余 62 个的随机排列。
执行完 后,内部状态设为:
接下来 和 都只更新 ,所以无论顺序是 还是 ,最后输出里的 都应该一样。考虑把两边的 回代消掉中间状态可以得到类似:
的方程,通过解方程可以得到 或者少量的候选,不过这个可以换不同的 来取交集解决。最后循环多次即可拿到所有的 从而恢复 。
恢复 第一块
然后注意到 的第一块是 64 bytes,因此我们还需要恢复剩下的 9 bytes。
很显然这部分肯定是无法通过 h2 直接解决,需要使用 h1。
构造 witness
观察题目里的 MD5 压缩函数,每个 step 只会更新 a,b,c,d 中的一个寄存器。因此我们可以固定某个 target,例如 b,只在更新 b 的 16 个 step 内做局部换序。
选两个 step:
要求它们满足:
- target 相同
- 使用的布尔函数相同
- rotation 相同
然后在中间插入若干个同 target 的 step,构造两种短序列:
也就是说,left 和 right 只交换了两端两个 step 的顺序。
将这个短序列嵌入到该 target 对应的 16 个 step 中,其它 target 的 step 保持一致。然后分别查询服务得到两次 h1。
如果两次结果中非 target 的三个 digest word 完全相同,就说明这个局部换序对真实消息块形成了一个有效约束。这样的约束记为一个 positive witness。
例如 target 为 b 时,检查:
如果成立,则保留该 witness。
用 witness 检查候选
对于任意候选的第一块:
我们在本地模拟这组 witness。若该候选正确,则对应的局部换序应满足:
如果不相等,就用两者差异的 bit 数作为代价:
对所有 witness 求和:
正确的第一块应该满足:
因此恢复 s[55:64] 就变成了一个 9 字节的小搜索问题:寻找一个 guess_9,使得所有 witness 的总代价为 0。
实际做的时候可以先收一批正 witness,然后做小种群搜索和坐标修正。为了防止 witness 太少导致假解,找到 score = 0 后,再额外收一批 holdout witness 验证一下。(很显然这种玩意丢给 ai 就完事了,自己写是不可能的)
然后由此我们可以完整恢复出来了 的第一块。
恢复 第二块
第一块知道以后,就可以把两块问题拆开。
对于任意排列 P,第一块压缩后的链值可以本地算:
服务端给的 h1(P) 是两块都压完之后的结果,所以可以扣掉第一块:
这样题目就变成了 已知初始链值,如何恢复第二块。
第二块格式也很固定:
所以未知量就是 7 个完整 word,加上 M1[7] 的低 16 bit。
单 word 过滤
接下来我们先不急着直接恢复全部的 ,而是先对每一个未知 word 单独做过滤。
对于某个未知的 ,可以找一个和它同 residue 的已知 padding word 作为参照:
这里 都是 ,所以它们是已知的。
为什么要找同 residue 的 word?因为在 MD5 的 step 表里面, 和 会出现在同 target、同函数、同 rotation 的位置上。比如 和 都能找到形如:
的 step。这样我们就可以构造一个只和当前未知 word 有关的局部换序。
设:
- 是使用未知 的 step
- 是使用已知 padding word 的 step
构造两个局部 gadget:
这里的 middle 只选同 target 且消息 word 已知的 step,或者干脆为空。这样在检查某个候选值 的时候,整个局部 gadget 里面唯一的未知量就是 。
完整排列可以写成:
也就是把这个局部 gadget 放在最后。因为最后几行都只更新同一个 target,所以其它三个寄存器在 gadget 执行期间不会变,并且它们的值可以直接从最终输出里读出来。
不过这里有一个两块消息带来的问题:同一组排列会同时作用在第一块和第二块上。如果 和 压完第一块以后的链值不同,那么第二块的初始状态也不同,后面就不能把左右两边放在一起比较。
所以我们先在本地筛 witness,要求:
只有满足这个条件的排列对,才拿去查询服务端。这样进入第二块时,左右两边的初始链值是一样的;又因为前缀 完全相同,所以进入最后局部 gadget 前的状态也一样。
现在考虑如何检查候选值 。一个 target step 可以写成:
于是可以反过来:
对于服务端返回的 和 ,我们先扣掉第一块链值,拿到第二块最后的裸状态 。然后从左右两边的 target 输出开始,把最后的 gadget 逆回去。
如果 是正确的,那么左右两边应该逆回同一个局部入口:
否则这个 就可以丢掉。
实际实现时不会直接扫完整的 。第一组 witness 可以用来生成候选:因为 和 被选成同 target、同函数、同 rotation,入口相等的条件最后会化成只含 的 rotation 方程,和前面恢复 时类似,只需要枚举少量低位分支即可得到一小批候选。后续再换不同的 witness,不断取交集即可。
对于 ,它的格式是:
也就是小端意义下:
其中 只有低 bit 未知,所以这一项甚至可以直接枚举 个值,再用上面的 witness 过滤。
经过这个步骤后,每个未知 word 都会得到一个较小的候选集合:
其中 里的值都已经满足 padding 的高 bit。
MITM 合并
单 word 过滤只能说明“每个 word 单独看起来可能对”,但这些候选之间不一定能组合成同一个真实第二块。因此最后还需要把这些候选合并。
这里继续利用 step 只更新一个寄存器的性质。
如果我们把某个 target 的 16 个 step 放在整个排列最前面,那么这 16 行执行完以后,后面的 48 行都不会再修改这个 target。因此最终 digest 里这个 target 的裸输出,只依赖:
- 第二块进入时的链值;
- 这个 target 对应的 16 个 step;
- 这些 step 用到的消息 word。
第一块 已经知道,所以对于任意排列 ,第二块入口链值:
可以本地算出来。服务端返回的 扣掉 后,也能得到第二块执行完后的裸状态。于是我们可以对某个 target 单独做 meet-in-the-middle。
比如 target 为 时,涉及的未知 word 只有:
其它 的 step 用到的都是 padding word,已经已知。
我们控制 的 16 行顺序,把它拆成两段:
其中 主要放使用 的 step, 主要放使用 的 step,中间可以穿插已知 padding step。
然后做 MITM:
- 枚举 ,从入口 正向执行 ,得到中间值 ,存入哈希表;
- 枚举 ,从最终 反向执行 ,也得到一个 ;
- 如果两个 相同,就保留这组四个 word 的组合。
这样就不用枚举四个 word 的完整笛卡尔积,而是拆成了左右两边各两个 word。
同理,target 为 时,涉及的未知 word 是:
于是再做一次 MITM,就可以得到剩下四个 word 的组合。
这两组刚好覆盖第二块的全部未知量:
如果一次 MITM 后还有多个组合,就换一组 target 行顺序重新收一个 witness 再过滤。一般一两组之后就能唯一。
最后得到完整的第二块:
所以取数据部分即可:
最终:
拿到完整的 bytes 之后,提交即可得到 flag。