mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5283 字
15 分钟
哈希与消息认证码
2026-03-16

当你下载一个 Ubuntu ISO 镜像时,页面上总会附一串 SHA-256 校验值——怎么确认文件没有被篡改?当你登录网站时,数据库里存的是密码原文还是一串乱码?如果攻击者拿到了数据库,你的密码还安全吗? 在 非对称加密 中理解了 RSA 与 ECC。密码学的第三根支柱——哈希函数与消息认证码——看起来最不起眼,却无处不在:Git 的 commit hash、区块链的区块哈希、密码的 bcrypt 存储、HMAC 签名……它们不加密任何数据,却默默守护着完整性和认证。

一、哈希函数基础#

1.1 什么是哈希函数?#

哈希函数将任意长度的输入映射为固定长度的输出(摘要):

graph LR M1["消息 1<br/>'Hello'"] --> H["哈希函数<br/>SHA-256"] --> D1["摘要 1<br/>185f8db..."] M2["消息 2<br/>'Hello!'"] --> H --> D2["摘要 2<br/>334d016..."] Note["仅差一个字符<br/>摘要完全不同"]

1.2 哈希函数的五大属性#

密码学哈希函数必须同时满足以下五个核心属性,缺少任何一个都会导致安全漏洞:

属性英文定义直觉理解破坏后果
原像抗性Preimage Resistance给定 h,找到 m 使得 H(m)=h 不可行给你指纹,找不到人可逆推原始数据
第二原像抗性Second Preimage给定 m1,找到 m2≠m1 使 H(m1)=H(m2) 不可行找同指纹的另一个人可替换消息
抗碰撞Collision Resistance找到任意 m1≠m2 使 H(m1)=H(m2) 不可行找任意两个同指纹的人可伪造签名
雪崩效应Avalanche Effect输入 1 位变化导致约 50% 输出位变化牵一发而动全身差分分析攻击
伪随机性Pseudorandomness输出与随机数不可区分看起来像噪声信息泄露
Note

抗碰撞 ⟹ 第二原像抗性,但抗碰撞并不直接蕴含原像抗性。三者需要独立评估。

graph TB subgraph "哈希函数在安全系统中的角色" A["文件完整性校验"] --> H["哈希函数"] B["密码存储"] --> H C["数字签名"] --> H D["消息认证码"] --> H E["密钥派生"] --> H F["随机数生成"] --> H end

1.3 哈希 vs 加密#

维度哈希加密
方向性单向,不可逆双向,可解密
密钥无需密钥必须有密钥
输出长度固定与输入相关
典型用途完整性、认证机密性
举例指纹——只能采集,不能还原人保险箱——有钥匙能打开

1.4 密码学哈希 vs 普通哈希#

维度密码学哈希普通哈希
用途安全认证数据结构
抗碰撞必须不需要
单向性必须不需要
速度故意慢(密码存储)越快越好
典型算法SHA-256, bcryptMurmurHash, xxHash

二、SHA-2 与 SHA-3#

2.1 SHA-2 家族#

算法输出长度分组大小安全等级用途
SHA-224224 位512 位112 位兼容
SHA-256256 位512 位128 位最广泛使用
SHA-384384 位1024 位192 位高安全
SHA-512512 位1024 位256 位最高安全
# Python: SHA-256
import hashlib
digest = hashlib.sha256(b"Hello, World!").hexdigest()
# 输出: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
# SHA-512
digest512 = hashlib.sha512(b"Hello, World!").hexdigest()
// Java: SHA-256
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest("Hello, World!".getBytes(StandardCharsets.UTF_8));

2.2 SHA-256 内部结构#

SHA-256 基于 Merkle-Damgård 结构,核心是消息调度和压缩函数。理解其内部原理有助于理解后续的长度扩展攻击:

  1. 消息填充:将消息填充到 512 位的整数倍,填充内容包含原始消息长度
  2. 消息调度(Message Schedule):将 512 位分组扩展为 64 个 32 位字 W[0..63]
  3. 压缩函数:64 轮运算,每轮使用不同的轮常数和消息字
  4. 累加:将压缩结果与当前哈希状态相加
graph TB subgraph "SHA-256 处理流程" MSG["原始消息"] --> PAD["填充<br/>1 + 0...0 + 64位长度"] PAD --> BLOCK1["分组 1<br/>512 bits"] PAD --> BLOCK2["分组 2<br/>512 bits"] BLOCK1 --> COMP1["压缩函数<br/>64轮"] IV["初始向量<br/>H0..H7"] --> COMP1 COMP1 --> STATE1["中间状态"] STATE1 --> COMP2["压缩函数<br/>64轮"] BLOCK2 --> COMP2 COMP2 --> FINAL["最终摘要<br/>256 bits"] end

消息调度扩展:前 16 个字直接来自消息分组,后续字通过 W[t] = σ1(W[t-2]) + W[t-7] + σ0(W[t-15]) + W[t-16] 生成。压缩函数每轮执行 Ch、Maj 等位运算并更新 8 个工作变量。

// Go: SHA-256 流式计算(处理大文件)
package main
import (
"crypto/sha256"
"fmt"
)
func main() {
h := sha256.New()
h.Write([]byte("Hello, "))
h.Write([]byte("World!"))
fmt.Printf("%x\n", h.Sum(nil))
}

2.3 SHA-3(Keccak)#

SHA-3 基于 Keccak 算法,与 SHA-2 结构完全不同。SHA-3 的诞生并非因为 SHA-2 被攻破,而是作为保险——万一 Merkle-Damgård 结构被发现严重漏洞,有一个完全不同的替代方案。

为什么需要 SHA-3?

原因说明
结构多样性SHA-2 全家基于 Merkle-Damgård,一个漏洞可能影响全部
长度扩展攻击Merkle-Damgård 结构天然受此攻击影响
可扩展功能海绵结构天然支持 XOF、KMAC 等扩展
形式化安全海绵结构有可证明安全性
维度SHA-2SHA-3
结构Merkle-Damgård海绵结构
安全证明无形式化证明可证明安全
长度扩展攻击受影响不受影响
可扩展功能有限XOF、SHAKE、KMAC
# Python: SHA-3
import hashlib
digest = hashlib.sha3_256(b"Hello, World!").hexdigest()
# SHAKE128(可扩展输出函数)
shake = hashlib.shake_128(b"Hello, World!")
output = shake.hexdigest(32) # 32 字节输出

2.4 海绵结构#

SHA-3 的核心是海绵结构(Sponge Construction),分为吸收和挤出两个阶段:

graph LR subgraph "海绵结构详细流程" direction LR M1["消息块 1"] -->|"⊕"| S1["状态<br/>r位|c位"] M2["消息块 2"] -->|"⊕"| S2["状态<br/>r位|c位"] S1 -->|"f<br/>置换"| S2 S2 -->|"f<br/>置换"| S3["状态<br/>r位|c位"] S3 -->|"挤出"| Z1["输出块 1"] S3 -->|"f<br/>置换"| S4["状态"] S4 -->|"挤出"| Z2["输出块 2"] end
参数说明
吸收率(r)每轮吸收的比特数
容量(c)安全性参数,c = 2×安全等级
状态大小r + c = 1600 位(SHA-3)
置换函数 fKeccak-f[1600],24 轮非线性置换

海绵结构的关键优势:输出长度可变,只需继续”挤出”即可。这正是 SHAKE 函数的基础。

2.5 性能对比#

算法输出长度速度(MB/s)硬件加速推荐场景
SHA-256256 位~600Intel SHA-NI通用首选
SHA-512512 位~90064 位平台
SHA3-256256 位~400抗长度扩展
BLAKE2b256/512 位~1000高性能场景
BLAKE3256 位~1500SIMD极致性能
Note

SHA-256 在支持 SHA-NI 指令集的处理器上速度可提升 5-10 倍。在大多数场景中,SHA-256 仍是性能和安全的最佳平衡点。

三、HMAC 消息认证码#

3.1 为什么需要 HMAC?#

哈希函数只能验证完整性,不能验证消息来源——攻击者可以同时篡改消息和哈希值。HMAC 使用密钥来认证消息:

graph TB subgraph "哈希(无认证)" M1["消息"] --> H1["SHA-256"] H1 --> D1["摘要"] Note1["攻击者可以同时修改消息和摘要"] end subgraph "HMAC(有认证)" M2["消息"] --> HMAC["HMAC-SHA256<br/>+ 密钥"] HMAC --> MAC["MAC 值"] Note2["没有密钥无法伪造 MAC"] end

3.2 HMAC 构造详解#

HMAC(K, m) = H((K' ⊕ opad) || H((K' ⊕ ipad) || m))
其中:
- K' = 如果 K > block_size,则 K' = H(K);否则 K' = K 填充到 block_size
- ipad = 0x36 重复 block_size 次
- opad = 0x5C 重复 block_size 次
graph TB subgraph "HMAC 构造流程" K["密钥 K"] --> PAD1["填充到 block_size<br/>得到 K'"] PAD1 --> XOR1["K' ⊕ ipad<br/>(0x3636...36)"] M["消息 m"] --> INNER XOR1 --> INNER["内部哈希<br/>H(K'⊕ipad || m)"] INNER --> INNER_HASH["内部摘要"] PAD1 --> XOR2["K' ⊕ opad<br/>(0x5C5C...5C)"] XOR2 --> OUTER["外部哈希<br/>H(K'⊕opad || 内部摘要)"] INNER_HASH --> OUTER OUTER --> RESULT["HMAC 结果"] end

为什么 HMAC 比 H(key||message) 更安全?

构造方式安全性原因
H(key || message)不安全受长度扩展攻击
H(message || key)不安全内部碰撞攻击
H(key || H(key || message))可用可防御长度扩展,但无证明
HMAC安全有可证明安全性

HMAC 的双层哈希确保:内层将变长消息压缩为固定长度,外层防止长度扩展攻击,ipad/opad 不同确保内外层使用不同”密钥”。

# Python: HMAC-SHA256
import hmac
import hashlib
key = b"secret-key"
message = b"Hello, World!"
# 生成 HMAC
mac = hmac.new(key, message, hashlib.sha256).hexdigest()
# 验证 HMAC(常数时间比较)
received_mac = "..."
hmac.compare_digest(mac, received_mac) # 安全
# mac == received_mac # 不安全(时序攻击)
// Go: HMAC-SHA256
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
func main() {
key := []byte("secret-key")
message := []byte("Hello, World!")
mac := hmac.New(sha256.New, key)
mac.Write(message)
fmt.Println(hex.EncodeToString(mac.Sum(nil)))
}

3.3 HMAC 在 JWT 中的应用#

JWT 使用 HS256 算法时,实际上就是 HMAC-SHA256:

JWT = Base64(header) + "." + Base64(payload) + "." + Base64(HMAC-SHA256(key, header + "." + payload))
# Python: JWT HMAC 签名验证
import hmac, hashlib, base64
def verify_jwt_hs256(token: str, secret: str) -> bool:
parts = token.split(".")
if len(parts) != 3:
return False
signing_input = f"{parts[0]}.{parts[1]}".encode()
expected_sig = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
actual_sig = base64.urlsafe_b64decode(parts[2] + "==")
return hmac.compare_digest(expected_sig, actual_sig)
Warning

JWT 的 HMAC 密钥必须足够长且随机。建议至少 256 位(32 字节)随机数据。弱密钥可被离线暴力破解,伪造任意 JWT。

3.4 HMAC vs 其他 MAC#

MAC 类型基于算法速度并行安全
HMAC-SHA256SHA-256
Poly1305一次性密钥极快
GMACAES + GHASH快(需 AES-NI)
KMACSHA-3/Keccak
Note

HMAC 的一个常见错误是使用 == 比较 MAC 值——这会导致时序攻击。始终使用常数时间比较函数(如 Python 的 hmac.compare_digest 或 Java 的 MessageDigest.isEqual)。

四、碰撞攻击#

4.1 碰撞的类型#

攻击类型定义难度实际影响
碰撞攻击找到 m1 ≠ m2 使得 H(m1) = H(m2)2^(n/2)可伪造两份不同文档
第二原像攻击给定 m1,找到 m2 使得 H(m1) = H(m2)2^n可替换指定文档
原像攻击给定 h,找到 m 使得 H(m) = h2^n可逆推任意数据

4.2 生日攻击#

生日攻击利用生日悖论:在 2^(n/2) 次尝试后,有 50% 概率找到碰撞:

哈希长度碰撞难度安全评估
MD5 (128 位)2^64已被攻破
SHA-1 (160 位)2^80已被攻破
SHA-256 (256 位)2^128安全
SHA-512 (512 位)2^256极安全

4.3 MD5 碰撞——Flame 恶意软件#

MD5 碰撞已从理论走向实战。最著名的案例是 2012 年的 Flame 恶意软件:攻击者利用 MD5 碰撞生成两份相同哈希的证书,一份正常、一份包含恶意代码签名标志,绕过了 Windows 的代码签名验证。

年份MD5 碰撞里程碑复杂度
2004王小云团队理论碰撞2^39(约 1 小时)
2006可控前缀碰撞2^50
2008伪造 CA 证书实际演示
2012Flame 恶意软件实际攻击
# 演示:MD5 碰撞的严重性
import hashlib
# 两个不同的输入产生相同的 MD5(概念演示)
m1 = bytes.fromhex("d131dd02c5e6eec4693d9a0698aff95c...")
m2 = bytes.fromhex("d131dd02c5e6eec4693d9a0698aff95c...")
# m1 != m2,但 MD5(m1) == MD5(m2)
assert m1 != m2
assert hashlib.md5(m1).hexdigest() == hashlib.md5(m2).hexdigest()

4.4 SHA-1 碰撞——SHAttered 攻击#

2017 年,Google 和 CWI Amsterdam 联合发布了 SHAttered 攻击,首次实现 SHA-1 实际碰撞:

年份事件影响
2005理论攻击2^63 复杂度
2017SHAttered 攻击实际碰撞演示,约 6500 CPU 年
2020选择前缀碰撞可伪造证书,约 9000 GPU 年
Warning

SHA-1 已被完全攻破,不得用于任何安全场景。Git 已迁移到 SHA-256,浏览器已拒绝 SHA-1 证书。如果系统中仍使用 SHA-1,立即迁移。

4.5 碰撞攻击的实际影响#

场景影响原因
数字签名严重 严重攻击者可准备两份文档,让你签一份,替换为另一份
密码存储中等 较小需要原像攻击,碰撞攻击无法直接利用
文件校验中等 中等需要第二原像攻击才能替换指定文件
证书签发严重 严重选择前缀碰撞可伪造 CA 证书

4.6 如何选择哈希算法#

使用场景推荐算法理由
通用数据完整性SHA-256性能好,广泛支持
数字签名SHA-256 或 SHA-512安全性足够
需要抗长度扩展SHA-3 或 HMAC-SHA256海绵结构天然免疫
密码存储Argon2id慢哈希 + 内存硬
高性能场景BLAKE2b/BLAKE3比 SHA 更快
需要可变输出SHAKE256XOF 灵活输出

五、长度扩展攻击#

5.1 攻击原理#

Merkle-Damgård 结构的哈希函数(SHA-2)存在长度扩展攻击:

已知:H(m) 和 len(m)
可以计算:H(m || padding || m') 而不需要知道 m!

为什么可能?因为 SHA-2 的输出就是内部状态——知道 H(m) 就等于知道了处理完消息 m 后的完整内部状态。攻击者可以直接将这个状态作为初始状态,继续处理附加消息 m’。

graph TB subgraph "正常计算" M1["消息 m"] --> H1["压缩函数"] H1 --> STATE1["中间状态<br/>= H(m)"] STATE1 --> H2["压缩函数"] M2["附加 m'"] --> H2 H2 --> RESULT1["H(m || pad || m')"] end subgraph "长度扩展攻击" KNOWN["已知 H(m) 和 len(m)"] --> FAKE["直接用 H(m) 作为状态"] FAKE --> H3["压缩函数"] M2B["附加 m'"] --> H3 H3 --> RESULT2["H(m || pad || m')"] NOTE["无需知道 m!"] end

5.2 长度扩展攻击实战#

# 长度扩展攻击演示(使用 hashpumpy 库)
# pip install hashpumpy
import hashpumpy, hashlib
# 场景:服务器使用 H(key || message) 验证消息
secret_key = b"my-secret-key" # 攻击者不知道
original_message = b"data=value"
server_hash = hashlib.sha256(secret_key + original_message).hexdigest()
key_length = len(secret_key)
# 长度扩展攻击:追加 "&admin=true"
new_data, new_hash = hashpumpy.hashpump(
server_hash, # 原始哈希
original_message, # 原始消息
b"&admin=true", # 要追加的数据
key_length # 密钥长度
)
# new_hash == H(key || original_message || padding || "&admin=true")
# 服务器会验证通过!

5.3 为什么 HMAC 能防御长度扩展#

HMAC 的双层结构天然防御长度扩展攻击:

  1. 内层:H(K'⊕ipad || m) — 即使攻击者知道内层输出,也无法扩展
  2. 外层:H(K'⊕opad || 内层输出) — 攻击者不知道 K'⊕opad,无法继续扩展

关键:HMAC 的最终输出不是内层哈希的状态,而是外层哈希的输出。攻击者无法从中恢复外层哈希的内部状态。

5.4 防御措施#

防御方式说明推荐
HMAC密钥参与哈希计算推荐
SHA-3海绵结构不受影响推荐
H(key || m)受长度扩展攻击不安全
H(m || key)仍受攻击不安全
双哈希 H(H(m))可防御可选

六、密码存储专用哈希#

6.1 为什么不能用 SHA-256 存密码?#

SHA-256 太快了——这是通用哈希的优点,却是密码存储的致命缺陷:

攻击方式SHA-256 速度意味着什么
离线暴力破解~10 亿次/秒(GPU)6 位密码 < 1 秒破解
字典攻击~10 亿次/秒(GPU)常见密码瞬间破解
彩虹表预计算一次相同密码哈希值相同

即使加盐(salt),SHA-256 的速度仍让暴力破解可行。密码存储需要故意慢的哈希函数。

6.2 密码哈希算法对比#

算法速度内存硬GPU 抵抗并行抵抗推荐
bcrypt~1万次/秒否(4KB)部分兼容
scrypt~1千次/秒是(可调)可选
Argon2id~100次/秒是(可调)推荐
PBKDF2~1万次/秒兼容

Argon2 参数选择:

参数含义推荐值
time_cost迭代次数3
memory_cost内存使用65536 (64MB)
parallelism并行度4
salt_length盐长度16 字节
hash_length输出长度32 字节

6.3 代码示例#

# Python: Argon2 密码哈希(推荐)
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=3, memory_cost=65536, parallelism=4,
hash_len=32, salt_len=16
)
hash = ph.hash("my-password")
# 输出: $argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$hash...
try:
ph.verify(hash, "my-password") # True
except Exception:
print("Password incorrect")
# 检查是否需要重新哈希(参数变更后)
if ph.check_needs_rehash(hash):
new_hash = ph.hash("my-password")
// Go: bcrypt 密码哈希
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
password := "my-password"
hash, _ := bcrypt.GenerateFromPassword([]byte(password), 12)
fmt.Println(string(hash))
err := bcrypt.CompareHashAndPassword(hash, []byte(password))
fmt.Println(err == nil) // true
}

6.4 密码存储最佳实践#

Tip

密码存储清单——每一条都必须做到:

Tip

密码存储清单——每一条都必须做到:

  • 使用专用密码哈希:Argon2id > scrypt > bcrypt > PBKDF2,绝不使用 SHA-256
  • 每个密码独立加盐:盐至少 16 字节随机数,防止彩虹表攻击
  • 参数足够高:目标验证时间 ≥ 250ms(用户登录场景)
  • 常数时间比较:验证时使用常数时间函数,防止时序攻击
  • pepper 机制:在哈希前额外混入服务器端密钥,数据库泄露后仍需额外信息
  • 密码迁移策略:用户登录时自动升级旧哈希到新算法
  • 限制登录尝试:防止在线暴力破解
  • 不存原文:绝不存储密码明文或可逆加密

七、哈希在真实系统中的应用#

哈希函数远不止”校验文件完整性”——它们是现代计算基础设施的基石。

7.1 Git 提交哈希#

Git 使用 SHA-1(正在迁移到 SHA-256)作为内容寻址标识。每个对象的哈希值就是它的唯一标识:任何修改都会改变哈希值,相同内容始终产生相同哈希(去重),哈希值本身就是完整性证明。

7.2 区块链#

区块链的核心数据结构就是哈希链——每个区块包含前一个区块的哈希:

graph LR B1["区块 1<br/>Hash: 0xabc..."] -->|"prev_hash"| B2["区块 2<br/>Hash: 0xdef..."] B2 -->|"prev_hash"| B3["区块 3<br/>Hash: 0x789..."] B3 -->|"prev_hash"| B4["区块 4<br/>Hash: 0x012..."] Note1["修改区块 1<br/>→ 后续所有哈希失效"]

修改任何一个区块,后续所有区块的哈希都会改变——这就是区块链不可篡改性的基础。

7.3 内容寻址存储与去重#

IPFS 等分布式存储系统使用内容寻址(Content-Addressable Storage):

特性传统寻址内容寻址
标识方式位置(URL/路径)内容哈希
重复数据多份副本自动去重
完整性需额外校验哈希即校验
# 简化的去重存储系统
import hashlib, os
class DedupStorage:
def __init__(self, storage_dir):
self.storage_dir = storage_dir
self.hash_to_ref = {}
def store(self, filepath: str) -> str:
with open(filepath, "rb") as f:
content = f.read()
content_hash = hashlib.sha256(content).hexdigest()
if content_hash not in self.hash_to_ref:
path = os.path.join(self.storage_dir, content_hash)
with open(path, "wb") as f:
f.write(content)
self.hash_to_ref[content_hash] = 1
else:
self.hash_to_ref[content_hash] += 1
return content_hash

七·附、实践:哈希碰撞与消息认证#

哈希函数的历史几乎与计算机科学本身一样悠久。早在 1950 年代,程序员就用简单的校验和(Checksum)来检测数据传输错误——把所有字节加起来取模,一个数字就能告诉你”数据有没有变”。但校验和太弱了:交换两个字节的位置,校验和不变;把某些字节加上再减去同样的值,校验和也不变。于是 CRC(循环冗余校验)在 1960 年代被发明出来,用多项式除法检测突发错误,至今仍在以太网、ZIP 文件中服役。然而 CRC 本质上不是密码学工具——它只检测随机错误,不抵抗恶意篡改。

密码学哈希的时代从 1990 年 Ron Rivest 设计的 MD4 开始。MD4 只有 128 位输出,结构简洁得令人惊叹——但正是这份简洁让王小云团队在 2004 年用不到一小时的计算找到了碰撞。MD5 作为 MD4 的加强版,同样在 2004 年被攻破。1993 年发布的 SHA-0、1995 年的 SHA-1 也相继倒下——2017 年的 SHAttered 攻击用 6500 CPU 年实现了 SHA-1 实际碰撞。SHA-2 家族(SHA-256/SHA-512)至今屹立不倒,但其 Merkle-Damgård 结构固有的长度扩展攻击缺陷促使 NIST 在 2007 年发起了 SHA-3 竞赛。2012 年,Keccak 凭借其海绵结构胜出——它不仅不受长度扩展攻击影响,还天然支持可变长度输出(SHAKE)和消息认证码(KMAC)。与此同时,密码存储领域也经历了从 DES-crypt 到 MD5-crypt、再到 bcrypt(1999)、scrypt(2009)、Argon2(2015)的演进——核心思路始终是:让攻击者的每一次猜测都付出足够高的代价。

附.1 前置知识#

  • Python 3.8+ 环境(本节使用 Python 3.13)
  • 标准库:hashlibhmacstruct;第三方库:bcryptpip install bcrypt
  • 理解本章前七节的理论内容,尤其是 Merkle-Damgård 结构和 HMAC 构造

附.2 SHA-2 与 SHA-3 哈希计算#

同一输入,不同算法,输出完全不同——这正是哈希函数设计的目标:不同算法之间没有任何关联性。

import hashlib
message = b"Hello, Cryptography!"
# SHA-256
sha256 = hashlib.sha256(message).hexdigest()
print(f"SHA-256: {sha256}")
# SHA-512
sha512 = hashlib.sha512(message).hexdigest()
print(f"SHA-512: {sha512}")
# SHA3-256
sha3_256 = hashlib.sha3_256(message).hexdigest()
print(f"SHA3-256: {sha3_256}")
# 雪崩效应:仅改一个字符
message2 = b"Hello, Cryptography?"
sha256_2 = hashlib.sha256(message2).hexdigest()
h1, h2 = int(sha256, 16), int(sha256_2, 16)
diff_bits = bin(h1 ^ h2).count('1')
print(f"\n--- 雪崩效应 ---")
print(f"SHA-256('...y!'): {sha256}")
print(f"SHA-256('...?'): {sha256_2}")
print(f"不同位数: {diff_bits}/256 ({diff_bits/256*100:.1f}%)")
SHA-256: 29aff889935f5a275ec562ef46c138e917a270aab79b4d5577ee8ea5af308f73
SHA-512: c3528d42b472281ab0ee4ad4410dbe351eefda688dff8550c9dfcc7b29a2bc1b70c1fcfa9f3d1f552e928f7a97b02b2b50dd675b1e17019b383c0162655c6918
SHA3-256: f2d6e73d4c107550fae2b9a08e2f85c820e5811c0ea37cc9a412df33b195f904
--- 雪崩效应 ---
SHA-256('...y!'): 29aff889935f5a275ec562ef46c138e917a270aab79b4d5577ee8ea5af308f73
SHA-256('...?'): 1084ad7edc449759443809509c3e4a87f64c4ea206d5900e997859877e26814e
不同位数: 151/256 (59.0%)

注:雪崩效应的理想值是 50%(128/256 位),实际 59.0% 在统计波动范围内。仅改变一个字符 !?,输出就有超过一半的位发生变化。

附.3 文件完整性校验#

文件完整性校验是哈希函数最古老也最直观的应用:发布者公布哈希值,下载者本地计算哈希值,两者一致则文件未被篡改。

import hashlib
import tempfile
import os
def file_hash(path, algo="sha256"):
"""计算文件的哈希值(流式读取,支持大文件)"""
h = hashlib.new(algo)
with open(path, "rb") as f:
while chunk := f.read(8192):
h.update(chunk)
return h.hexdigest()
# 创建临时文件
tmpdir = tempfile.mkdtemp()
filepath = os.path.join(tmpdir, "important_data.txt")
original_content = (
b"Critical system configuration:\n"
b"server=prod-db-01\n"
b"port=5432\n"
b"ssl=enabled\n"
)
with open(filepath, "wb") as f:
f.write(original_content)
# 计算原始哈希
original_hash = file_hash(filepath)
print(f"原始文件 SHA-256: {original_hash}")
# 完整性校验——通过
current_hash = file_hash(filepath)
print(f"完整性校验: {' 通过' if current_hash == original_hash else ' 失败'}")
# 模拟篡改
print(f"\n--- 模拟篡改 ---")
tampered_content = (
b"Critical system configuration:\n"
b"server=attacker-evil\n"
b"port=5432\n"
b"ssl=disabled\n"
)
with open(filepath, "wb") as f:
f.write(tampered_content)
tampered_hash = file_hash(filepath)
print(f"篡改后 SHA-256: {tampered_hash}")
print(f"完整性校验: {' 通过' if tampered_hash == original_hash else ' 失败(文件已被篡改!)'}")
# 同一文件多种算法对比
print(f"\n--- 同一文件多种算法 ---")
with open(filepath, "wb") as f:
f.write(original_content)
for algo in ["md5", "sha1", "sha256", "sha512"]:
print(f"{algo.upper():8s}: {file_hash(filepath, algo)}")
os.remove(filepath)
os.rmdir(tmpdir)
原始文件 SHA-256: cb470cc7dff2ff61629f30749e41a73c7d16af67603783578f7050a3940f6025
完整性校验: 通过
--- 模拟篡改 ---
篡改后 SHA-256: 5b2168683c5957063d4a0539d57f68f81217fbe9d401969c204521b2e386a580
完整性校验: 失败(文件已被篡改!)
--- 同一文件多种算法 ---
MD5 : 05bf53ced9b5a06bc9fdc8abfc5d8dac
SHA1 : 14382d3c827a478f38d0626476f37e64666c0c74
SHA256 : cb470cc7dff2ff61629f30749e41a73c7d16af67603783578f7050a3940f6025
SHA512 : ed4c0ca7f34efbe36364f67275537a6b29297dd048b95cd2d8c928056a02e20ae08455da3e95a90c3f58bf645575f20a54755f2476714b7d5222d1ffb6ecd282

注意:MD5 和 SHA-1 已被攻破,仅用于遗留系统兼容。新系统必须使用 SHA-256 或更强的算法。上面的对比只是为了展示不同算法的输出长度差异。

附.4 HMAC 签名与验证#

哈希只能验证完整性,不能验证来源——攻击者可以同时篡改消息和哈希值。HMAC 通过引入密钥解决了这个问题:没有密钥就无法伪造合法的 MAC 值。

import hmac
import hashlib
secret_key = b"my-secret-key-2024"
message = b"Transfer $1000 to account 12345"
# 生成 HMAC-SHA256 签名
mac_value = hmac.new(secret_key, message, hashlib.sha256).hexdigest()
print(f"密钥: {secret_key.decode()}")
print(f"消息: {message.decode()}")
print(f"HMAC-SHA256: {mac_value}")
# 正确密钥验证
is_valid = hmac.compare_digest(
hmac.new(secret_key, message, hashlib.sha256).hexdigest(),
mac_value
)
print(f"\n--- 验证(正确密钥) ---")
print(f"验证结果: {' 签名有效' if is_valid else ' 签名无效'}")
# 错误密钥验证
wrong_key = b"wrong-secret-key"
is_valid_wrong = hmac.compare_digest(
hmac.new(wrong_key, message, hashlib.sha256).hexdigest(),
mac_value
)
print(f"\n--- 验证(错误密钥) ---")
print(f"错误密钥: {wrong_key.decode()}")
print(f"验证结果: {' 签名有效' if is_valid_wrong else ' 签名无效(密钥不匹配)'}")
# 篡改消息验证
tampered_message = b"Transfer $9000 to account 12345"
is_valid_tampered = hmac.compare_digest(
hmac.new(secret_key, tampered_message, hashlib.sha256).hexdigest(),
mac_value
)
print(f"\n--- 验证(篡改消息) ---")
print(f"篡改消息: {tampered_message.decode()}")
print(f"验证结果: {' 签名有效' if is_valid_tampered else ' 签名无效(消息被篡改)'}")
# 不同算法的 HMAC
print(f"\n--- 不同算法的 HMAC ---")
for algo in ["md5", "sha1", "sha256", "sha512"]:
mac = hmac.new(secret_key, message, algo).hexdigest()
print(f"HMAC-{algo.upper():6s}: {mac}")
密钥: my-secret-key-2024
消息: Transfer $1000 to account 12345
HMAC-SHA256: b6c909a9cd218c40a881e7de606f9b633a709befc225eb1210b380e39d35c753
--- 验证(正确密钥) ---
验证结果: 签名有效
--- 验证(错误密钥) ---
错误密钥: wrong-secret-key
验证结果: 签名无效(密钥不匹配)
--- 验证(篡改消息) ---
篡改消息: Transfer $9000 to account 12345
验证结果: 签名无效(消息被篡改)
--- 不同算法的 HMAC ---
HMAC-MD5 : d1e2ea83d3cb132d0227cdd3468a6adf
HMAC-SHA1 : 4d6ea70b9e3b1cf7e704db516270d444430a4052
HMAC-SHA256: b6c909a9cd218c40a881e7de606f9b633a709befc225eb1210b380e39d35c753
HMAC-SHA512: 8fbfffc9c87a0554ecc9ced3f7170cbba63d1dc1142ac4362c538a13d64d8dc3e22443bbd0ff1843fa3c05dc10e0e23052ebc502608cfc08477ac092cfaa2aee

注:验证 HMAC 时必须使用 hmac.compare_digest() 而非 ==== 运算符在发现第一个不同字节时就返回,攻击者可以通过测量响应时间逐字节猜测正确的 MAC 值——这就是时序攻击。compare_digest 始终比较全部字节,不泄露任何时序信息。

附.5 长度扩展攻击演示#

这是本节最重要的实践:亲眼看到 H(key||msg) 构造为什么不安全。将手动实现 SHA-256 的压缩函数,从已知哈希值恢复内部状态,然后在不知道密钥的情况下构造出合法的伪造哈希。

攻击前提:攻击者知道 H(key||msg) 的值和 len(key||msg) 的长度,但不知道 key 本身。

攻击原理:SHA-256 基于 Merkle-Damgård 结构,其输出就是处理完最后一个块后的内部状态。攻击者可以直接将这个状态作为起点,继续处理附加数据,得到 H(key||msg||padding||extension) 而不需要知道 key。

import hashlib
import struct
# SHA-256 轮常数
K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]
def rotr(x, n):
return ((x >> n) | (x << (32 - n))) & 0xFFFFFFFF
def sha256_compress(state, block):
"""SHA-256 压缩函数:处理一个 512 位块"""
W = list(struct.unpack('>16I', block))
for i in range(16, 64):
s0 = rotr(W[i-15], 7) ^ rotr(W[i-15], 18) ^ (W[i-15] >> 3)
s1 = rotr(W[i-2], 17) ^ rotr(W[i-2], 19) ^ (W[i-2] >> 10)
W.append((W[i-16] + s0 + W[i-7] + s1) & 0xFFFFFFFF)
a, b, c, d, e, f, g, h = state
for i in range(64):
S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
ch = (e & f) ^ ((~e) & g)
temp1 = (h + S1 + ch + K[i] + W[i]) & 0xFFFFFFFF
S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
maj = (a & b) ^ (a & c) ^ (b & c)
temp2 = (S0 + maj) & 0xFFFFFFFF
h, g, f, e, d, c, b, a = g, f, e, (d+temp1)&0xFFFFFFFF, c, b, a, (temp1+temp2)&0xFFFFFFFF
return tuple((s+v) & 0xFFFFFFFF for s, v in zip(state, (a,b,c,d,e,f,g,h)))
def sha256_padding(msg_length):
"""计算 SHA-256 的 Merkle-Damgård 填充"""
padding = b'\x80'
pad_len = (55 - msg_length % 64) % 64
padding += b'\x00' * pad_len
padding += struct.pack('>Q', msg_length * 8)
return padding
def sha256_from_state(state, prior_length, append_data):
"""从已知内部状态继续哈希计算(长度扩展攻击的核心)"""
total_length = prior_length + len(append_data)
padded = append_data + sha256_padding(total_length)
for i in range(0, len(padded), 64):
block = padded[i:i+64]
if len(block) == 64:
state = sha256_compress(state, block)
return struct.pack('>8I', *state).hex()
# ===== 攻击演示 =====
secret_key = b"secret-key-123" # 攻击者不知道
original_message = b"data=hello"
# 服务器计算 H(key||msg)
server_hash = hashlib.sha256(secret_key + original_message).hexdigest()
print(f"[服务器端]")
print(f"密钥(攻击者未知): {secret_key.decode()}")
print(f"原始消息: {original_message.decode()}")
print(f"H(key||msg): {server_hash}")
# 攻击者已知信息
print(f"\n[攻击者已知]")
print(f"原始消息: {original_message.decode()}")
print(f"H(key||msg): {server_hash}")
print(f"key 长度: {len(secret_key)} 字节")
# 步骤 1:从哈希值恢复内部状态
recovered_state = struct.unpack('>8I', bytes.fromhex(server_hash))
# 步骤 2:计算原始消息的填充(glue padding)
original_full_length = len(secret_key) + len(original_message)
glue_padding = sha256_padding(original_full_length)
padded_length = original_full_length + len(glue_padding)
# 步骤 3:构造伪造消息
append_data = b"&admin=true"
forged_data = original_message + glue_padding + append_data
# 步骤 4:从恢复的状态计算伪造哈希
forged_hash = sha256_from_state(recovered_state, padded_length, append_data)
print(f"\n[攻击者构造]")
print(f"追加数据: {append_data.decode()}")
print(f"伪造哈希: {forged_hash}")
# 验证
verification_hash = hashlib.sha256(secret_key + forged_data).hexdigest()
print(f"\n[验证]")
print(f"服务器计算 H(key||forged_msg): {verification_hash}")
print(f"攻击者提供的哈希: {forged_hash}")
print(f"是否匹配: {' 匹配!攻击成功!' if verification_hash == forged_hash else ' 不匹配'}")
# HMAC 不受此攻击影响
import hmac
hmac_original = hmac.new(secret_key, original_message, hashlib.sha256).hexdigest()
hmac_forged = hmac.new(secret_key, forged_data, hashlib.sha256).hexdigest()
print(f"\n[HMAC 对比]")
print(f"HMAC(key, original): {hmac_original}")
print(f"HMAC(key, forged): {hmac_forged}")
print(f"两者相同: {'是(漏洞!)' if hmac_original == hmac_forged else '否(HMAC 安全)'}")
[服务器端]
密钥(攻击者未知): secret-key-123
原始消息: data=hello
H(key||msg): 4d554309dc24740b5d3e90c69180195d0c436943bc6f82ad819fa9b648d5be02
[攻击者已知]
原始消息: data=hello
H(key||msg): 4d554309dc24740b5d3e90c69180195d0c436943bc6f82ad819fa9b648d5be02
key 长度: 14 字节
[攻击者构造]
追加数据: &admin=true
伪造哈希: 7070b7b023e4cf47ea8efcbfcf0792f0a928c2a284f019de7e906bf6690dff45
[验证]
服务器计算 H(key||forged_msg): 7070b7b023e4cf47ea8efcbfcf0792f0a928c2a284f019de7e906bf6690dff45
攻击者提供的哈希: 7070b7b023e4cf47ea8efcbfcf0792f0a928c2a284f019de7e906bf6690dff45
是否匹配: 匹配!攻击成功!
[HMAC 对比]
HMAC(key, original): d51afc438daa955f2b3f0a267bf17a38f9a4caefdb80c051d95e06f4e2681afe
HMAC(key, forged): 608d841d5f983db31b05cfa5ec090e39b1829798b128b2321f3be8d3db3e7064
两者相同: 否(HMAC 安全)

注意:这个攻击之所以成功,根本原因是 SHA-256 的输出就是内部状态。知道 H(key||msg) 就等于知道了处理完 key||msg||padding 后的完整状态。攻击者只需计算 key||msg 的 Merkle-Damgård 填充(这只需要知道长度,不需要知道内容),然后以该状态为起点继续哈希附加数据。HMAC 的双层结构将密钥混入外层哈希,攻击者无法从最终输出恢复外层状态,因此天然免疫。

附.6 bcrypt 密码哈希#

密码存储与数据完整性校验的需求截然相反:完整性校验要快,密码哈希要慢。bcrypt 通过可调的 cost factor 和内置盐,让每一次验证都付出可控的计算代价。

import bcrypt
import time
password = b"MySecurePassword123!"
# 同一密码两次哈希——结果不同(盐不同)
hash1 = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
hash2 = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
print(f"原始密码: {password.decode()}")
print(f"第一次哈希: {hash1.decode()}")
print(f"第二次哈希: {hash2.decode()}")
print(f"两次不同: {'是(盐不同)' if hash1 != hash2 else '否'}")
# 验证正确密码
is_valid = bcrypt.checkpw(password, hash1)
print(f"\n--- 验证正确密码 ---")
print(f"结果: {' 密码正确' if is_valid else ' 密码错误'}")
# 验证错误密码
is_valid_wrong = bcrypt.checkpw(b"WrongPassword456!", hash1)
print(f"\n--- 验证错误密码 ---")
print(f"结果: {' 密码正确' if is_valid_wrong else ' 密码错误'}")
# 不同 cost factor 性能对比
print(f"\n--- 不同 cost factor 性能对比 ---")
print(f"{'Cost':>6s} | {'耗时':>10s}")
print(f"{'-'*6}-+-{'-'*10}")
for cost in [10, 12, 14]:
start = time.time()
bcrypt.hashpw(password, bcrypt.gensalt(rounds=cost))
elapsed = time.time() - start
print(f"{cost:>6d} | {elapsed:>9.4f}s")
# 解析 bcrypt 哈希格式
sample_hash = hash1.decode()
print(f"\n--- bcrypt 哈希格式解析 ---")
print(f"完整哈希: {sample_hash}")
print(f"算法标识: {sample_hash[:3]}")
print(f"代价因子: {sample_hash[4:6]} (2^{sample_hash[4:6]} = {2**int(sample_hash[4:6])} 轮)")
print(f"盐(22字符): {sample_hash[7:29]}")
print(f"哈希(31字符): {sample_hash[29:]}")
原始密码: MySecurePassword123!
第一次哈希: $2b$12$YfL2C1Ue.P6Ea3Jpesr5ZOGVliP4G9MpMNAUerLOlg3KJQxCDk5BS
第二次哈希: $2b$12$2Dr1RtB5OtjHisQLB//Bv.lZkjnuHnUUQ.rnR5mzR4lxozllKtij2
两次不同: 是(盐不同)
--- 验证正确密码 ---
结果: 密码正确
--- 验证错误密码 ---
结果: 密码错误
--- 不同 cost factor 性能对比 ---
Cost | 耗时
-------+----------
10 | 0.0529s
12 | 0.2143s
14 | 0.8942s
--- bcrypt 哈希格式解析 ---
完整哈希: $2b$12$YfL2C1Ue.P6Ea3Jpesr5ZOGVliP4G9MpMNAUerLOlg3KJQxCDk5BS
算法标识: $2b
代价因子: 12 (2^12 = 4096 轮)
盐(22字符): YfL2C1Ue.P6Ea3Jpesr5ZO
哈希(31字符): GVliP4G9MpMNAUerLOlg3KJQxCDk5BS

注:cost 每增加 1,计算时间约翻倍。cost=10 约 50ms,cost=12 约 200ms,cost=14 约 900ms。对于用户登录场景,建议 cost=12(约 200ms 延迟,用户几乎无感知,但 GPU 暴力破解速度从 SHA-256 的 10 亿次/秒降到约 1 万次/秒)。

附.7 实践小结#

场景推荐方案关键要点
数据完整性校验SHA-256 / SHA-512快速、广泛支持;SHA-3 在需要抗长度扩展时选用
文件发布校验SHA-256发布者公布哈希值,下载者本地验证
消息认证HMAC-SHA256密钥保护,常数时间比较,防御长度扩展
抗长度扩展HMAC 或 SHA-3绝不使用 H(key||msg) 构造
密码存储Argon2id > bcrypt慢哈希 + 盐 + 内存硬,cost 调至验证 ≥ 250ms
高性能场景BLAKE3比 SHA-3 快 3-4 倍,支持并行和增量更新

八、总结#

上一章剖析了非对称加密与椭圆曲线。

维度关键要点
SHA-2SHA-256 最广泛,Merkle-Damgård 结构,理解内部原理有助防御
SHA-3海绵结构,抗长度扩展,可扩展功能,SHA-2 的保险方案
HMAC密钥 + 哈希 = 消息认证,双层构造防御长度扩展,常数时间比较
碰撞攻击MD5/SHA-1 已攻破,Flame 恶意软件是实战案例
长度扩展SHA-2 受影响,用 HMAC 或 SHA-3 防御
密码存储Argon2id 推荐,慢哈希 + 内存硬抗暴力破解
真实应用Git、区块链、CAS、去重——哈希无处不在
算法选择决策表:
你的需求选择不要选择
数据完整性校验SHA-256MD5、SHA-1
消息认证HMAC-SHA256H(key || msg)
密码存储Argon2idSHA-256、MD5
数字签名SHA-256 + RSA/ECDSASHA-1
需要可变输出SHAKE256截断 SHA-256
高性能场景BLAKE3SHA-3
抗长度扩展SHA-3 或 HMACH(key || msg)
兼容旧系统PBKDF2MD5 + salt

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

哈希与消息认证码
https://blog.souloss.com/posts/cryptography/hash-and-mac/
作者
Souloss
发布于
2026-03-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时