当你下载一个 Ubuntu ISO 镜像时,页面上总会附一串 SHA-256 校验值——怎么确认文件没有被篡改?当你登录网站时,数据库里存的是密码原文还是一串乱码?如果攻击者拿到了数据库,你的密码还安全吗? 在 非对称加密 中理解了 RSA 与 ECC。密码学的第三根支柱——哈希函数与消息认证码——看起来最不起眼,却无处不在:Git 的 commit hash、区块链的区块哈希、密码的 bcrypt 存储、HMAC 签名……它们不加密任何数据,却默默守护着完整性和认证。
一、哈希函数基础
1.1 什么是哈希函数?
哈希函数将任意长度的输入映射为固定长度的输出(摘要):
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 | 输出与随机数不可区分 | 看起来像噪声 | 信息泄露 |
抗碰撞 ⟹ 第二原像抗性,但抗碰撞并不直接蕴含原像抗性。三者需要独立评估。
1.3 哈希 vs 加密
| 维度 | 哈希 | 加密 |
|---|---|---|
| 方向性 | 单向,不可逆 | 双向,可解密 |
| 密钥 | 无需密钥 | 必须有密钥 |
| 输出长度 | 固定 | 与输入相关 |
| 典型用途 | 完整性、认证 | 机密性 |
| 举例 | 指纹——只能采集,不能还原人 | 保险箱——有钥匙能打开 |
1.4 密码学哈希 vs 普通哈希
| 维度 | 密码学哈希 | 普通哈希 |
|---|---|---|
| 用途 | 安全认证 | 数据结构 |
| 抗碰撞 | 必须 | 不需要 |
| 单向性 | 必须 | 不需要 |
| 速度 | 故意慢(密码存储) | 越快越好 |
| 典型算法 | SHA-256, bcrypt | MurmurHash, xxHash |
二、SHA-2 与 SHA-3
2.1 SHA-2 家族
| 算法 | 输出长度 | 分组大小 | 安全等级 | 用途 |
|---|---|---|---|---|
| SHA-224 | 224 位 | 512 位 | 112 位 | 兼容 |
| SHA-256 | 256 位 | 512 位 | 128 位 | 最广泛使用 |
| SHA-384 | 384 位 | 1024 位 | 192 位 | 高安全 |
| SHA-512 | 512 位 | 1024 位 | 256 位 | 最高安全 |
# Python: SHA-256import hashlib
digest = hashlib.sha256(b"Hello, World!").hexdigest()# 输出: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
# SHA-512digest512 = hashlib.sha512(b"Hello, World!").hexdigest()// Java: SHA-256MessageDigest digest = MessageDigest.getInstance("SHA-256");byte[] hash = digest.digest("Hello, World!".getBytes(StandardCharsets.UTF_8));2.2 SHA-256 内部结构
SHA-256 基于 Merkle-Damgård 结构,核心是消息调度和压缩函数。理解其内部原理有助于理解后续的长度扩展攻击:
- 消息填充:将消息填充到 512 位的整数倍,填充内容包含原始消息长度
- 消息调度(Message Schedule):将 512 位分组扩展为 64 个 32 位字 W[0..63]
- 压缩函数:64 轮运算,每轮使用不同的轮常数和消息字
- 累加:将压缩结果与当前哈希状态相加
消息调度扩展:前 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-2 | SHA-3 |
|---|---|---|
| 结构 | Merkle-Damgård | 海绵结构 |
| 安全证明 | 无形式化证明 | 可证明安全 |
| 长度扩展攻击 | 受影响 | 不受影响 |
| 可扩展功能 | 有限 | XOF、SHAKE、KMAC |
# Python: SHA-3import 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),分为吸收和挤出两个阶段:
| 参数 | 说明 |
|---|---|
| 吸收率(r) | 每轮吸收的比特数 |
| 容量(c) | 安全性参数,c = 2×安全等级 |
| 状态大小 | r + c = 1600 位(SHA-3) |
| 置换函数 f | Keccak-f[1600],24 轮非线性置换 |
海绵结构的关键优势:输出长度可变,只需继续”挤出”即可。这正是 SHAKE 函数的基础。
2.5 性能对比
| 算法 | 输出长度 | 速度(MB/s) | 硬件加速 | 推荐场景 |
|---|---|---|---|---|
| SHA-256 | 256 位 | ~600 | Intel SHA-NI | 通用首选 |
| SHA-512 | 512 位 | ~900 | 无 | 64 位平台 |
| SHA3-256 | 256 位 | ~400 | 无 | 抗长度扩展 |
| BLAKE2b | 256/512 位 | ~1000 | 无 | 高性能场景 |
| BLAKE3 | 256 位 | ~1500 | SIMD | 极致性能 |
SHA-256 在支持 SHA-NI 指令集的处理器上速度可提升 5-10 倍。在大多数场景中,SHA-256 仍是性能和安全的最佳平衡点。
三、HMAC 消息认证码
3.1 为什么需要 HMAC?
哈希函数只能验证完整性,不能验证消息来源——攻击者可以同时篡改消息和哈希值。HMAC 使用密钥来认证消息:
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 次为什么 HMAC 比 H(key||message) 更安全?
| 构造方式 | 安全性 | 原因 |
|---|---|---|
H(key || message) | 不安全 | 受长度扩展攻击 |
H(message || key) | 不安全 | 内部碰撞攻击 |
H(key || H(key || message)) | 可用 | 可防御长度扩展,但无证明 |
| HMAC | 安全 | 有可证明安全性 |
HMAC 的双层哈希确保:内层将变长消息压缩为固定长度,外层防止长度扩展攻击,ipad/opad 不同确保内外层使用不同”密钥”。
# Python: HMAC-SHA256import hmacimport hashlib
key = b"secret-key"message = b"Hello, World!"
# 生成 HMACmac = hmac.new(key, message, hashlib.sha256).hexdigest()
# 验证 HMAC(常数时间比较)received_mac = "..."hmac.compare_digest(mac, received_mac) # 安全# mac == received_mac # 不安全(时序攻击)// Go: HMAC-SHA256package 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)JWT 的 HMAC 密钥必须足够长且随机。建议至少 256 位(32 字节)随机数据。弱密钥可被离线暴力破解,伪造任意 JWT。
3.4 HMAC vs 其他 MAC
| MAC 类型 | 基于算法 | 速度 | 并行 | 安全 |
|---|---|---|---|---|
| HMAC-SHA256 | SHA-256 | 快 | 否 | 高 |
| Poly1305 | 一次性密钥 | 极快 | 否 | 高 |
| GMAC | AES + GHASH | 快(需 AES-NI) | 是 | 高 |
| KMAC | SHA-3/Keccak | 中 | 是 | 高 |
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) = h | 2^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 证书 | 实际演示 |
| 2012 | Flame 恶意软件 | 实际攻击 |
# 演示:MD5 碰撞的严重性import hashlib
# 两个不同的输入产生相同的 MD5(概念演示)m1 = bytes.fromhex("d131dd02c5e6eec4693d9a0698aff95c...")m2 = bytes.fromhex("d131dd02c5e6eec4693d9a0698aff95c...")
# m1 != m2,但 MD5(m1) == MD5(m2)assert m1 != m2assert hashlib.md5(m1).hexdigest() == hashlib.md5(m2).hexdigest()4.4 SHA-1 碰撞——SHAttered 攻击
2017 年,Google 和 CWI Amsterdam 联合发布了 SHAttered 攻击,首次实现 SHA-1 实际碰撞:
| 年份 | 事件 | 影响 |
|---|---|---|
| 2005 | 理论攻击 | 2^63 复杂度 |
| 2017 | SHAttered 攻击 | 实际碰撞演示,约 6500 CPU 年 |
| 2020 | 选择前缀碰撞 | 可伪造证书,约 9000 GPU 年 |
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 更快 |
| 需要可变输出 | SHAKE256 | XOF 灵活输出 |
五、长度扩展攻击
5.1 攻击原理
Merkle-Damgård 结构的哈希函数(SHA-2)存在长度扩展攻击:
已知:H(m) 和 len(m)可以计算:H(m || padding || m') 而不需要知道 m!为什么可能?因为 SHA-2 的输出就是内部状态——知道 H(m) 就等于知道了处理完消息 m 后的完整内部状态。攻击者可以直接将这个状态作为初始状态,继续处理附加消息 m’。
5.2 长度扩展攻击实战
# 长度扩展攻击演示(使用 hashpumpy 库)# pip install hashpumpyimport 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 的双层结构天然防御长度扩展攻击:
- 内层:
H(K'⊕ipad || m)— 即使攻击者知道内层输出,也无法扩展 - 外层:
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") # Trueexcept 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 密码存储最佳实践
密码存储清单——每一条都必须做到:
密码存储清单——每一条都必须做到:
- 使用专用密码哈希:Argon2id > scrypt > bcrypt > PBKDF2,绝不使用 SHA-256
- 每个密码独立加盐:盐至少 16 字节随机数,防止彩虹表攻击
- 参数足够高:目标验证时间 ≥ 250ms(用户登录场景)
- 常数时间比较:验证时使用常数时间函数,防止时序攻击
- pepper 机制:在哈希前额外混入服务器端密钥,数据库泄露后仍需额外信息
- 密码迁移策略:用户登录时自动升级旧哈希到新算法
- 限制登录尝试:防止在线暴力破解
- 不存原文:绝不存储密码明文或可逆加密
七、哈希在真实系统中的应用
哈希函数远不止”校验文件完整性”——它们是现代计算基础设施的基石。
7.1 Git 提交哈希
Git 使用 SHA-1(正在迁移到 SHA-256)作为内容寻址标识。每个对象的哈希值就是它的唯一标识:任何修改都会改变哈希值,相同内容始终产生相同哈希(去重),哈希值本身就是完整性证明。
7.2 区块链
区块链的核心数据结构就是哈希链——每个区块包含前一个区块的哈希:
修改任何一个区块,后续所有区块的哈希都会改变——这就是区块链不可篡改性的基础。
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)
- 标准库:
hashlib、hmac、struct;第三方库:bcrypt(pip install bcrypt) - 理解本章前七节的理论内容,尤其是 Merkle-Damgård 结构和 HMAC 构造
附.2 SHA-2 与 SHA-3 哈希计算
同一输入,不同算法,输出完全不同——这正是哈希函数设计的目标:不同算法之间没有任何关联性。
import hashlib
message = b"Hello, Cryptography!"
# SHA-256sha256 = hashlib.sha256(message).hexdigest()print(f"SHA-256: {sha256}")
# SHA-512sha512 = hashlib.sha512(message).hexdigest()print(f"SHA-512: {sha512}")
# SHA3-256sha3_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: 29aff889935f5a275ec562ef46c138e917a270aab79b4d5577ee8ea5af308f73SHA-512: c3528d42b472281ab0ee4ad4410dbe351eefda688dff8550c9dfcc7b29a2bc1b70c1fcfa9f3d1f552e928f7a97b02b2b50dd675b1e17019b383c0162655c6918SHA3-256: f2d6e73d4c107550fae2b9a08e2f85c820e5811c0ea37cc9a412df33b195f904
--- 雪崩效应 ---SHA-256('...y!'): 29aff889935f5a275ec562ef46c138e917a270aab79b4d5577ee8ea5af308f73SHA-256('...?'): 1084ad7edc449759443809509c3e4a87f64c4ea206d5900e997859877e26814e不同位数: 151/256 (59.0%)注:雪崩效应的理想值是 50%(128/256 位),实际 59.0% 在统计波动范围内。仅改变一个字符
!→?,输出就有超过一半的位发生变化。
附.3 文件完整性校验
文件完整性校验是哈希函数最古老也最直观的应用:发布者公布哈希值,下载者本地计算哈希值,两者一致则文件未被篡改。
import hashlibimport tempfileimport 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 : 05bf53ced9b5a06bc9fdc8abfc5d8dacSHA1 : 14382d3c827a478f38d0626476f37e64666c0c74SHA256 : cb470cc7dff2ff61629f30749e41a73c7d16af67603783578f7050a3940f6025SHA512 : ed4c0ca7f34efbe36364f67275537a6b29297dd048b95cd2d8c928056a02e20ae08455da3e95a90c3f58bf645575f20a54755f2476714b7d5222d1ffb6ecd282注意:MD5 和 SHA-1 已被攻破,仅用于遗留系统兼容。新系统必须使用 SHA-256 或更强的算法。上面的对比只是为了展示不同算法的输出长度差异。
附.4 HMAC 签名与验证
哈希只能验证完整性,不能验证来源——攻击者可以同时篡改消息和哈希值。HMAC 通过引入密钥解决了这个问题:没有密钥就无法伪造合法的 MAC 值。
import hmacimport 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 ' 签名无效(消息被篡改)'}")
# 不同算法的 HMACprint(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 12345HMAC-SHA256: b6c909a9cd218c40a881e7de606f9b633a709befc225eb1210b380e39d35c753
--- 验证(正确密钥) ---验证结果: 签名有效
--- 验证(错误密钥) ---错误密钥: wrong-secret-key验证结果: 签名无效(密钥不匹配)
--- 验证(篡改消息) ---篡改消息: Transfer $9000 to account 12345验证结果: 签名无效(消息被篡改)
--- 不同算法的 HMAC ---HMAC-MD5 : d1e2ea83d3cb132d0227cdd3468a6adfHMAC-SHA1 : 4d6ea70b9e3b1cf7e704db516270d444430a4052HMAC-SHA256: b6c909a9cd218c40a881e7de606f9b633a709befc225eb1210b380e39d35c753HMAC-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 hashlibimport 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 hmachmac_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=helloH(key||msg): 4d554309dc24740b5d3e90c69180195d0c436943bc6f82ad819fa9b648d5be02
[攻击者已知]原始消息: data=helloH(key||msg): 4d554309dc24740b5d3e90c69180195d0c436943bc6f82ad819fa9b648d5be02key 长度: 14 字节
[攻击者构造]追加数据: &admin=true伪造哈希: 7070b7b023e4cf47ea8efcbfcf0792f0a928c2a284f019de7e906bf6690dff45
[验证]服务器计算 H(key||forged_msg): 7070b7b023e4cf47ea8efcbfcf0792f0a928c2a284f019de7e906bf6690dff45攻击者提供的哈希: 7070b7b023e4cf47ea8efcbfcf0792f0a928c2a284f019de7e906bf6690dff45是否匹配: 匹配!攻击成功!
[HMAC 对比]HMAC(key, original): d51afc438daa955f2b3f0a267bf17a38f9a4caefdb80c051d95e06f4e2681afeHMAC(key, forged): 608d841d5f983db31b05cfa5ec090e39b1829798b128b2321f3be8d3db3e7064两者相同: 否(HMAC 安全)注意:这个攻击之所以成功,根本原因是 SHA-256 的输出就是内部状态。知道
H(key||msg)就等于知道了处理完key||msg||padding后的完整状态。攻击者只需计算key||msg的 Merkle-Damgård 填充(这只需要知道长度,不需要知道内容),然后以该状态为起点继续哈希附加数据。HMAC 的双层结构将密钥混入外层哈希,攻击者无法从最终输出恢复外层状态,因此天然免疫。
附.6 bcrypt 密码哈希
密码存储与数据完整性校验的需求截然相反:完整性校验要快,密码哈希要慢。bcrypt 通过可调的 cost factor 和内置盐,让每一次验证都付出可控的计算代价。
import bcryptimport 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-2 | SHA-256 最广泛,Merkle-Damgård 结构,理解内部原理有助防御 |
| SHA-3 | 海绵结构,抗长度扩展,可扩展功能,SHA-2 的保险方案 |
| HMAC | 密钥 + 哈希 = 消息认证,双层构造防御长度扩展,常数时间比较 |
| 碰撞攻击 | MD5/SHA-1 已攻破,Flame 恶意软件是实战案例 |
| 长度扩展 | SHA-2 受影响,用 HMAC 或 SHA-3 防御 |
| 密码存储 | Argon2id 推荐,慢哈希 + 内存硬抗暴力破解 |
| 真实应用 | Git、区块链、CAS、去重——哈希无处不在 |
| 算法选择决策表: |
| 你的需求 | 选择 | 不要选择 |
|---|---|---|
| 数据完整性校验 | SHA-256 | MD5、SHA-1 |
| 消息认证 | HMAC-SHA256 | H(key || msg) |
| 密码存储 | Argon2id | SHA-256、MD5 |
| 数字签名 | SHA-256 + RSA/ECDSA | SHA-1 |
| 需要可变输出 | SHAKE256 | 截断 SHA-256 |
| 高性能场景 | BLAKE3 | SHA-3 |
| 抗长度扩展 | SHA-3 或 HMAC | H(key || msg) |
| 兼容旧系统 | PBKDF2 | MD5 + salt |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






