土法炼钢 · 系统与基础设施

密码学哈希与非密码学哈希:安全定义、迭代构造与哈希洪泛

文章导航

分类入口
algorithmscryptography
标签入口
#hash-function#siphash#hash-flooding#merkle-damgard#length-extension#sponge#sha-3#blake3#avalanche#smhasher#murmurhash3#abseil

目录

选哈希函数时常听到三句话:「给 MurmurHash 加个随机种子就能防哈希洪泛」「SipHash 是一种更快的密码学哈希」「Abseil 的哈希表用的是 wyhash」。第一句在 2012 年就被针对带种子 MurmurHash 的公开攻击推翻;第二句与 SipHash 论文的原话相反,论文明确写着 SipHash 不追求、也不具备抗碰撞性;第三句只对 Abseil 20250512.0 及更早的版本成立,而且即使在那些版本里,也只覆盖长于 16 字节的输入。

三句话错在同一个地方:没有先问清这个哈希函数对谁、承诺了什么。本文先把承诺分成三种契约,再逐一看密码学哈希的安全定义和两种迭代构造(Merkle–Damgård 与海绵),然后回到哈希表:雪崩测试能说明什么、不能说明什么,哈希洪泛(hash flooding)为什么需要带密钥的伪随机函数,以及 CPython、Rust、Go、Abseil 在钉定版本里的实际选择。

文中所有实验数字都来自同目录的 reproduce/,包括长度扩展伪造、雪崩偏差矩阵、哈希洪泛的比较次数和短键速度,实验环境见第五节。哈希表本身的结构(链式、线性探测、SwissTable)见 哈希表内部:链式、线性探测、Robin Hood 与生产实现的取舍,XXH3 与 wyhash 的内部实现和提速手段见 XXH3 与 wyhash:SIMD 累加器和 128 位标量乘法两条提速路线,本文不重复。

一、三种契约

三种哈希契约的对比:左列无密钥的非密码学哈希(FNV-1a、MurmurHash3、xxHash、wyhash)不假设攻击者,只承诺对非针对性输入分布均匀,靠统计测试检验,攻击者能挑键时失效;中列带密钥 PRF(SipHash)允许攻击者选输入、看输出但拿不到 128 位密钥,承诺输出与随机函数不可区分,不承诺抗碰撞;右列无密钥密码学哈希(SHA-256、SHA3-256、BLAKE3)假设攻击者知道一切,承诺抗原像、抗第二原像、抗碰撞,通用攻击代价为 2n、2n、2^(n/2);底部箭头表示攻击者模型逐渐变强、每字节工作量逐渐增加

图中三列的差别在于攻击者模型:

哈希表面对的攻击者是:能决定插入哪些键,可能通过响应时间或遍历顺序间接看到桶号,但不知道进程启动时生成的密钥。这正是 PRF 的模型,而不是密码学哈希的模型。所以哈希表的”安全默认”是 SipHash 这类 PRF,而不是 SHA-256:后者对哈希表来说既太慢(第五节实测 8 字节键比 SipHash 慢约 4.6 到 7 倍),又在防什么的问题上答非所问,因为它的安全性不依赖任何秘密,攻击者完全可以离线穷举出落进同一个桶的键。

二、密码学哈希的三条安全性质

定义

设 \(H: \{0,1\}^* \to \{0,1\}^n\)。按 Rogaway 与 Shrimpton(FSE 2004)的整理,三条经典性质是:

对一个理想的 \(n\) 位函数,前两条的通用攻击是穷举,期望约 \(2^n\) 次计算;抗碰撞的通用攻击是生日攻击,约 \(\sqrt{\pi/2} \cdot 2^{n/2}\) 次。FIPS 202 的 Table 4 按这个口径给出 SHA-256 与 SHA3-256 的安全强度:抗碰撞 128 位,抗原像与抗第二原像 256 位。

三者的关系

flowchart LR
    CR["collision resistance"] -->|"always implies"| SPR["second-preimage resistance"]
    CR -.->|"only if H compresses enough"| PR["preimage resistance"]
    SPR -.->|"only if H compresses enough"| PR

三条性质会分别失效。MD5 的碰撞早在 2004 年就被构造出来(Wang & Yu, EUROCRYPT 2005),但它的原像攻击至今仍接近穷举:Sasaki 与 Aoki(EUROCRYPT 2009)的全轮 MD5 原像攻击需要 \(2^{123.4}\) 次计算。所以”MD5 已被攻破”指的是抗碰撞性,这足以让它退出签名和证书,但不意味着它的每条性质都塌了。

碰撞攻击怎样走到实战

年份 事件 出处
2004–2005 Wang 等人构造出 MD5 碰撞 Wang & Yu, EUROCRYPT 2005
2005 全轮 SHA-1 碰撞攻击,复杂度低于 \(2^{69}\) Wang, Yin & Yu, CRYPTO 2005
2008 用 MD5 选择前缀碰撞伪造出可用的 CA 证书(25C3 演示) Stevens et al., CRYPTO 2009
2012 Flame 恶意软件用一种新的 MD5 选择前缀碰撞伪造微软代码签名证书 Stevens, “Counter-Cryptanalysis”, CRYPTO 2013
2017 第一对实际的 SHA-1 碰撞(SHAttered),约 \(9 \times 10^{18}\) 次 SHA-1 计算,相当于 6500 CPU 年加 110 GPU 年 Stevens et al., CRYPTO 2017;shattered.io
2019–2020 SHA-1 选择前缀碰撞,2020 年达到实用规模并用于伪造 PGP 信任链 Leurent & Peyrin, EUROCRYPT 2019;USENIX Security 2020

“选择前缀”(chosen-prefix)碰撞比普通碰撞强:攻击者可以先任意指定两段不同的前缀,再为它们各自补上后缀使哈希相同。伪造证书需要的正是这种能力,因为两份证书的主体信息必须不同。

三、Merkle–Damgård 构造与长度扩展

构造

Merkle 与 Damgård 在 CRYPTO ’89 上各自独立发表了同一种构造(Merkle, “One Way Hash Functions and DES”;Damgård, “A Design Principle for Hash Functions”):给定一个定长的压缩函数 \(f: \{0,1\}^n \times \{0,1\}^b \to \{0,1\}^n\),把消息填充后切成 \(b\) 位的块 \(m_1, \ldots, m_\ell\),然后

\[ h_0 = IV, \qquad h_i = f(h_{i-1}, m_i), \qquad H(M) = h_\ell. \]

填充里写入原始消息长度,这一步称为 MD 强化(MD strengthening)。它保证了该构造的核心定理:若 \(f\) 抗碰撞,则 \(H\) 抗碰撞。证明是从 \(H\) 的一对碰撞倒着走链:由于最后一块包含长度,两条链要么在某一步给出 \(f\) 的碰撞,要么逐块完全相同,而后者与 \(M \ne M'\) 矛盾。MD5、SHA-1、SHA-2 都是这个结构。SHA-256 取 \(n = 256\)、\(b = 512\),填充规则是追加 0x80、若干零字节、再追加 64 位大端的比特长度(FIPS 180-4, §5.1.1)。

压缩函数内部是 64 轮运算,本文复现程序里的实现如下(摘自 reproduce/sha256.h 的 sha256_compress(),省略了消息扩展):

for (int i = 0; i < 64; i++) {
    uint32_t t1 = hh + (ror32(e, 6) ^ ror32(e, 11) ^ ror32(e, 25)) + ((e & f) ^ (~e & g)) +
                  sha256_k[i] + w[i];
    uint32_t t2 = (ror32(a, 2) ^ ror32(a, 13) ^ ror32(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
    hh = g; g = f; f = e; e = d + t1;
    d = c; c = b; b = a; a = t1 + t2;
}
h[0] += a; h[1] += b; h[2] += c; h[3] += d;
h[4] += e; h[5] += f; h[6] += g; h[7] += hh;

最后两行是前馈(feed-forward):把这一轮的输入状态加回输出,使 \(f\) 即使在内部置换可逆时也难以求逆。test_vectors 用 FIPS 180-4 的样例和 2000 个随机长度输入与 OpenSSL 3.6.2 对照,结果一致。

长度扩展攻击

结构上的问题出在最后一步:SHA-256 的输出就是最后的链接变量 \(h_\ell\),完整的 256 位内部状态直接暴露给了外部。

Merkle–Damgård 迭代与长度扩展:上半部分从 IV 出发,依次用压缩函数 f 吸收 block 1、block 2 和含 0x80 与长度的填充块,链接变量 h1、h2、h3 的最后一个直接作为 tag 输出;下半部分攻击者把 tag 当作 h3,继续用 f 吸收 ext 及新的填充,得到 H(M’ || ext),全程不需要 secret

设服务端用 \(t = \mathrm{SHA256}(\mathit{secret} \,\|\, \mathit{msg})\) 做消息认证。攻击者知道 \(\mathit{msg}\)、\(t\) 和秘密的长度,不知道秘密本身。他可以:

  1. 按秘密长度加 \(|\mathit{msg}|\) 算出服务端当时用的填充 \(\mathit{pad}\);
  2. 把 \(t\) 拆回 8 个 32 位字,当作链接变量;
  3. 从这个状态继续吸收任意后缀 \(\mathit{ext}\),并按总长度 \(|\mathit{secret}| + |\mathit{msg}| + |\mathit{pad}| + |\mathit{ext}|\) 写最后的填充。

得到的就是 \(\mathrm{SHA256}(\mathit{secret} \,\|\, \mathit{msg} \,\|\, \mathit{pad} \,\|\, \mathit{ext})\)。攻击代码的核心只有几行(reproduce/lenext.cc,attacker_forge()):

sha256_ctx c;
for (int i = 0; i < 8; i++)
    c.h[i] = (uint32_t)tag[4 * i] << 24 | (uint32_t)tag[4 * i + 1] << 16 |
             (uint32_t)tag[4 * i + 2] << 8 | tag[4 * i + 3];
c.total = secret_len + n + padn; /* a multiple of 64 */
c.n = 0;
sha256_update(&c, ext, strlen(ext));
sha256_final(&c, out_tag);

程序里服务端的秘密是一个 25 字节的字符串,攻击函数拿不到它,只从 1 开始逐个猜秘密长度,每猜一次就把伪造结果交给服务端验证。实际输出:

original msg : user=alice&role=guest
original tag : 71caef4cd7e6ca257f4c4402478ff49c51e5d33597628b0b3b733880fbcf377d
secret length guess 25 accepted after 25 tries
forged msg   : 757365723d616c69636526726f6c653d677565737480000000000000000000000000000000017026726f6c653d61646d696e (50 bytes)
forged tag   : 82fe92e757aff2e473366632b96d24770aca0a200eeab6416592987b1c2b1b3f
forgery ACCEPTED by server

伪造消息是原消息、18 字节的”胶水”填充(80、若干 00、以及 0x170 = 368 位 = 46 字节的长度)和 &role=admin。如果服务端的解析器取同名参数的最后一个值,这条消息就把角色改成了 admin。

修补办法有三类:

迭代结构的其他代价

长度扩展不是 Merkle–Damgård 唯一的结构性弱点。两篇论文说明,迭代让”通用攻击”比理想函数便宜:

四、海绵构造、SHA-3 与 BLAKE3

从 SHA-1 危机到 FIPS 202

2005 年的 SHA-1 攻击之后,NIST 在 2007 年公开征集新的哈希标准。2012 年 Keccak(Bertoni、Daemen、Peeters、Van Assche)胜出,2015 年以 FIPS 202 发布为 SHA-3。SHA-2 至今没有被实际攻破,SHA-3 的意义在于提供一个结构上完全不同的备份:即使针对 Merkle–Damgård 或 SHA-2 压缩函数的分析取得突破,SHA-3 也不受连带影响。

海绵构造

海绵构造:1600 位状态分为 r 位的比特率部分和 c 位的容量部分,初始为零;吸收阶段把填充后的消息块 P0、P1、P2 依次异或进 r 部分,每次后接置换 f;挤压阶段每次从 r 部分取出 Z0、Z1 作为输出,中间再做置换;容量部分既不被输入写入,也从不输出;SHA3-256 取 Z0 的前 256 位

海绵构造(sponge construction)基于一个固定宽度的置换 \(f\),SHA-3 用的是 \(b = 1600\) 位的 Keccak-\(f\)[1600]。状态分成两段:\(r\) 位的比特率(rate)和 \(c\) 位的容量(capacity),\(r + c = b\)。

Bertoni 等人(EUROCRYPT 2008)证明,只要 \(f\) 是随机置换,海绵构造与随机预言机(random oracle)的不可区分性界在 \(2^{c/2}\) 量级。换句话说,安全强度由容量决定:SHA3-256 的 \(c = 512\) 对应 256 位的通用安全界,再和输出长度决定的 \(2^{128}\) 碰撞、\(2^{256}\) 原像取较小者。

长度扩展在这里行不通:输出只来自比特率部分,攻击者拿到摘要后仍缺 \(c\) 位容量,无法恢复完整状态继续吸收。所以对 SHA-3,\(H(K \,\|\, m)\) 本身就可以当 MAC 用;NIST SP 800-185 进一步把它标准化为 KMAC,加上了域分隔和长度编码。

Keccak-\(f\)[1600] 把状态看成 \(5 \times 5\) 个 64 位字,做 24 轮,每轮五步:\(\theta\)(列奇偶扩散)、\(\rho\)(字内旋转)、\(\pi\)(字的位置置换)、\(\chi\)(唯一的非线性步骤,\(a_x \leftarrow a_x \oplus (\lnot a_{x+1} \land a_{x+2})\))和 \(\iota\)(异或轮常数,打破轮间对称)。

BLAKE2 与 BLAKE3

另一条路线是保留”压缩函数加链接变量”,但修掉 Merkle–Damgård 的暴露问题。BLAKE2(Aumasson, Neves, Wilcox-O’Hearn & Winnerlein, ACNS 2013;RFC 7693)源自 SHA-3 决赛算法 BLAKE,最后一块用专门的终结标志处理,不受长度扩展影响。

BLAKE3(O’Connor, Aumasson, Neves & Wilcox-O’Hearn, 2020)按规范文档的说法有三处主要变化:

它的目标安全强度与 BLAKE2s 相同,是 128 位。BLAKE3 规范是设计者发布的文档,不是经同行评审的论文;把轮数减到 7 的依据是设计者对既有 BLAKE/BLAKE2 分析结果的解读,这一点在规范里写得很清楚,是否足够保守是可以争论的。

五、非密码学哈希的质量:雪崩测试与它的边界

实验环境

严格雪崩准则与测量口径

Webster 与 Tavares(CRYPTO ’85)为 S 盒提出了严格雪崩准则(Strict Avalanche Criterion,SAC):翻转任意一个输入位,每个输出位都以 \(1/2\) 的概率翻转。把它当作测量量:对 \(N\) 个随机输入,记输入位 \(i\) 翻转时输出位 \(j\) 翻转的比例为 \(P_{ij}\),偏差定义为

\[ b_{ij} = \left| 2 P_{ij} - 1 \right|. \]

这正是 SMHasher 的 AvalancheTest 使用的量:原版 SMHasher 对每种键长做 300000 次重复,只要最差的 \(b_{ij}\) 超过 1%(源码常量 AVALANCHE_FAIL)就判失败。\(N\) 有限时,即使是理想函数,\(b_{ij}\) 也有抽样噪声,均值约为 \(\sqrt{2/\pi} / \sqrt{N}\)。所以下表额外放了一行 ideal:用随机数代替哈希差值,给出同样 \(N\) 下的噪声底。

函数 8 字节键平均偏差 8 字节键最差偏差 32 字节键平均偏差 32 字节键最差偏差
ideal(噪声底) 0.078% 0.352% 0.155% 0.738%
FNV-1a 64 29.598% 100.000% 12.938% 100.000%
DJBX33A(64 位) 83.766% 100.000% 61.637% 100.000%
MurmurHash3 x86_32 0.079% 0.410% 0.156% 0.924%
MurmurHash3 x64_128(低 64 位) 0.076% 0.359% 0.155% 0.714%
XXH64 0.077% 0.345% 0.156% 0.731%
XXH3_64bits 0.077% 0.360% 0.154% 0.862%
wyhash 4.3 0.078% 0.357% 0.155% 0.775%
SipHash-1-3 0.079% 0.414% 0.156% 0.850%
SipHash-2-4 0.078% 0.324% 0.154% 0.802%
SHA-256(前 64 位) 0.078% 0.444% 0.153% 0.861%

8 字节键 \(N = 2^{20}\),32 字节键 \(N = 2^{18}\),两种配置的”输入位翻转”总次数都是 \(2^{26}\)。下图是 8 字节键的四个偏差矩阵:

四个雪崩偏差热力图,横轴为 8 字节键的 64 个输入位,纵轴为 64 个输出位,颜色越亮偏差越大:FNV-1a 左侧(前几个字节的输入位)大多是暗色,右侧(最后两三个字节)出现大片亮区,底部最低几个输出位对每个输入字节都呈亮色锯齿,左下角输入位 0 到输出位 0 为 100%;DJBX33A 上半部分和右侧几乎全亮,只有左下方一片带斜条纹的区域偏差较低;MurmurHash3 x64_128 与 SipHash-1-3 通体暗色,最差偏差都约为 0.4%

可以读出两件事。

第一,FNV-1a 和 DJBX33A 的最差偏差都是 100%,出在”输入位 0 到输出位 0”,原因是代数结构而不是运气。FNV-1a 每步做 \(h \leftarrow (h \oplus c) \cdot p\),DJBX33A 每步做 \(h \leftarrow 33h + c\),乘数都是奇数,而奇数乘法和加法都不改变最低位的奇偶关系:输出的第 0 位恒等于各字节第 0 位的异或(再加一个常数)。所以翻转任何一个字节的最低位,输出最低位必然翻转。用 h & (m - 1) 取桶号时,最低位正是最先用到的位。

第二,也是更重要的一点:MurmurHash3、XXH64、XXH3、wyhash 的偏差与 SipHash、SHA-256 以及 ideal 噪声底处在同一水平。在这个测试的分辨率下,看不出”非密码学”和”密码学”的区别。第六节会展示,恰恰是雪崩表现无可挑剔的 MurmurHash3,存在与种子无关的碰撞族。雪崩测试回答的是”对随机输入混合得好不好”,不回答”攻击者能不能找到碰撞”。

SMHasher 与 SMHasher3

SMHasher 由 MurmurHash 的作者 Austin Appleby 编写,包括雪崩、比特独立性(BIC)、差分、稀疏键、循环键、种子等一系列统计测试。Reini Urban 的分支(rurban/smhasher)加入了更多测试和一张覆盖上百个函数的结果表;Frank J. T. Wojcik 在它的基础上开发了 SMHasher3,按其 README,主要改进是修正若干严重缺陷、为所有测试报告 p 值、改进部分测试的统计基础、统一各哈希实现并提升速度。

这些工具很适合发现 FNV、DJB 这类结构性缺陷,但它们测的都是”非对抗”输入。通过全部测试只说明函数在这些输入分布上像随机函数,不说明它能抵御专门构造的输入。

速度:安全性的价格

单次调用的耗时(ns,输入都在 L1/L2 中,调用之间互不依赖):

函数 8 B 16 B 32 B 64 B 256 B 4096 B
FNV-1a 64 3.68 8.20 16.61 36.28 207.49 3716.31
MurmurHash3 x64_128 5.97 4.94 6.40 9.41 28.61 432.98
XXH64 3.05 4.22 7.38 9.57 20.67 245.21
XXH3_64bits 2.23 1.99 2.68 4.12 17.66 89.32
wyhash 4.3 2.51 2.54 3.22 4.22 9.70 145.53
SipHash-1-3 6.87 8.35 11.31 16.73 52.56 754.20
SipHash-2-4 10.47 12.76 17.86 28.42 89.62 1303.75
SHA-256(OpenSSL,低层 API) 48.11 48.32 47.22 70.92 154.64 1852.10

按比例看:

这张表是微基准:输入常驻缓存,调用之间没有依赖。哈希表里一次查找还包括取桶、比较键和可能的缓存未命中,哈希函数在整次操作里占多大比例,要在具体负载上测量。

六、哈希洪泛:从确定性哈希到带种子的哈希

代价模型

链式哈希表里,若 \(n\) 个键全部落进同一个桶,第 \(i\) 次插入要先和已有的 \(i-1\) 个键逐一比较(检查是否重复),总比较次数是

\[ \sum_{i=1}^{n} (i - 1) = \frac{n(n-1)}{2}. \]

而 \(n\) 个键均匀散列到 \(m\) 个桶时,期望比较次数约为 \(n^2 / (2m)\)。开放寻址表的退化形式不同,但同样是从期望常数变成线性。攻击者只需发送 \(n\) 个键,就迫使服务端做 \(\Theta(n^2)\) 的工作。

历史

oCERT-2011-003 公告列出的修复版本(做法一栏来自各项目自己的发布说明):

平台 修复版本 做法
PHP 5.3.9(2012-01-10) 新增 max_input_vars 限制请求参数个数(PHP 5 ChangeLog)
Python 2.6.8、2.7.3、3.1.5、3.2.3 加入 -R 哈希随机化,默认关闭(公告原文);3.3 起默认开启
Ruby 1.8.7-p357;1.9.x 列为已修复 公告未说明
JRuby 1.6.5.1 公告未说明
Tomcat、Jetty、Rack 7.0.23、7.6.0.RC3、1.4.0 等 公告未说明
Java、V8、Rubinius 公告标为 N/A Java 的 String.hashCode() 公式写在 API 规范里,无法在不破坏兼容的前提下更换

DJBX33A:等价子串与组合放大

PHP 5 的 zend_inline_hash_func()(PHP 5.3.8,Zend/zend_hash.h)是 DJBX33A:从 5381 开始,每个字节做 \(h \leftarrow 33h + c\),类型是 ulong,在 64 位 Linux 上即 64 位。对长度为 \(L\) 的串 \(s\),

\[ H(s) = 33^{L} h_0 + \sum_{i=1}^{L} 33^{L-i} c_i . \]

两个字符的块 “Ez” 与 “FY” 贡献相同:\(69 \times 33 + 122 = 70 \times 33 + 89 = 2399\)。所以 \(\{\text{Ez}, \text{FY}\}^k\) 中的 \(2^k\) 个等长串哈希值全部相同,而且这个结论与初值 \(h_0\) 无关:把 5381 换成一个随机种子,碰撞照样成立。这是第三节 Joux 多碰撞的一个极简版本:找一次块碰撞,组合出指数多个碰撞。

为什么”加随机种子”不够

Python 在 2012 年的第一轮修复里给 FNV 变体加了随机前缀和后缀。Aumasson 与 Bernstein 在 SipHash 论文附录 B 里给出一段脚本:只需两个哈希输出(hash("\0") 与 hash("\0\0")),就能解出 Python 2.7.3 和 3.2.3 的 128 位随机化密钥。PEP 456 据此认定这种随机化”不能抵御攻击”,Python 3.4 改用 SipHash。

更一般的问题是与种子无关的碰撞。Aumasson、Bernstein 与 Boßlet 在 2012 年 11 月公开了针对 MurmurHash2、MurmurHash3 的概念验证代码(12 月又在 29C3 上报告,PEP 456 引用了这次报告,并指出 CityHash 有类似弱点),oCERT-2012-001 列出受影响的实现:Ruby(MurmurHash2,1.9.3-p327 修复)、JRuby(1.7.1 修复)、Rubinius(MurmurHash3),以及 JDK 7 中默认关闭的替代字符串哈希,编号 CVE-2012-5370 至 CVE-2012-5373。Ruby 的修复就是换成 SipHash。

原理可以用 MurmurHash3_x86_32 演示。它每处理一个 4 字节块 \(k\),先做可逆变换 \(f(k) = \mathrm{rotl}(k \cdot c_1, 15) \cdot c_2\),再更新状态 \(h \leftarrow \mathrm{rotl}(h \oplus f(k), 13) \cdot 5 + C\)。种子只是 \(h\) 的初值。

MurmurHash3_x86_32 中与种子无关的一对碰撞:键 P 和 Q 的状态都从同一个未知种子 h0 出发;第一块让两者的差为第 18 位;rotl 13 后差移到第 31 位;乘 5 加常数后差仍然只在第 31 位,因为 5 乘 2^31 模 2^32 等于 2^31;第二块再异或一个第 31 位的差,两者状态完全相同,之后的块相同则最终哈希相同;k 对这样的块拼接可得 2^k 个等长同哈希键

构造一对 8 字节的键 \(P = (x_1, x_2)\) 和 \(Q = (x_1', x_2')\):

  1. 任取 \(a_1\),令 \(f(x_1) = a_1\)、\(f(x_1') = a_1 \oplus 2^{18}\)。因为 \(f\) 是双射,\(x_1, x_1'\) 可以直接求逆得到。异或进状态后,两边只差第 18 位。
  2. 左旋 13 位后差在第 31 位。设此时一边是 \(y\),另一边就是 \(y \oplus 2^{31} = y + 2^{31} \pmod{2^{32}}\)。乘 5 后差为 \(5 \cdot 2^{31} \equiv 2^{31} \pmod{2^{32}}\),加常数 \(C\) 不改变这一点。所以第一块处理完,两边无论种子是多少都只差最高位。
  3. 第二块令 \(f(x_2') = f(x_2) \oplus 2^{31}\),异或之后差被抵消,两边状态完全相同。

\(k\) 对独立构造的块对拼接起来,就得到 \(2^k\) 个等长的键,对任意种子哈希值都相同。这是本文按同一思路推导的构造,不是那次攻击原始代码的复刻;reproduce/hashdos.cc 里的 murmur_pair() 实现了它,并用 SMHasher 的参考实现在 1000 个随机种子下验证了 \(2^{15}\) 个 120 字节的键全部碰撞。

实测:比较次数

表有 \(m = 2^{16}\) 个桶、链式解决冲突,每种组合换一个新的随机种子或密钥,插入 \(n = 2^{15}\) 个互不相同的键(reproduce/results/hashdos.txt):

键集合 DJBX33A(随机初值) MurmurHash3_x86_32(随机种子) SipHash-1-3(随机密钥)
Ez/FY 组合(30 字节) 536,854,528(最长链 32768) 8,128 8,296
Murmur 差分对组合(120 字节) 9,149 536,854,528(最长链 32768) 8,127
随机可打印串(120 字节) 8,122 8,175 8,160

\(536{,}854{,}528 = 2^{15}(2^{15} - 1)/2\),正好是全部落进一个桶的理论值;其余格子都接近 \(n^2 / (2m) = 8192\)。下图是 \(n\) 从 \(2^8\) 到 \(2^{15}\) 的比较次数。因为实验固定了桶数,均匀散列的 \(n(n-1)/(2m)\) 同样随 \(n\) 平方增长,所有曲线在双对数坐标上斜率都接近 2,区别在常数:全碰撞时少了除以 \(m = 65536\) 这一步,差出四个多数量级。真实的哈希表会随 \(n\) 扩容、让 \(m\) 与 \(n\) 同阶,均匀情况下每次插入是常数,全碰撞情况下仍是线性。

键比较次数随插入键数变化的双对数图,桶数固定为 65536:Ez/FY 键配 DJBX33A 和差分对键配 MurmurHash3 两条曲线重合,从 n=256 时约 3.3 万次增长到 n=32768 时约 5.4 亿次;同样两组键配 SipHash-1-3 以及随机键配 SipHash-1-3 的三条曲线在底部,n=32768 时约 8 千次,两组曲线斜率相近、相差约 65536 倍

每组键只对它瞄准的那个函数有效:Ez/FY 键对 MurmurHash3 和 SipHash 毫无威胁,Murmur 差分对也打不动 DJBX33A。攻击者需要的是目标函数的结构,种子挡不住结构。

强 PRF 能挡住什么、挡不住什么

SipHash 论文 §7 还给出了两个更细的论证。

所以 PRF 的作用是把放大倍数从线性压到平方根,而不是彻底消灭攻击。限制单次请求的键数量(PHP 的 max_input_vars)、把长链树化(Java 8,见 哈希表内部 第二节),这类结构性措施在有了 SipHash 之后依然有意义。

七、SipHash

定义

SipHash-\(c\)-\(d\) 接受 128 位密钥 \((k_0, k_1)\) 和任意长度的字节串,输出 64 位(Aumasson & Bernstein, INDOCRYPT 2012, §2):

  1. 四个 64 位状态字用密钥与常数初始化,常数是 ASCII 串 “somepseudorandomlygeneratedbytes”。论文说明这个值没有特殊之处,只是要让 \(v_0, v_1\) 与 \(v_2, v_3\) 不对称。
  2. 消息按小端切成 64 位字,最后一个字装入剩余的 0 到 7 个字节,最高字节写入 \(\text{len} \bmod 256\)。每个字 \(m_i\) 先异或进 \(v_3\),做 \(c\) 轮 SipRound,再异或进 \(v_0\)。
  3. 终结时 \(v_2 \oplus\) 0xff,做 \(d\) 轮 SipRound,输出 \(v_0 \oplus v_1 \oplus v_2 \oplus v_3\)。

一轮 SipRound 是 4 次加法、4 次异或、6 次循环移位,没有乘法、查表和数据相关的分支(reproduce/siphash.h):

#define SIPROUND                                            \
    do {                                                    \
        v0 += v1; v1 = rotl64(v1, 13); v1 ^= v0;            \
        v0 = rotl64(v0, 32);                                \
        v2 += v3; v3 = rotl64(v3, 16); v3 ^= v2;            \
        v0 += v3; v3 = rotl64(v3, 21); v3 ^= v0;            \
        v2 += v1; v1 = rotl64(v1, 17); v1 ^= v2;            \
        v2 = rotl64(v2, 32);                                \
    } while (0)

压缩循环与终结部分:

for (; p != end; p += 8) {
    uint64_t m;
    memcpy(&m, p, 8);
    v3 ^= m;
    for (int i = 0; i < c; i++) SIPROUND;
    v0 ^= m;
}
/* ... 最后一个字 b 同样处理 ... */
v2 ^= 0xff;
for (int i = 0; i < d; i++) SIPROUND;
return v0 ^ v1 ^ v2 ^ v3;

test_vectors 用官方仓库的 64 个 SipHash-2-4 测试向量(密钥 00..0f,消息 00..3e 的各个前缀)逐一核对,并复现了论文附录 A 的 15 字节样例 a129ca6149be45e5。

论文承诺了什么

SipHash-1-3 的位置

SipHash-1-3 不在论文的推荐之列,但被广泛采用:Rust 1.11.0(2016-08)把标准库 HashMap 的默认哈希从 SipHash-2-4 换成 1-3(RELEASES.md,PR #33940);CPython 3.11 也把 str、bytes 的默认哈希改为 siphash13,What’s New 的说法是它”与 siphash24 有相近的安全性质,在长输入上稍快”(bpo-29410)。第五节的实测里,1-3 在 8 字节键上比 2-4 快约 1.5 倍。公开的第三方分析集中在 2-4 上,1-3 的安全余量没有同等深度的论证,第九节会回到这一点。

八、生产实现对照

以下按钉定版本的源码或官方文档:

实现 版本 字节串默认哈希 密钥或种子
CPython 3.3 带随机前缀和后缀的 FNV 变体 进程启动时随机,默认开启;可被两个输出解出(SipHash 论文附录 B)
CPython 3.4–3.10 SipHash-2-4(PEP 456) 128 位,进程级随机,PYTHONHASHSEED 可固定
CPython 3.11 起 SipHash-1-3(Include/pyhash.h 中 Py_HASH_ALGORITHM 默认为 Py_HASH_SIPHASH13) 同上;本机 3.14.5 的 sys.hash_info 为 algorithm='siphash13'、seed_bits=128
Rust std 1.11.0 起 SipHash-1-3(此前自 0.4 起为 SipHash-2-4) RandomState 随机密钥
hashbrown(crate) 0.17.1 默认特性 default-hasher 下为 foldhash::fast::RandomState;src/map.rs 文档写明”随时可能改变”,且”通常不能抵御 HashDoS 之类的攻击” 随机状态;不影响标准库 HashMap,后者仍用 SipHash-1-3
Go runtime 1.26.8 amd64 上有 AES、SSSE3、SSE4.1,或 arm64 上有 AES 时用基于 AES 轮函数的 aeshash;否则用 hash64.go 中”受 wyhash 启发”的 memhashFallback alginit() 用 bootstrapRand() 初始化密钥;每个 map 另有随机种子
Abseil 20240722.0 长于 16 字节且有 128 位整数时用 LowLevelHash,头文件注释称其”紧密基于某个版本的 wyhash,但不保证与之兼容”;否则用 CityHash64 见下一行
Abseil 20260817.0 32 字节以内:有 SSE4.2 或 ARM CRC 时用 CRC32C 指令加 128 位乘法混合,否则用乘法混合;超过 32 字节:有 AES 指令时用 AES 轮函数,否则用 Mix32Bytes() 的乘法混合;low_level_hash.h 已在 20250814.0 删除 Seed() 返回全局变量 kSeed 的地址,依赖 ASLR;注释写明”目前不是安全特性”
Java JDK 8 起 String.hashCode() 为 API 规范规定的固定多项式 \(s_0 31^{n-1} + \cdots + s_{n-1}\) 无;靠 HashMap 链表树化兜底(JEP 180)

几点说明:

九、争论与开放问题

争论一:语言默认的哈希该不该是 PRF

一方以 SipHash 论文 §7 为代表:只要应用可能把外部输入当键,默认哈希就应该是强 PRF,因为几乎不可能审计所有应用有没有通过遍历顺序、响应时间暴露桶号。CPython 与 Rust 标准库采纳了这个立场,并为此付出第五节那样的短键开销。

另一方以 Abseil、hashbrown 的默认设置为代表:用更快的混合函数加一个廉价的随机化,防止用户依赖具体哈希值,把抗攻击的责任交给真正面对不可信输入的场景。Abseil 在源码注释里明确把种子定位为”目前不是安全特性”,但”为升级到真正的进程级随机种子留了门”;hashbrown 的文档直接写明默认哈希”通常不能抵御 HashDoS”。

两边的证据不对称:前者有论文和 2011–2012 年的真实攻击;后者的依据主要是性能,公开的数据通常是厂商或维护者自己的基准。第五节的微基准显示差距在短键上是每次几纳秒,这个差距在整次表查找里占多大比例,取决于负载,本文没有这样的数据。

争论二:修函数还是修结构

dnscache 给链长设上限,PHP 限制参数个数,Java 8 把长链树化,这些都不改哈希函数,而是限制最坏情况的代价。Java 的做法把单桶最坏查找从 \(O(n)\) 降到 \(O(\log n)\),但前提是键可比较,而且代价是更复杂的实现。SipHash 论文的分析说明,即使换了强 PRF,攻击者盲猜的放大倍数仍是流量的平方根。两类措施解决的是不同层面的问题,互相替代不了。

开放问题

十、工程选型与常见错误

flowchart TD
    A["Do attackers choose or influence the keys?"] -->|"yes"| B["Hash table keys?"]
    A -->|"no"| C["Must the value be stable across versions or processes?"]
    B -->|"yes"| D["Keyed PRF: SipHash with a per-process random key, plus limits on key count"]
    B -->|"no, need a MAC"| E["HMAC-SHA-256, KMAC or keyed BLAKE3"]
    C -->|"yes"| F["A specified function with a frozen output: SHA-256, BLAKE3, or XXH64 / XXH3 when no adversary exists"]
    C -->|"no"| G["Fast non-crypto hash: wyhash, XXH3, or the library default"]

图中”库默认”指 Abseil、hashbrown 等已经带随机化的实现;它们适合可信输入,面对不可信输入时要按第八节确认默认哈希到底是什么。

错误 后果 做法
用固定或”随机种子”的非密码学哈希处理外部输入的键 种子无关的碰撞族依然有效(第六节,MurmurHash3 与 DJBX33A 各 5.4 亿次比较) 用带随机密钥的 SipHash;同时限制单个请求的键数
用 \(\mathrm{SHA256}(\mathit{secret} \,\|\, \mathit{msg})\) 当 MAC 长度扩展伪造(第三节,猜 25 次秘密长度即成功) HMAC-SHA-256、KMAC 或 BLAKE3 带密钥模式
用 h & (m - 1) 配 FNV-1a 或 DJBX33A 最低位是各字节最低位的异或,雪崩偏差 100% 换混合充分的函数,或在取模前再做一次混合
把 wyhash、Abseil 哈希值写入磁盘或网络协议 版本升级后哈希值改变(wyhash final 3 与 4.3 常数不同;Abseil 不保证兼容) 持久化用有规范、输出冻结的函数
把”通过 SMHasher”当作安全证明 统计测试不覆盖对抗输入 区分”质量”与”抗攻击”两个问题
把 SipHash 当作抗碰撞的摘要 64 位输出,知道密钥时约 \(2^{32}\) 次就有碰撞 需要无密钥摘要时用 SHA-256 / SHA-3 / BLAKE3
用 SHA-256 做哈希表的哈希 短键上比 SipHash 慢 4.6 到 7 倍,且不防离线构造的同桶键 哈希表用 PRF;SHA-256 留给签名和内容寻址
用快速哈希存储口令 SHA-256 这样的函数本来就被设计得很快,利于暴力破解 用 Argon2id、scrypt 或 bcrypt 这类专用口令哈希

十一、参考资料

规范与文档

源码

核心论文

其他论文

工程资料

实验


系列导航: - 上一篇:Bloom Filter 家族:从位数组到 Ribbon,每键位数离下界还差多少 - 下一篇:XXH3 与 wyhash:SIMD 累加器和 128 位标量乘法两条提速路线

相关阅读: - 哈希表内部:链式、线性探测、Robin Hood 与生产实现的取舍 - Swiss Table:控制字节、分组探测与墓碑 - 完美哈希 - Merkle 树与认证数据结构 - 密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 - MAC 与 HMAC:消息认证的正确姿势

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。


By .