☰
自然溢出哈希碰撞构造:从原理到实战的完整解析
2026/10/15 10:27:45 网站建设 项目流程

1. 从一道经典构造题说起:自然溢出哈希的命门在哪里

字符串哈希是算法竞赛里最常用的工具之一,写起来简单,跑起来飞快。绝大多数人第一次接触哈希时,用的都是所谓的“自然溢出法”——把哈希值定义成一个无符号整数,每次乘上基数再加上字符值,让溢出自动发生,相当于对 2 的 64 次方取模。代码短到只有几行,看起来又优雅又高效。

但优雅的背后藏着一个致命问题:自然溢出法是可以被构造数据卡掉的。这道题要做的,就是给定一个基数,构造出两个不同的字符串,让它们的自然溢出哈希值完全相同。换句话说,我们要主动制造一次哈希碰撞,把这种“看起来很美”的写法打回原形。

我第一次遇到这个知识点的时候,心里是有点不服气的。2 的 64 次方是个天文数字,怎么可能说撞就撞?后来才明白,问题不在于模数够不够大,而在于这个模数本身的结构太特殊了——它是 2 的幂。任何和 2 的幂有关的模数,都会给攻击者留下可乘之机。这篇文章就把这道题的完整思路、背后的数学原理、以及我在实现过程中踩过的坑,从头到尾讲清楚。适合已经会写基础字符串哈希、想进一步理解哈希安全性、或者正在准备算法竞赛的读者。

2. 自然溢出哈希的数学结构:为什么 2 的幂模数天生脆弱

2.1 哈希函数的定义与“溢出即取模”的等价性

先把自然溢出哈希的公式写清楚。对于一个字符串 s,它的哈希值定义为:

H(s) = (s[0] * B^(n-1) + s[1] * B^(n-2) + ... + s[n-1] * B^0) mod 2^64

其中 B 是基数,通常取一个质数,比如 131、13331 之类。在 C++ 里用unsigned long long存储,乘法溢出时自动对 2^64 取模,所以代码写起来就是:

unsigned long long h = 0; for (char c : s) h = h * B + c;

这行代码等价于上面那个多项式在模 2^64 下的取值。理解这一点很关键:自然溢出不是“没有取模”,而是“隐式地对 2^64 取模”。很多人误以为不写取模就安全了,其实只是把模数换成了一个更糟糕的选择。

2.2 模 2 的幂时,偶数基数的退化现象

这里有一个容易被忽略的细节:如果基数 B 是偶数,那么 B 的若干次方在模 2^64 下会迅速变成 0。具体来说,B 中 2 的因子越多,B^k 越早归零。一旦 B^k ≡ 0 (mod 2^64),那么所有长度超过 k 的字符串,高位部分全部消失,哈希值只由最后 k 个字符决定。

这意味着什么?意味着大量不同的字符串会共享同一个哈希值,碰撞变得极其容易。所以正规做法里基数一定要取奇数,最好是质数。但即便基数取奇数,问题也没有完全解决,因为模数本身是 2 的幂这个事实没有改变。

2.3 奇偶分离:把问题拆成两个独立的部分

这是整道题最核心的观察。当基数 B 是奇数时,B 的任意次方都是奇数。我们来看哈希值的奇偶性:

  • 字符串最后一个字符 s[n-1] 乘以 B^0 = 1,它的奇偶性直接决定了整个哈希值的最低位的奇偶性。
  • 其他所有项都是“奇数 × 字符值”,字符值的奇偶性会影响结果。

更一般地,我们可以把哈希值按二进制位拆开看。由于模数是 2^64,最低位(第 0 位)只受最后一项影响,第 1 位受最后两项影响,以此类推。这种“低位只依赖后缀”的性质,正是构造碰撞的突破口。

提示:这个性质在模数为 2 的幂时成立,如果模数是质数(比如 10^9+7),就不存在这种逐位分离的结构,构造难度会大得多。

3. 构造碰撞的完整思路:从低位逐位对齐

3.1 核心目标:让两个字符串的哈希值在每一位上都相同

我们要构造两个长度相同(或不同)的字符串 A 和 B,使得 H(A) = H(B) mod 2^64。直接让整个多项式相等很难,但利用 2 的幂模数的逐位特性,我们可以从最低位开始,一位一位地把差异消掉。

具体策略是这样的:先构造一对字符串,让它们的哈希值最低位相同;然后在此基础上扩展,让最低两位相同;再扩展,让最低三位相同……一直做到 64 位全部相同,碰撞就完成了。

3.2 利用“加一个字符”来翻转特定位

假设我们已经有了一对字符串,它们的哈希值在低 k 位相同。现在我们在两个字符串末尾各加一个字符,看看会发生什么。

在末尾加字符 c,新的哈希值是 H' = H * B + c。由于 H 的低 k 位相同,H * B 的低 k 位也相同(乘以同一个奇数 B 不改变低位的相等关系)。所以新哈希值的低 k 位仍然相同,差异只可能出现在第 k 位及更高位。

而第 k 位的差异,取决于 c 的取值。通过选择合适的 c,我们可以控制第 k 位是 0 还是 1。这就给了我们逐位对齐的工具。

3.3 一个具体的构造模板

把上面的思路具体化。我们从空串开始,两个字符串都为空,哈希值都是 0,低 0 位当然相同。然后重复 64 次以下操作:

  1. 当前两个字符串哈希值在低 k 位相同。
  2. 在末尾各加一个字符,使得新哈希值的第 k 位也相同。
  3. k 增加 1。

问题是怎么选这两个字符。设当前哈希值为 h,加字符 c 后变成 h * B + c。我们要让两个新哈希值的第 k 位相同。由于 h * B 的低 k 位已经相同,第 k 位的差异只来自 c 的差异。所以只要让两个 c 的第 k 位相同即可——但 c 是字符,范围有限。

这里需要更精细的分析。实际上,常用的构造方法是利用 B 的逆元或者直接暴力搜索。对于 64 位,暴力搜索每个位置的两个字符,使得新哈希值在目标位上对齐,是可行的,因为每个位置只需要尝试常数种组合。

3.4 为什么长度会达到 64 以上

每对齐一位,字符串长度增加 1。要覆盖全部 64 位,长度至少是 64。但实际构造中,由于字符范围的限制和进位的复杂性,长度往往会超过 64,可能达到 100 多甚至更多。这道题通常不限制长度上限(或者上限很宽松),所以长度不是问题。

我在实现时用的是逐位搜索的方法:维护两个字符串,每一步尝试所有可能的字符对(比如 0 到 255),找到能让新哈希值在目标位上相同的那一对,然后继续。实测下来,每一步几乎都能很快找到合适的字符对,整体效率很高。

4. 代码实现与关键细节

4.1 基础框架

用 C++ 实现,核心是一个循环,逐步扩展两个字符串。伪代码如下:

unsigned long long ha = 0, hb = 0; string a, b; for (int k = 0; k < 64; k++) { // 尝试找到字符 ca, cb,使得 (ha * B + ca) 和 (hb * B + cb) // 在低 k+1 位相同 for (int ca = 0; ca < 256; ca++) { for (int cb = 0; cb < 256; cb++) { unsigned long long na = ha * B + ca; unsigned long long nb = hb * B + cb; if ((na & mask(k+1)) == (nb & mask(k+1))) { a += (char)ca; b += (char)cb; ha = na; hb = nb; goto next; } } } next:; }

其中mask(k)是低 k 位全 1 的掩码。这个框架的逻辑很直白:每一步都保证低 k+1 位对齐。

4.2 字符范围的坑

字符不能随便取。如果题目要求输出的是可见字符串,那么字符范围通常限制在 'a' 到 'z' 或者 ASCII 可见字符。这会大大缩小搜索空间,有时候在某个位置找不到合适的字符对。

遇到这种情况,我的处理办法是允许字符串长度增加,但不一定每步只加一个字符。可以在某一步加两个字符,相当于多一次乘法,给搜索更多自由度。或者换一个基数重新试。实测中,如果基数选得好(比如 131),用可见字符通常也能构造出来,只是长度会长一些。

4.3 基数的影响

不同的基数,构造难度不一样。基数越小,B 的幂增长越慢,低位的变化越“温和”,可能更容易对齐。基数越大,进位越复杂,但搜索空间也更大。我试过 131、13331、1000003 等几个常见基数,都能构造出来,只是长度和搜索时间有差异。

注意:如果基数本身是偶数,构造会变得极其简单,因为高位会退化。但正规题目的基数通常是奇数质数,所以还是要用逐位对齐的方法。

4.4 验证碰撞

构造完成后,一定要验证两个字符串的哈希值确实相等。用同样的哈希函数分别计算,比较结果。同时确认两个字符串不相同。这一步不能省,因为搜索过程中可能有逻辑错误导致假碰撞。

5. 实测中的意外与排查记录

5.1 第一次尝试:长度爆炸

我最初的想法是每一步都暴力搜索所有字符对,但没限制长度。结果发现某些位置找不到合适的字符对,程序陷入死循环或者字符串无限增长。后来加了长度上限和回退机制,才稳定下来。

排查过程是这样的:打印每一步的 k 值和当前哈希值的低位,发现卡在某个 k 上,所有字符对都无法让第 k 位对齐。原因是当前两个哈希值在第 k 位以上的差异太大,加一个字符无法弥补。解决办法是在卡住的时候,回退一步,换一个之前的选择。

5.2 第二次尝试:字符集限制导致的失败

换成可见字符后,搜索空间从 256 降到 95 左右,某些位置确实找不到解。我当时的做法是允许在字符串中插入一些特殊字符,但题目可能不接受。后来发现,只要在卡住的时候多加一个字符(相当于多乘一次 B),就能绕过。这个技巧很实用。

5.3 最终稳定版本的关键改动

最终的代码里,我加了两个机制:一是每步搜索时,如果找不到解,就尝试加两个字符;二是设置一个总长度上限,超过就换基数重来。这样虽然不能保证一次成功,但多试几次总能构造出来。实测下来,用基数 131 和可见字符,平均长度在 80 到 120 之间。

6. 从这道题延伸出的哈希安全思考

6.1 自然溢出法为什么不该在正式场合使用

这道题最直接的教育意义就是:自然溢出法在对抗性数据面前是不安全的。算法竞赛中,如果出题人知道你的哈希写法,就可以专门构造数据卡掉你。这不是理论上的可能性,而是已经被反复验证的事实。

正确的做法是使用双哈希或者大质数模数。双哈希用两个不同的模数和基数,碰撞概率降到极低。大质数模数(比如 10^9+7 和 10^9+9)则避免了 2 的幂模数的结构性问题。

6.2 单哈希的碰撞概率到底怎么算

很多人会算生日攻击的概率:如果哈希值空间是 N,那么大约需要 sqrt(N) 个字符串才能期望一次碰撞。对于 2^64,sqrt 是 2^32,看起来很大。但这是随机情况下的期望,对抗性构造不需要随机,它直接利用模数的结构,可以在多项式时间内找到碰撞。所以不能用生日攻击的概率来安慰自己。

6.3 实际工程中的哈希选择

在实际工程中,字符串哈希常用于去重、缓存键、数据校验等场景。如果输入不是对抗性的(比如用户不会故意构造碰撞),自然溢出法勉强能用。但只要涉及安全敏感的场景,就必须用密码学哈希(如 SHA-256)或者至少双哈希。这道题的价值就在于让你亲眼看到碰撞是怎么被构造出来的,从而对“简单哈希”保持警惕。

7. 我在实现这道题时总结的几条经验

第一,不要害怕暴力搜索。这道题的构造过程看起来需要精巧的数学推导,但实际上逐位搜索就足够了。每一步的搜索空间很小,整体复杂度完全可以接受。很多时候,把问题拆成小步骤,每步暴力解决,比试图一步到位推导公式要简单得多。

第二,验证环节不能省。构造出来的字符串一定要用独立的哈希函数重新计算一遍,确认碰撞成立。我见过有人因为搜索逻辑里的位运算写错,导致“假碰撞”,提交后才发现问题。

第三,基数和字符集要提前确认。不同的题目对基数和字符集有不同的要求。有的题目固定基数,有的让你自己选。如果基数固定且字符集受限,构造难度会上升,需要更灵活的搜索策略。提前读清楚题目要求,能省下大量调试时间。

第四,长度不是问题,但要有上限。构造出的字符串长度可能远超预期,但只要题目不限制,就没关系。不过代码里一定要设一个上限,防止死循环。超过上限就换策略或者换基数重来。

最后分享一个小技巧:如果逐位对齐卡住了,试试在卡住的位置连续加两个相同字符,相当于多乘一次 B 再加一次,往往能打破僵局。这个技巧在我调试时救过好几次场。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询