哈希与认证基础(一):哈希函数的安全目标:原像、第二原像与碰撞

哈希与认证基础(一):哈希函数的安全目标:原像、第二原像与碰撞

前言

假设有一份合同文件:

text 复制代码
contract.pdf

系统先计算它的哈希摘要:

h = H ( M ) h=H(M) h=H(M)

其中, M M M 表示合同内容, H H H 表示哈希函数, h h h 表示摘要。

之后,系统可能会对这个摘要进行数字签名:

σ = Sign ⁡ s k ( H ( M ) ) \sigma=\operatorname{Sign}_{sk}(H(M)) σ=Signsk(H(M))

这套流程看起来很简单,但安全性取决于一个关键问题:

攻击者能不能找到另一份不同的消息,却让它产生相同的摘要?

如果攻击者能够构造:

M ′ ≠ M M'\neq M M′=M

同时满足:

H ( M ′ ) = H ( M ) H(M')=H(M) H(M′)=H(M)

那么原本针对 M M M 生成的签名,就可能被攻击者拿来证明 M ′ M' M′。

这只是哈希函数安全问题中的一种情况。

在不同攻击场景下,攻击者可能掌握:

  • 一个已经公开的摘要;
  • 一条已经确定的原始消息;
  • 完全自由选择的两条消息。

对应的安全问题分别是:

  1. 原像抗性:给定摘要,难以找到能够产生该摘要的消息;
  2. 第二原像抗性:给定一条消息,难以找到另一条摘要相同的消息;
  3. 碰撞抗性:难以找到任意两条摘要相同的不同消息。

这三个概念名称相似,但攻击者的能力和攻击难度并不相同。

本文先不讨论 SHA-256 的具体轮函数,也不展开 Merkle-Damgård 结构、长度扩展攻击和 HMAC,而是先建立哈希函数安全性的基础框架。

Crypto 专栏:https://blog.csdn.net/r_feynman_/category_13190241.html
Crypto 密码解析实战靶场:https://blog.csdn.net/r_feynman_/category_13194584.html


一、哈希函数的基本模型

1.1 从任意长度消息得到固定长度摘要

密码学哈希函数可以表示为:

H : { 0 , 1 } ∗ → { 0 , 1 } n H:\{0,1\}^{*}\rightarrow\{0,1\}^{n} H:{0,1}∗→{0,1}n

其中:

  • { 0 , 1 } ∗ \{0,1\}^{*} {0,1}∗ 表示任意长度的比特串;
  • { 0 , 1 } n \{0,1\}^{n} {0,1}n 表示固定长度为 n n n 的比特串;
  • n n n 表示哈希摘要长度。

例如,SHA-256 的输出长度为 256 位:

H ( M ) ∈ { 0 , 1 } 256 H(M)\in\{0,1\}^{256} H(M)∈{0,1}256

无论输入是:

text 复制代码
hello

还是一个几 GB 大小的视频文件,最终得到的摘要长度都固定为 256 位。

例如:

text 复制代码
输入:
hello

SHA-256:
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

哈希函数具有几个基本特征:

  • 相同输入必须得到相同输出;
  • 输入长度可以任意;
  • 输出长度固定;
  • 给定输入时,摘要可以高效计算;
  • 输入发生微小变化时,摘要通常会发生明显变化。

但是,这些特征还不足以说明哈希函数是安全的。

密码学真正关心的是:

攻击者能否利用摘要值,构造满足特定条件的输入。


1.2 碰撞为什么必然存在

假设哈希函数输出长度为 n n n 位,那么摘要空间大小为:

2 n 2^n 2n

而输入消息可以是任意长度,数量远远超过 2 n 2^n 2n。

根据抽屉原理,必然存在两条不同消息:

M 1 ≠ M 2 M_1\neq M_2 M1=M2

使得:

H ( M 1 ) = H ( M 2 ) H(M_1)=H(M_2) H(M1)=H(M2)

这就是碰撞。

因此,哈希函数无法做到:

任意两条不同消息的摘要都不同。

因为输出空间有限,输入空间无限,碰撞在数学上必然存在。

哈希函数的安全目标是让碰撞满足下面的条件:

碰撞虽然存在,但攻击者无法在现实计算资源内找到。

这也是"碰撞存在"和"碰撞可利用"之间的区别。


1.3 哈希函数不是加密算法

哈希函数经常和加密算法一起使用,但两者的目标不同。

加密算法关注保密性:

C = E K ( M ) C=E_K(M) C=EK(M)

持有密钥的一方可以通过解密恢复明文:

M = D K ( C ) M=D_K(C) M=DK(C)

哈希函数则是:

h = H ( M ) h=H(M) h=H(M)

它通常没有对应的解密过程。

哈希函数也不能简单理解成"把数据压缩后保存"。普通压缩算法希望在压缩后恢复原始数据:

compressed data → original data \text{compressed data}\rightarrow\text{original data} compressed data→original data

而哈希函数只保留一个固定长度摘要:

M → H ( M ) M\rightarrow H(M) M→H(M)

摘要中并不包含足够的信息来唯一恢复原始消息。

不过,"哈希不可逆"并不是完整的安全定义。因为实际系统中,攻击者可能通过字典、枚举或已知候选集合,尝试找到一个能够产生相同摘要的输入。


二、原像抗性:给定摘要寻找消息

2.1 原像的概念

假设有一条消息 M M M:

h = H ( M ) h=H(M) h=H(M)

那么 M M M 就是摘要 h h h 的一个原像。

原像攻击的目标是:

已知摘要 h h h,寻找任意一条消息 M ′ M' M′,使 H ( M ′ ) = h H(M')=h H(M′)=h。

形式化表示为:

给定 h ,寻找 M ′ ,满足 H ( M ′ ) = h \text{给定 }h,寻找M'\text{,满足 }H(M')=h 给定 h,寻找M′,满足 H(M′)=h

注意,这里不要求找到原始消息 M M M。

只要找到任意一条消息 M ′ M' M′,满足:

H ( M ′ ) = h H(M')=h H(M′)=h

从原像攻击的定义来看,就已经成功。


2.2 原像攻击和密码破解的区别

假设数据库中保存了用户密码的哈希值:

text 复制代码
hash = SHA256(password)

攻击者拿到这个哈希值后,可以尝试:

text 复制代码
123456
password
admin123
qwerty

然后依次计算:

H ( 123456 ) H(\texttt{123456}) H(123456)

H ( password ) H(\texttt{password}) H(password)

H ( admin123 ) H(\texttt{admin123}) H(admin123)

只要某个结果和数据库中的摘要一致,就找到了一个可用输入。

这个过程在形式上与"根据摘要寻找输入"有关,但它并不等同于针对理想哈希函数进行 2 n 2^n 2n 级别的通用原像攻击。

原因是用户密码的有效搜索空间通常很小。

用户密码不是均匀随机的 256 位数据,而是经常来自:

  • 常见单词;
  • 手机号码;
  • 生日;
  • 键盘组合;
  • 其他网站泄露密码;
  • 用户名与年份组合。

因此,密码哈希被破解,通常是因为密码本身熵不足,而不是哈希函数被数学逆向。

更准确地说:

哈希函数的原像抗性不能阻止攻击者枚举低熵密码。

这也是密码存储必须使用专门 KDF,而不是直接保存 SHA-256 摘要的原因。密码存储将在第九篇单独讨论。


2.3 原像攻击的理论复杂度

对于输出长度为 n n n 位的理想哈希函数,通用原像攻击的计算复杂度约为:

2 n 2^n 2n

以 SHA-256 为例:

n = 256 n=256 n=256

因此,通用原像攻击的理论量级约为:

2 256 2^{256} 2256

这并不表示现实中任何输入都绝对无法找到,而是表示在没有额外结构、没有弱密码和没有实现漏洞的情况下,最直接的通用搜索成本极高。

原像抗性关注的是:

text 复制代码
给你一个摘要,能不能找到一条消息匹配它?

它不关注攻击者能不能自己找两条消息碰撞,这属于后面的碰撞抗性。


三、第二原像抗性:给定消息寻找替代品

3.1 第二原像的定义

第二原像攻击的前提是:

攻击者已经知道一条消息 M M M,并且可以计算它的摘要:

h = H ( M ) h=H(M) h=H(M)

攻击者要寻找另一条不同消息 M ′ M' M′:

M ′ ≠ M M'\neq M M′=M

满足:

H ( M ′ ) = H ( M ) H(M')=H(M) H(M′)=H(M)

形式化表示为:

给定 M ,寻找 M ′ ≠ M ,使 H ( M ′ ) = H ( M ) \text{给定 }M,寻找M'\neq M\text{,使 }H(M')=H(M) 给定 M,寻找M′=M,使 H(M′)=H(M)

这条 M ′ M' M′ 就叫作 M M M 的第二原像。


3.2 第二原像和原像的区别

两者可以这样对比:

原像攻击

攻击者知道:

h h h

攻击者寻找:

M ′ M' M′

满足:

H ( M ′ ) = h H(M')=h H(M′)=h

第二原像攻击

攻击者知道:

M M M

攻击者寻找:

M ′ ≠ M M'\neq M M′=M

满足:

H ( M ′ ) = H ( M ) H(M')=H(M) H(M′)=H(M)

原像攻击只给出一个目标摘要,而第二原像攻击给出了一条具体的原始消息。

可以用一句话区分:

text 复制代码
原像:给摘要找消息。
第二原像:给消息找替代品。

3.3 文件替换场景

假设审计系统已经归档了一份文件:

text 复制代码
audit-report.pdf

系统保存其摘要:

h = H ( audit-report.pdf ) h=H(\texttt{audit\text{-}report.pdf}) h=H(audit-report.pdf)

攻击者希望替换文件内容,但又不想让摘要校验失败。

攻击者需要构造:

text 复制代码
audit-report-fake.pdf

满足:

H ( audit-report-fake.pdf ) = H ( audit-report.pdf ) H(\texttt{audit\text{-}report\text{-}fake.pdf})= H(\texttt{audit\text{-}report.pdf}) H(audit-report-fake.pdf)=H(audit-report.pdf)

由于原文件已经确定,攻击者不能重新选择第一条消息。

这就不是普通的"随便找一组碰撞",而是针对指定消息寻找第二原像。

对于理想化的 n n n 位哈希函数,第二原像攻击通常需要约:

2 n 2^n 2n

量级的计算。

不过对于某些具体哈希结构和超长消息,可能存在低于理想复杂度的专门攻击。后续讲解 Merkle-Damgård 结构时,会进一步讨论消息长度和内部状态对攻击模型的影响。


3.4 第二原像与数字签名

数字签名通常不会直接对任意长度消息执行签名,而是先计算摘要:

h = H ( M ) h=H(M) h=H(M)

再对摘要进行签名:

σ = Sign ⁡ s k ( h ) \sigma=\operatorname{Sign}_{sk}(h) σ=Signsk(h)

验证时,接收方重新计算:

h ′ = H ( M ) h'=H(M) h′=H(M)

然后验证:

Verify ⁡ p k ( σ , h ′ ) = True \operatorname{Verify}_{pk}(\sigma,h')=\text{True} Verifypk(σ,h′)=True

如果攻击者针对已经签名的消息 M M M 找到了第二原像 M ′ M' M′:

M ′ ≠ M M'\neq M M′=M

且:

H ( M ′ ) = H ( M ) H(M')=H(M) H(M′)=H(M)

那么在某些签名流程中,原本对应 M M M 的签名就可能被拿去验证 M ′ M' M′。

但这里不要简单得出:

数字签名只依赖第二原像抗性。

实际数字签名同时涉及:

  • 哈希函数的碰撞抗性;
  • 哈希函数的第二原像抗性;
  • 签名算法的不可伪造性;
  • 消息编码和签名格式;
  • 公钥和证书验证。

安全性是多个环节共同作用的结果。


四、碰撞抗性:任意寻找两条相同摘要的消息

4.1 碰撞攻击的定义

碰撞攻击的目标是寻找任意两条不同消息:

M 1 ≠ M 2 M_1\neq M_2 M1=M2

满足:

H ( M 1 ) = H ( M 2 ) H(M_1)=H(M_2) H(M1)=H(M2)

与第二原像攻击不同的是,碰撞攻击中两条消息都可以由攻击者自由选择。

攻击者不需要:

  • 匹配某个指定摘要;
  • 针对某个已知文件寻找替代品;
  • 恢复任何真实原始消息。

只要找到一组满足条件的消息对即可。


4.2 碰撞和第二原像的核心差别

假设攻击者要攻击一份已经存在的合同 M M M。

情况一:合同已经固定

攻击者只能寻找:

M ′ ≠ M M'\neq M M′=M

满足:

H ( M ′ ) = H ( M ) H(M')=H(M) H(M′)=H(M)

这是第二原像攻击。

情况二:签名之前自由准备两份合同

攻击者可以提前准备:

M 1 M_1 M1

和:

M 2 M_2 M2

并努力构造:

H ( M 1 ) = H ( M 2 ) H(M_1)=H(M_2) H(M1)=H(M2)

这是碰撞攻击。

可以简单理解为:

text 复制代码
第二原像:
第一条消息由别人决定。

碰撞:
两条消息都由攻击者决定。

正因为碰撞攻击可以自由选择两条消息,所以它通常比第二原像攻击更容易。


4.3 碰撞安全性为什么只有一半

假设哈希输出为 n n n 位,摘要空间大小为:

N = 2 n N=2^n N=2n

攻击者生成 q q q 条消息后,可以形成:

( q 2 ) = q ( q − 1 ) 2 \binom{q}{2}= \frac{q(q-1)}{2} (2q)=2q(q−1)

组消息对。

每一组消息对产生碰撞的概率约为:

1 N = 1 2 n \frac1N= \frac1{2^n} N1=2n1

因此,碰撞出现的机会与下面这个量有关:

q ( q − 1 ) 2 n + 1 \frac{q(q-1)}{2^{n+1}} 2n+1q(q−1)

当:

q ≈ 2 n / 2 q\approx2^{n/2} q≈2n/2

时:

q 2 ≈ 2 n q^2\approx2^n q2≈2n

此时消息对数量已经达到摘要空间的量级,出现碰撞的概率会明显增加。

因此,理想化情况下:

n 位哈希的碰撞攻击复杂度约为 2 n / 2 \boxed{ n\text{ 位哈希的碰撞攻击复杂度约为 }2^{n/2} } n 位哈希的碰撞攻击复杂度约为 2n/2

例如:

哈希输出长度 理想化碰撞复杂度
128 位 2 64 2^{64} 264
160 位 2 80 2^{80} 280
256 位 2 128 2^{128} 2128
512 位 2 256 2^{256} 2256

对于 SHA-256:

n = 256 n=256 n=256

所以它的通用碰撞安全强度约为:

2 128 2^{128} 2128

这并不表示 SHA-256 摘要只有 128 位,而是表示寻找任意碰撞时,生日攻击将有效搜索空间降低到了 128 位安全强度。

完整的碰撞概率推导和生日攻击实验将在下一篇展开。


五、三种安全目标的统一对比

5.1 攻击模型对比

安全目标 攻击者已知信息 攻击者能否自由选择消息 攻击目标
原像抗性 摘要 h h h 需要寻找输入 找到 M M M,使 H ( M ) = h H(M)=h H(M)=h
第二原像抗性 已知消息 M M M 第一条消息不能选择 找到 M ′ ≠ M M'\neq M M′=M,使 H ( M ′ ) = H ( M ) H(M')=H(M) H(M′)=H(M)
碰撞抗性 只知道哈希算法 两条消息都可以选择 找到 M 1 ≠ M 2 M_1\neq M_2 M1=M2,使摘要相同

5.2 复杂度对比

对于理想化的 n n n 位哈希函数:

原像攻击 ≈ 2 n 第二原像攻击 ≈ 2 n 碰撞攻击 ≈ 2 n / 2 \begin{aligned} \text{原像攻击}&\approx2^n\\ \text{第二原像攻击}&\approx2^n\\ \text{碰撞攻击}&\approx2^{n/2} \end{aligned} 原像攻击第二原像攻击碰撞攻击≈2n≈2n≈2n/2

三者不能简单地互相替代。

尤其需要注意:

找到一组碰撞,不代表攻击者可以指定目标摘要。

碰撞攻击只要求:

H ( M 1 ) = H ( M 2 ) H(M_1)=H(M_2) H(M1)=H(M2)

但原像攻击要求匹配一个已经指定的摘要:

H ( M ) = h H(M)=h H(M)=h

这两个问题的自由度不同,不能因为它们都出现了"相同摘要",就认为攻击难度相同。


5.3 用一句话记忆

text 复制代码
原像:给摘要找消息。

第二原像:给消息找替代品。

碰撞:任意找两条摘要相同的消息。

再换一种角度:

text 复制代码
原像看一个摘要;
第二原像看一条固定消息;
碰撞看两条可自由选择的消息。

六、哈希函数不等于认证

6.1 普通哈希只能进行公开校验

假设下载页面发布:

text 复制代码
文件:tool.zip
摘要:H(tool.zip)

用户下载文件后重新计算:

H ( downloaded file ) H(\text{downloaded file}) H(downloaded file)

如果两个摘要相同,可以说明当前文件与当前摘要匹配。

但这有一个前提:

摘要本身必须来自可信渠道。

如果攻击者可以同时修改:

text 复制代码
恶意文件

和:

text 复制代码
网页上的摘要

那么攻击者只需要重新计算:

H ( malicious file ) H(\text{malicious file}) H(malicious file)

然后把新的摘要一起发布即可。

因此,普通哈希能够帮助发现文件变化,但不能单独证明文件来自官方。


6.2 完整性和认证不是一回事

可以把几个概念区分开:

机制 能否发现数据被修改 能否证明来源 是否需要秘密
普通哈希 有条件可以 不能单独证明
HMAC 可以 可以证明来自持钥方
数字签名 可以 可以验证签名者 私钥
加密 主要提供保密性 不自动提供认证

普通哈希是公开计算的:

H ( M ) H(M) H(M)

任何人都可以重新计算。

HMAC 则引入密钥:

HMAC ⁡ K ( M ) \operatorname{HMAC}_K(M) HMACK(M)

不知道密钥 K K K 的攻击者,不能仅靠公开哈希函数生成正确的认证值。

HMAC 和 MAC 将在后续文章详细介绍,这里只需要先明确:

哈希函数是构造认证机制的基础组件,但普通哈希本身不是认证协议。


6.3 密码存储也不能直接使用普通哈希

下面这种做法看起来合理:

text 复制代码
database_password = SHA256(password)

但它存在明显问题:

  • SHA-256 计算速度很快;
  • 攻击者可以使用 GPU 批量计算;
  • 常见密码容易被字典枚举;
  • 没有盐时,相同密码会产生相同摘要;
  • 泄露一个密码库后,可以针对所有用户重复尝试。

密码存储需要专门的密码派生函数,并配置:

  • 独立随机盐;
  • 足够的计算成本;
  • 必要的内存成本;
  • 合适的并行参数。

这一部分将在第九篇"密码存储与密钥派生"中完整展开。


七、用 16 位实验观察碰撞

7.1 为什么不能直接实验 SHA-256 碰撞

SHA-256 的完整输出长度为 256 位。

根据生日攻击估算,寻找碰撞需要约:

2 128 2^{128} 2128

量级的计算。

普通电脑无法通过简单枚举完成这样的实验,因此我们把 SHA-256 的输出截短为 16 位:

H 16 ( M ) = Truncate ⁡ 16 ( SHA256 ⁡ ( M ) ) H_{16}(M)= \operatorname{Truncate}_{16}(\operatorname{SHA256}(M)) H16(M)=Truncate16(SHA256(M))

此时摘要空间只有:

2 16 = 65536 2^{16}=65536 216=65536

种结果。

对应的碰撞复杂度约为:

2 8 = 256 2^8=256 28=256

这已经适合进行课堂演示。

下面的 16 位摘要只用于观察生日碰撞,不具备任何实际安全性。


7.2 Python 实验代码

python 复制代码
import hashlib


def hash16(message: bytes) -> bytes:
    """
    截取 SHA-256 的前 16 位。
    只用于演示生日碰撞。
    """
    return hashlib.sha256(message).digest()[:2]


seen = {}
collision = None

for i in range(100000):
    message = f"message-{i}".encode()
    digest = hash16(message)

    if digest in seen:
        collision = (
            seen[digest],
            message,
            digest
        )
        break

    seen[digest] = message


if collision is None:
    print("没有找到碰撞")
else:
    message_1, message_2, digest = collision

    print("发现碰撞:")
    print("消息 1:", message_1)
    print("消息 2:", message_2)
    print("摘要:", digest.hex())
    print("消息是否不同:", message_1 != message_2)
    print(
        "摘要是否相同:",
        hash16(message_1) == hash16(message_2)
    )

对于固定的枚举顺序,可能得到:

text 复制代码
发现碰撞:
消息 1: b'message-126'
消息 2: b'message-133'
摘要: 6620
消息是否不同: True
摘要是否相同: True

验证过程为:

message-126 ≠ message-133 \texttt{message-126}\neq\texttt{message-133} message-126=message-133

但:

H 16 ( message-126 ) = H 16 ( message-133 ) = 6620 H_{16}(\texttt{message-126})= H_{16}(\texttt{message-133})= \texttt{6620} H16(message-126)=H16(message-133)=6620

这段实验真正说明的是:

输出空间缩短后,碰撞会迅速出现;哈希输出长度直接影响碰撞安全性。

它并不说明完整 SHA-256 可以在几百次计算内找到碰撞。


总结

哈希函数的核心安全性,不是"摘要不可逆",也不是"输入改变后输出变化很大",而是攻击者能否在现实成本内完成特定任务。

原像抗性

给定摘要:

h h h

寻找:

M , H ( M ) = h M,\quad H(M)=h M,H(M)=h

它回答的是:

能不能从摘要找到一条匹配消息?


第二原像抗性

给定消息:

M M M

寻找:

M ′ ≠ M M'\neq M M′=M

满足:

H ( M ′ ) = H ( M ) H(M')=H(M) H(M′)=H(M)

它回答的是:

能不能替换一条已经确定的消息?


碰撞抗性

寻找任意两条不同消息:

M 1 ≠ M 2 M_1\neq M_2 M1=M2

满足:

H ( M 1 ) = H ( M 2 ) H(M_1)=H(M_2) H(M1)=H(M2)

它回答的是:

能不能自由构造一对摘要相同的消息?

对于理想化的 n n n 位哈希函数:

原像攻击 ≈ 2 n 第二原像攻击 ≈ 2 n 碰撞攻击 ≈ 2 n / 2 \begin{aligned} \text{原像攻击}&\approx2^n\\ \text{第二原像攻击}&\approx2^n\\ \text{碰撞攻击}&\approx2^{n/2} \end{aligned} 原像攻击第二原像攻击碰撞攻击≈2n≈2n≈2n/2

因此, n n n 位哈希函数的碰撞安全性通常只有 n / 2 n/2 n/2 位。

这一篇最重要的不是记住三个英文术语,而是理解攻击模型:

text 复制代码
原像:给摘要找消息。
第二原像:给消息找替代品。
碰撞:任意找两条摘要相同的消息。

下一篇将专门推导生日攻击:

text 复制代码
哈希与认证基础(二):生日攻击:为什么 n-bit 哈希只有 n/2-bit 碰撞安全性

届时再从概率公式、生日悖论和实际实验三个角度,解释碰撞复杂度为什么会从 2 n 2^n 2n 降低到 2 n / 2 2^{n/2} 2n/2。

相关推荐
坚持编程的菜鸟2 小时前
模拟实现strncat
c语言·算法·模拟实现strncat
wabs6662 小时前
关于哈希表【力扣1.两数之和的思考】
数据结构·算法·leetcode·散列表·图论
大辉狼_音频架构2 小时前
第 02 篇:时域的语言 —— 卷积、LTI 系统与冲激响应
算法·音视频开发
河北品高电子安安3 小时前
智慧井盖:物联网技术赋能城市地下管网安全监测
大数据·物联网·安全
淼澄研学3 小时前
NVIDIA NeMo Guardrails 2.0实战:大模型安全护栏集成指南
人工智能·python·安全
星轨初途3 小时前
LeetCode 热题 100——day7 接雨水
数据结构·c++·笔记·算法·leetcode·职场和发展
喜欢的名字被抢了3 小时前
FastAPI 接口安全与工程化 JWT、测试和配置管理
安全·fastapi
qeen873 小时前
【数据结构】搜索二叉树的介绍与算法原理解析及实现
数据结构·c++·学习·算法·模板
Chen—LSN3 小时前
C语言——深度理解指针(6)
c语言·开发语言·算法·c#