title: “哈希碰撞攻击:为何一条HTTP请求能让服务器CPU飙升到100%” date: “2026-03-05T16:55:02+08:00” description: “深入解析哈希碰撞DoS攻击的技术原理:从2003年Crosby和Wallach的开创性论文到2011年横扫主流语言的安全危机,揭示确定性哈希函数如何将O(1)操作变成O(n²)灾难,以及SipHash如何成为现代语言的标准防线。” draft: false categories: [“安全漏洞”, “数据结构”, “Web安全”] tags: [“哈希碰撞”, “HashDoS”, “算法复杂度攻击”, “SipHash”, “哈希表”, “拒绝服务攻击”, “Web安全”]
2003年8月,莱斯大学的Scott Crosby和Dan Wallach在第12届USENIX安全会议上发表了一篇题为《Denial of Service via Algorithmic Complexity Attacks》的论文。他们在演讲中展示了如何用不到拨号调制解调器的带宽,让一台专用入侵检测系统服务器在六分钟内开始丢弃71%的流量。
八年后的2011年12月28日,oCERT发布了编号为2011-003的安全公告,宣布Java、PHP、Python、Ruby、ASP.NET、V8 JavaScript等主流语言和平台全部受到同类攻击影响。微软罕见地打破了常规补丁周期,在12月29日发布了紧急修复。
这个攻击的核心在于:程序员信赖的"O(1)“哈希表查找,在特定输入下会退化为O(n)甚至O(n²)。
哈希表的致命弱点
哈希表是现代编程语言中最核心的数据结构之一。它的设计承诺很诱人:平均情况下,插入、查找和删除操作的时间复杂度都是O(1)。这个承诺建立在一个假设之上——哈希函数能将键均匀地分散到各个桶(bucket)中。
当两个不同的键被映射到同一个桶时,就发生了哈希碰撞。最常见的处理方式是链地址法(separate chaining):每个桶维护一个链表,所有哈希到该桶的元素都存储在链表中。
graph LR
subgraph 正常状态
A0[桶0] --> A1[元素A]
A1 --> A2[元素B]
A2 --> A3[null]
B0[桶1] --> B1[元素C]
B1 --> B2[null]
C0[桶2] --> C1[元素D]
C1 --> C2[元素E]
C2 --> C3[元素F]
C3 --> C4[null]
end
在正常情况下,每个桶的链表很短,查找操作几乎是常数时间。但如果攻击者能让大量键碰撞到同一个桶,哈希表就会退化成链表:
graph LR
subgraph 攻击状态
A0[桶0] --> A1[恶意键1]
A1 --> A2[恶意键2]
A2 --> A3[恶意键3]
A3 --> A4["..."]
A4 --> A5[恶意键N]
A5 --> A6[null]
B0[桶1] --> B1[null]
C0[桶2] --> C1[null]
end
当所有N个键都碰撞到同一个桶时,插入第N个元素需要遍历前N-1个元素,总插入时间变成O(n²)。这就是哈希碰撞攻击的数学本质。
等价子串:从两个种子到无限碰撞
攻击的关键在于如何高效地生成大量碰撞键。大多数语言使用的哈希函数都有一个致命特性:等价子串(equivalent substrings)。
以Java的String.hashCode()为例,它使用的哈希函数是:
h = 0
for each character c in string:
h = 31 * h + c
在这个函数中,字符串"Aa"和"BB"产生相同的哈希值(2112)。更重要的是,由于乘法结合律,如果两个子串哈希值相同,那么在相同位置替换它们,整个字符串的哈希值也相同。
这意味着:
- “Aa"和"BB"碰撞
- “AaAa”、“AaBB”、“BBAa”、“BBBB"四个字符串全部碰撞
- 以此类推,2n个字符可以产生2^n个碰撞字符串
攻击者只需要找到一对碰撞种子,然后通过排列组合就能生成任意数量的碰撞键。对于Java来说,64个字符长度的字符串可以产生2^32个碰撞——远超任何哈希表的处理能力。
中间相遇攻击:破解"更安全"的哈希函数
并非所有哈希函数都有等价子串特性。PHP使用的DJBX33A变体、Python使用的哈希函数都没有这个弱点。但这并不意味着它们安全。
Alexander Klink和Julian Wälde在2011年的研究中展示了**中间相遇攻击(meet-in-the-middle attack)**如何攻破这些哈希函数。
假设哈希函数输出32位,内部状态也是32位。攻击者将字符串分成前缀和后缀两部分:
- 遍历所有可能的后缀,计算达到目标哈希值所需的中间状态,存入查找表
- 遍历所有可能的前缀,检查是否能达到查找表中的某个中间状态
这种方法将暴力破解的复杂度从O(2^32)降低到O(2^16)。在现代CPU上,几分钟就能找到数千个碰撞。
一条POST请求的杀伤力
n.runs安全团队在2011年的安全公告中披露了攻击的实际效果:
| 平台 | CPU消耗 | 所需带宽 |
|---|---|---|
| Java/Tomcat (2MB POST) | 44分钟 | 6 kbit/s |
| ASP.NET | 90秒 | 30 kbit/s |
| PHP (8MB POST) | 4小时 | 70-100 kbit/s |
| Python/Plone (1MB POST) | 7分钟 | 20 kbit/s |
| Ruby (2MB POST) | 6小时 | 850 bit/s |
这些数字令人震惊:攻击者只需几十kbps的带宽——不到拨号上网的速度——就能让一台i7核心满载运行数小时。如果攻击者有1Gbps带宽,理论上可以让十万个CPU核心同时满载。
更可怕的是,攻击不需要特殊权限,不需要绕过防火墙,只需要向任何接受POST请求的表单提交精心构造的键值对。Web服务器会自动将这些数据解析到哈希表中,攻击就生效了。
Perl的先见之明
有趣的是,在2011年的大规模漏洞披露中,Perl是极少数"幸免于难"的语言之一。原因很简单:Perl早在2003年就修复了这个问题。
Crosby和Wallach的论文直接针对了Perl 5.6.1和5.8.0版本。Perl社区的反应是迅速的——Perl 5.8.1(2003年发布)引入了哈希随机化机制。当检测到异常的碰撞模式时,Perl会自动启用随机化的哈希种子。
这个"先见之明"实际上是被"打"出来的。但讽刺的是,尽管论文公开发表,尽管Perl已经修复,其他主流语言直到八年后才意识到问题的严重性。
Java的特殊困境
在所有受影响的语言中,Java的处境最为尴尬。Java的String.hashCode()返回值会被缓存在String对象的hash字段中——但有一个例外:当哈希值为0时不缓存。
这意味着攻击者如果选择哈希值为0的碰撞字符串,每次哈希计算都会重新执行,进一步放大攻击效果。Klink和Wälde的安全公告中特别指出:Java 2MB的碰撞POST请求可以消耗44分钟的CPU时间。
Oracle的反应令人失望。他们宣布"Java本身不需要修复”,只在后续版本的GlassFish应用服务器中增加了请求参数数量限制。这种态度导致了问题的持续存在。
直到JEP 180(Java 8,2014年)才引入了根本性的解决方案:当单个桶中的元素超过阈值(默认8个)时,将该桶的链表转换为红黑树。这将最坏情况从O(n)降低到O(log n),使得碰撞攻击的成本大幅上升。
SipHash:为短输入设计的密码学PRF
2012年,密码学家Jean-Philippe Aumasson和Daniel J. Bernstein提出了SipHash——一个专门为哈希表设计的伪随机函数(PRF)。
SipHash的设计目标很明确:
- 安全性:使用128位密钥,攻击者无法预测哈希值
- 速度:针对短输入优化,因为哈希表的键通常很短
- 简洁性:实现简单,便于审计
SipHash的核心是一个简单的轮函数,包含四个加法、四个异或和六个循环移位。SipHash-2-4(每消息块2轮,最终4轮)是推荐的配置,在保证安全性的同时提供良好性能。
graph TB
subgraph SipHash处理流程
A[128位密钥 k0, k1] --> B[初始化状态 v0-v3]
B --> C[消息分块处理]
C --> D[每块2轮SipRound]
D --> E[填充与最终化]
E --> F[4轮SipRound]
F --> G[64位输出]
end
关键是密钥的存在:由于攻击者不知道随机生成的128位密钥,即使知道算法,也无法预测哪些输入会碰撞。这从根本上解决了确定性哈希函数的安全问题。
各语言的应对时间线
| 语言/平台 | 修复方式 | 引入版本 | 时间 |
|---|---|---|---|
| Perl | 哈希随机化 | 5.8.1 | 2003 |
| Ruby 1.9 | 哈希随机化 | 1.9.0 | 2008 |
| PHP | max_input_vars限制 | 5.3.9 | 2012.01 |
| Python | PYTHONHASHSEED随机化 | 2.6.8/2.7.3/3.2.3 | 2012.04 |
| Ruby 1.8 | 哈希随机化 | 1.8.7-p357 | 2012 |
| Java | 桶内红黑树 | Java 8 | 2014 |
| Python 3.4 | SipHash | 3.4 | 2014 |
| Rust | SipHash(默认) | 1.0 | 2015 |
Python的PEP 456(2013年)详细记录了选择SipHash的理由。PEP作者Christian Heimes明确指出,之前的FNV变体加上随机前缀后缀的方案是不安全的——攻击者可以通过分析恢复随机种子。只有密码学强度的哈希函数才能真正防止碰撞攻击。
Rust从第一个稳定版本开始就将SipHash作为HashMap的默认哈希算法。这是一个深思熟虑的设计选择:虽然SipHash比非密码学哈希函数慢,但对于通用哈希表来说,安全性比极致性能更重要。
性能与安全的权衡
SipHash不是免费的午餐。与MurmurHash、FNV等非密码学哈希函数相比,SipHash确实有性能开销。根据基准测试,SipHash-2-4的速度大约是MurmurHash3的一半左右。
但这个权衡是值得的。首先,哈希计算通常只是应用程序总开销的一小部分。Python的PEP 456基准测试显示,使用SipHash后整体性能影响在1%以内。其次,对于大多数应用来说,安全性比几个百分点的性能提升更重要。
Rust社区的经验很有启发性:如果确实需要极致性能,可以使用hashbrown库或切换到FxHash等非安全哈希函数。但默认选项必须是安全的——因为开发者往往不会意识到需要主动选择安全性。
更广泛的算法复杂度攻击
哈希碰撞攻击只是"算法复杂度攻击"这个大类中的一个例子。任何在"平均情况"和"最坏情况"之间有巨大差距的算法,都可能成为攻击目标:
- 正则表达式DoS(ReDoS):某些正则表达式引擎在处理特定输入时会回溯指数级次数。2019年7月2日,Cloudflare因一个正则表达式漏洞导致全球服务中断27分钟,影响了大量网站。
- XML实体扩展(Billion Laughs):嵌套的XML实体可以在几KB的输入中展开成数GB的数据。
- DNS放大攻击:利用DNS响应通常比查询大得多的特性进行反射攻击。
- 解析器复杂度:JSON、CSV等格式解析器如果实现不当,也可能被恶意输入利用。
这些攻击的共同点是:代码没有逻辑漏洞,只是算法的最坏情况被恶意触发。传统的安全审计很难发现这类问题,因为代码"看起来正确”——平均情况下确实是正确的。
现代防御策略
针对哈希碰撞攻击,现代系统采用多层防御:
第一层:密码学哈希函数
使用SipHash等带密钥的哈希函数,从根本上阻止攻击者预测碰撞。这是最彻底的解决方案,已被Python 3.4+、Rust、Ruby 2.0+等采用。
第二层:数据结构改进
即使发生碰撞,也要限制最坏情况的损害。Java 8的红黑树策略将最坏情况从O(n)改善到O(log n)。这意味着即使攻击成功,CPU消耗也只会是对数增长而非线性或二次增长。
第三层:输入限制
限制POST参数数量、请求大小等。PHP的max_input_vars(默认1000)就是一个例子。这种防御简单直接,但可能影响正常功能(比如需要上传大量数据的表单)。
第四层:运行时检测
监控哈希表的行为,检测异常的碰撞模式。当检测到攻击时,可以切换到更安全的哈希函数或拒绝请求。Perl的"随机扰动"机制就是这类方案。
开发者应该知道的
如果你正在开发接受用户输入的系统,有几个原则值得记住:
永远不要假设输入是随机的。攻击者会精心构造输入来触发最坏情况。任何"概率很低"的假设,在对抗性环境中都是危险的。
哈希表的键如果来自用户输入,必须使用安全哈希函数。大多数现代语言的标准库已经这么做了,但如果你在使用旧版本或自定义实现,需要特别注意。
关注时间复杂度的最坏情况,而不只是平均情况。安全审计时应该问:如果有人刻意触发最坏情况,会发生什么?
性能优化不应该以安全为代价。SipHash比MurmurHash慢,但这个"慢"是必要的代价。如果确实需要极致性能,应该在明确评估风险后做出知情选择。
哈希碰撞攻击揭示了一个深刻的教训:在安全领域,“概率很低"不等于"不会发生”。当攻击者有动机和能力去寻找那0.0001%的情况时,平均情况下的良好表现毫无意义。
从2003年的学术论文到2011年的大规模漏洞,再到今天SipHash成为主流标准,这个过程花了整整十年。而在另一个平行宇宙里,如果更多人在2003年就认真对待Crosby和Wallach的警告,2011年的那场危机也许就不会发生。
安全从来不是一次性解决的问题,而是持续对抗的过程。算法复杂度攻击这个类别,至今仍在不断产生新的变体。下次当你写下一行代码,假设某个操作是"常数时间"的时候,不妨多问一句:在什么情况下,这个假设会失效?
参考文献
- Crosby, S. A., & Wallach, D. S. (2003). Denial of Service via Algorithmic Complexity Attacks. USENIX Security Symposium.
- Klink, A., & Wälde, J. (2011). n.runs-SA-2011.004: Denial of Service through hash table multi-collisions.
- oCERT-2011-003: Multiple implementations denial-of-service via hash algorithm collision.
- Aumasson, J. P., & Bernstein, D. J. (2012). SipHash: a fast short-input PRF. INDOCRYPT.
- Heimes, C. (2013). PEP 456 – Secure and interchangeable hash algorithm.
- Duigou, M. (2013). JEP 180: Handle Frequent HashMap Collisions with Balanced Trees.
- US-CERT Vulnerability Note VU#903934.
- https://cr.yp.to/siphash/siphash-20120918.pdf