密码学里最容易被混为一谈的三个概念,大概就是 Hash、MAC 和 HMAC 了。很多做了几年开发的工程师,在被问到"MD5 和 HMAC-SHA256 有什么区别"时,还是会卡壳。更常见的场景是:接口签名用了 MD5,觉得加了密钥就安全了,结果被人用长度扩展攻击打得措手不及。这三个东西名字长得像,用途也有重叠,但底层假设和安全边界完全不同。搞混它们的代价,轻则接口被人伪造请求,重则整个鉴权体系形同虚设。
这篇内容面向的是需要做接口签名、数据完整性校验、密码存储的开发者,也适合正在学密码学基础、准备面试或者打 CTF 的朋友。我会从"它们各自解决什么问题"入手,把三者的设计动机、内部结构、安全边界讲清楚,再落到实际代码里怎么选、怎么用、哪里容易踩坑。读完你应该能明确回答:什么时候用裸 Hash,什么时候必须上 HMAC,以及为什么 MAC 不等于 HMAC。
1. 从三个真实场景看这三个概念到底在解决什么
1.1 场景一:下载文件后校验完整性,为什么只需要 Hash
你从某个镜像站下载了一个系统镜像,站点旁边贴了一串 SHA256 值。你下载完在终端跑一句sha256sum image.iso,对比一下结果一致,就认为文件没被篡改。这个场景里用的就是裸 Hash。
Hash 函数的核心能力是把任意长度的输入压缩成固定长度的输出,且这个过程不可逆、抗碰撞。它不需要密钥,任何人拿到同样的输入都能算出同样的输出。所以它天然适合做"完整性校验"——只要原始摘要值是通过可信渠道拿到的(比如官网 HTTPS 页面),你本地算出来的值对得上,就说明文件在传输过程中没被改动。
但这里有个关键前提:摘要值本身必须来自可信渠道。如果攻击者能同时篡改文件和页面上的摘要值,那这个校验就毫无意义。这正是裸 Hash 的边界——它只能防"传输意外损坏",防不了"主动篡改"。
1.2 场景二:接口签名防伪造,为什么裸 Hash 会出事
假设你设计了一个 API 签名方案:把所有参数按字典序拼起来,末尾加上一个 secret,然后算 MD5,把结果作为 sign 传给服务端。服务端用同样的方式算一遍对比。看起来加了密钥,应该安全了吧?
问题在于,MD5 和 SHA1、SHA256 这类 Merkle–Damgård 结构的 Hash 都存在长度扩展攻击(Length Extension Attack)。攻击者即使不知道你的 secret,只要知道MD5(secret + data)的结果和secret的长度,就能在不知道 secret 的情况下,构造出MD5(secret + data + padding + evil_data)的合法签名。因为这类 Hash 在计算时是把数据分块迭代的,前一块的输出直接作为下一块的初始状态,攻击者可以"接着算"。
这就是为什么"Hash + 密钥"这种土办法不能当 MAC 用。你需要的是一个从设计上就抵抗这类攻击的结构,于是 HMAC 登场了。
1.3 场景三:消息认证码 MAC,它到底认证了什么
MAC(Message Authentication Code)是一个更抽象的概念:用密钥对消息生成一个认证标签,验证方用同一密钥验证标签是否合法。它同时保证了完整性(消息没被改)和真实性(消息确实来自持有密钥的一方)。
MAC 是一个"目标",不是一个具体算法。HMAC 是实现 MAC 的一种具体构造方式,CMAC、GMAC、Poly1305 也都是 MAC 的具体实现。所以严格来说,"MAC 和 HMAC 的区别"这个问法本身有点问题——HMAC 是 MAC 的一个子集。真正该问的是"HMAC 相比其他 MAC 构造有什么特点"。
把这三个概念的关系理一下:
| 概念 | 是否需要密钥 | 核心目标 | 典型算法 |
|---|---|---|---|
| Hash | 否 | 完整性(防意外损坏) | MD5、SHA1、SHA256、SM3 |
| MAC | 是 | 完整性 + 真实性 | HMAC、CMAC、GMAC、Poly1305 |
| HMAC | 是 | 完整性 + 真实性 | HMAC-SHA256、HMAC-SM3 |
理解了这张表,后面所有的细节都是围绕"为什么需要密钥""密钥怎么用才安全"展开的。
2. Hash 函数的内部结构决定了它的安全边界
2.1 Merkle–Damgård 结构:为什么会有长度扩展攻击
MD5、SHA1、SHA256 都采用 Merkle–Damgård 迭代结构。它的工作方式是这样的:先把消息填充到块大小的整数倍(填充规则通常是补一个 1、若干 0、再附上原始长度),然后分块送入压缩函数,每一块的输出作为下一块的输入状态,最后一块的输出就是最终摘要。
这个设计有个副作用:最终摘要实际上就是压缩函数在某个中间状态上的输出。如果攻击者知道H(secret + data)的值,他就知道了压缩函数处理完secret + data + padding之后的状态。他可以把这个状态当作初始值,继续喂入自己想追加的数据,算出新的合法摘要。整个过程不需要知道 secret 的内容。
我用一段伪代码说明攻击者的思路:
# 正常流程 digest = SHA256(secret + data) # 攻击者已知 digest 和 len(secret),想伪造 secret + data + evil 的签名 # 1. 把 digest 当作 SHA256 的初始状态(需要能控制 IV) # 2. 从 padding 之后的位置继续喂入 evil # 3. 得到 forged_digest = SHA256_state(digest, evil) # 4. 发送 data + padding + evil 和 forged_digest,服务端验证会通过实际攻击中,攻击者需要能控制 Hash 函数的初始向量(IV),这在标准库调用里通常做不到,但攻击者可以自己实现一份 Hash 逻辑,把 digest 塞进去当 IV。所以这个攻击在理论上是完全可行的,历史上也真实发生过(比如 Flickr 的 API 签名被绕过)。
2.2 SHA3 和 SM3:不同结构带来的不同特性
SHA3 用的是海绵结构(Sponge Construction),和 Merkle–Damgård 完全不同。它维护一个大的状态,通过"吸收"(absorb)输入、"挤出"(squeeze)输出。这种结构天然抵抗长度扩展攻击,因为攻击者无法从输出反推内部状态。
SM3 是国密杂凑算法,输出 256 位,结构上接近 SHA256,同样属于 Merkle–Damgård 家族,所以理论上也存在长度扩展攻击的风险。这也是为什么国密体系里做消息认证要用 HMAC-SM3,而不是直接 SM3(secret + data)。
这里给一个实用的判断原则:
只要你的场景里"密钥"和"消息"是拼接后一起 Hash 的,就要警惕长度扩展攻击。正确做法是用 HMAC,或者用 SHA3 这类抗长度扩展的结构。
2.3 密码存储为什么不能用裸 Hash
很多人把用户密码做一次 MD5 存进数据库,觉得"反正是不可逆的"。但现代 GPU 每秒能算几十亿次 MD5,配合彩虹表,弱密码几乎瞬间被还原。即使加盐(salt),如果盐是固定的、迭代次数是 1,依然扛不住暴力破解。
密码存储的正确做法是用慢哈希:PBKDF2、bcrypt、scrypt、Argon2。它们的核心思路是故意把计算变慢(通过大量迭代或内存占用),让暴力破解的成本高到不可接受。这跟 Hash 用于完整性校验的场景完全不同——那里追求的是快,这里追求的是慢。
| 用途 | 推荐算法 | 关键参数 |
|---|---|---|
| 文件完整性校验 | SHA256、SHA3、SM3 | 无 |
| 密码存储 | Argon2id、bcrypt、scrypt | 迭代次数、内存、并行度 |
| 消息认证 | HMAC-SHA256、HMAC-SM3 | 密钥长度 ≥ 256 位 |
3. HMAC 的构造:为什么它比"Hash 加盐"靠谱
3.1 HMAC 的两层嵌套结构拆解
HMAC 的标准定义是:
HMAC(K, m) = H((K' ⊕ opad) || H((K' ⊕ ipad) || m))其中 K' 是把密钥 K 填充或 Hash 到块大小后的结果,ipad 是 0x36 重复块大小次,opad 是 0x5C 重复块大小次。
拆开看就是两层:
- 内层:
H((K' ⊕ ipad) || m),把密钥和一个固定常量异或后,拼在消息前面做一次 Hash。 - 外层:把内层的结果,拼在
K' ⊕ opad后面再做一次 Hash。
这个设计精妙的地方在于:外层 Hash 把内层的输出"包"了起来。攻击者即使能对内层做长度扩展,也无法影响外层的计算,因为外层的输入是内层的完整输出,攻击者不知道内层的密钥相关状态。两层嵌套彻底堵死了长度扩展攻击的路。
3.2 密钥长度和填充规则的实际影响
HMAC 对密钥长度有明确处理规则:
- 如果密钥长度等于块大小(SHA256 是 64 字节),直接用。
- 如果密钥长度大于块大小,先对密钥做一次 Hash,把结果当密钥。
- 如果密钥长度小于块大小,右侧补 0 到块大小。
这个规则意味着,HMAC 的密钥长度超过块大小并不会带来额外安全性。比如你用 128 字节的密钥做 HMAC-SHA256,它会被 Hash 成 32 字节再用。所以密钥长度选 32 字节(256 位)就够了,再长是浪费。
实际工程里我见过有人用超长密钥"图个安心",其实没必要。真正影响安全性的是密钥的随机性,而不是长度。用os.urandom(32)或secrets.token_bytes(32)生成的随机密钥,比一个 200 字节的弱口令强得多。
3.3 一个完整的 HMAC 签名与验证实现
下面用 Python 写一个接口签名的完整例子,包含防重放的时间戳和随机数:
import hmac import hashlib import time import secrets SECRET_KEY = secrets.token_bytes(32) # 实际部署时从安全配置读取 def sign_request(params: dict) -> dict: # 1. 加入时间戳和随机数,防重放 params['timestamp'] = str(int(time.time())) params['nonce'] = secrets.token_hex(16) # 2. 按字典序拼接 sorted_items = sorted(params.items()) message = '&'.join(f'{k}={v}' for k, v in sorted_items) # 3. HMAC-SHA256 计算签名 signature = hmac.new( SECRET_KEY, message.encode('utf-8'), hashlib.sha256 ).hexdigest() params['sign'] = signature return params def verify_request(params: dict, max_age: int = 300) -> bool: received_sign = params.pop('sign', None) if not received_sign: return False # 1. 校验时间戳,防止旧请求重放 try: ts = int(params.get('timestamp', 0)) except ValueError: return False if abs(time.time() - ts) > max_age: return False # 2. 重新计算签名 sorted_items = sorted(params.items()) message = '&'.join(f'{k}={v}' for k, v in sorted_items) expected = hmac.new( SECRET_KEY, message.encode('utf-8'), hashlib.sha256 ).hexdigest() # 3. 常量时间比较,防时序攻击 return hmac.compare_digest(received_sign, expected)这段代码里有三个关键点值得展开:
第一,用hmac.compare_digest而不是==。普通的字符串比较是短路比较,遇到第一个不同的字符就返回,攻击者可以通过测量响应时间逐字节猜出正确签名。compare_digest是常量时间比较,无论内容如何,耗时都一样。
第二,时间戳窗口不能太大。我一般设 5 分钟,太短会导致客户端时钟偏差误判,太长会给重放攻击留窗口。配合 nonce 做服务端去重(比如存 Redis 设 5 分钟过期),可以进一步收紧。
第三,参数拼接规则必须严格统一。空值、数组、嵌套对象怎么处理,客户端和服务端必须完全一致,否则会出现"客户端算的签名服务端验不过"的经典问题。我踩过最坑的一次是 URL 编码:客户端对参数做了 URL encode 再拼接,服务端用原始值拼接,结果中文参数全部验签失败。
4. 选型实战:什么场景该用哪个
4.1 一张决策表帮你快速定位
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 文件下载校验 | SHA256 / SM3 | 无需密钥,快,够用 |
| 接口签名 | HMAC-SHA256 | 抗长度扩展,密钥可控 |
| 密码存储 | Argon2id / bcrypt | 慢哈希,抗暴力破解 |
| 数据库字段防篡改 | HMAC-SHA256 | 需要密钥,防内部篡改 |
| JWT 签名 | HMAC-SHA256 或 RS256 | HS256 对称,RS256 非对称 |
| 消息队列消息认证 | HMAC-SHA256 | 高性能,标准化 |
| 硬件资源受限 | CMAC-AES | 复用 AES 硬件加速 |
这张表不是绝对的,但覆盖了 90% 的日常场景。选型时先问自己两个问题:需不需要密钥?需不需要防主动篡改?两个都是"是",就上 HMAC;只有第二个是"否",裸 Hash 就够。
4.2 JWT 里 HS256 和 RS256 的取舍
JWT 的签名算法选择是个高频争议点。HS256 就是 HMAC-SHA256,对称密钥,签发和验证用同一个密钥。RS256 是 RSA 非对称签名,私钥签发、公钥验证。
什么时候用 HS256?单体应用或内部服务之间,签发方和验证方是同一套系统,共享密钥没有额外风险,HS256 性能更好、实现更简单。
什么时候用 RS256?签发方和验证方是不同信任域。比如你做了一个开放平台,第三方需要验证你签发的 token,但你不想把密钥给他们(给了他们就能伪造 token)。这时候用 RS256,公钥可以随便分发,私钥只有你自己持有。
我见过一个典型的坑:某团队用 HS256,然后把密钥硬编码在客户端 SDK 里。任何人反编译 SDK 就能拿到密钥,进而伪造任意用户的 token。这种场景必须用 RS256。
4.3 国密场景下 SM3 和 HMAC-SM3 的配合
在需要符合国密规范的场景里,杂凑用 SM3,消息认证用 HMAC-SM3。SM3 输出 256 位,块大小 64 字节,和 SHA256 参数一致,所以 HMAC 的构造方式完全一样,只是把底层 Hash 换成 SM3。
# 伪代码示意,实际需用支持 SM3 的库如 gmssl from gmssl import sm3 def hmac_sm3(key: bytes, message: bytes) -> bytes: block_size = 64 if len(key) > block_size: key = sm3.sm3_hash(key) key = key.ljust(block_size, b'\x00') ipad = bytes(b ^ 0x36 for b in key) opad = bytes(b ^ 0x5C for b in key) inner = sm3.sm3_hash(ipad + message) outer = sm3.sm3_hash(opad + inner) return outer注意 SM3 本身是 Merkle–Damgård 结构,所以绝对不能用SM3(secret + data)当 MAC,必须用 HMAC-SM3。这一点在国密改造项目里经常被忽略,我见过直接把 SM3 拼接密钥当签名用的实现,安全性等同于裸 Hash 加盐。
5. 那些年我踩过的坑和排查思路
5.1 签名验不过?先查这五个地方
接口签名验不过是最常见的联调问题,我总结了一个排查顺序,基本能覆盖 95% 的情况:
- 参数拼接顺序:确认双方都是按字典序(或约定的顺序)拼接,大小写敏感。
- 空值和特殊字符:空字符串、null、数组、中文、特殊符号的处理规则是否一致。
- 编码方式:UTF-8 还是 GBK,URL encode 在哪一步做。
- 密钥是否一致:有没有多余的空格、换行,是不是从配置文件读的时候带了引号。
- 时间戳和 nonce:是不是时间窗口过期了,nonce 是不是被服务端判重了。
我遇到过一次特别隐蔽的:客户端用 Python 的dict拼接,服务端用 Java 的TreeMap,两边对数字类型参数的处理不同——Python 把1和1.0当不同值,Java 统一成1。结果某个参数传浮点数时签名就对不上。后来统一规定所有参数先转字符串再拼接才解决。
5.2 长度扩展攻击的真实复现
为了让你直观感受这个攻击,我用 Python 演示一下对MD5(secret + data)的攻击思路(仅用于理解原理):
# 假设攻击者已知: # - 原始消息 data = "amount=100" # - 签名 sig = MD5(secret + data) # - secret 长度 = 8(通过其他途径推测) # 攻击者想伪造 data + padding + "&amount=9999" 的签名 # 利用 hashpumpy 这类工具可以自动完成 import hashpumpy original_sig = "已知的MD5值" original_data = "amount=100" append_data = "&amount=9999" secret_len = 8 new_sig, new_data = hashpumpy.hashpump( original_sig, original_data, append_data, secret_len ) # new_data 就是 data + padding + append_data # new_sig 就是服务端会认可的合法签名防御方法只有一个:用 HMAC。或者用 SHA3 这类抗长度扩展的结构。没有别的捷径。
5.3 时序攻击:一个容易被忽略的侧信道
前面提到用compare_digest,这里展开说一下为什么。假设验证逻辑是:
if received_sign == expected_sign: return TruePython 的字符串比较是逐字节的,遇到不同就返回 False。攻击者发送大量请求,测量响应时间,就能推断出前几个字节是否正确。虽然网络抖动会干扰,但通过大量采样统计,依然能还原出完整签名。
防御很简单:用常量时间比较函数。Python 用hmac.compare_digest,Java 用MessageDigest.isEqual,Go 用subtle.ConstantTimeCompare。这个细节在安全审计里经常被点名,值得养成习惯。
5.4 密钥管理:比算法更容易出事的地方
算法选对了,密钥管理翻车,一样白搭。我见过的问题包括:
- 密钥硬编码在代码里,提交到了 Git 仓库。
- 密钥在多个环境(开发、测试、生产)复用。
- 密钥轮换时没有过渡期,导致旧客户端全部验签失败。
- 密钥存在配置文件里,权限没控制好,被低权限进程读到。
我的做法是:密钥从环境变量或密钥管理服务读取,不同环境不同密钥,轮换时支持双密钥并行验证(新签名用新密钥,验证时新旧都试),给客户端留出升级窗口。这些看起来是运维细节,但实际决定了整个签名体系能不能长期稳定运行。
6. 把三个概念串起来:一张图看清它们的层次关系
6.1 从"目标"到"实现"的层次
如果非要用一句话概括三者的关系:Hash 是基础工具,MAC 是安全目标,HMAC 是实现 MAC 的一种标准方法。
- Hash 提供的是"压缩 + 不可逆",本身不涉及密钥,所以只能做完整性校验。
- MAC 引入了密钥,把"完整性"升级成"完整性 + 真实性",但 MAC 是概念,不是算法。
- HMAC 用两层嵌套的 Hash 构造,把 Hash 变成了一个安全的 MAC,是工程上最常用的 MAC 实现。
理解了这层关系,你就不会再有"MAC 和 HMAC 有什么区别"这种困惑了——它们不在一个层次上。
6.2 对称加密体系里的位置
在对称加密体系里,加密(如 AES)解决的是机密性,MAC 解决的是完整性和真实性。两者经常配合使用,比如 AES-GCM 就是"加密 + 认证"一体的模式,内部用 GHASH 做认证。而 HMAC 更多用在"只认证不加密"的场景,比如接口签名、JWT。
这里有个经典的安全原则:先加密再 MAC,还是先 MAC 再加密?这个问题的标准答案是"先加密再 MAC"(Encrypt-then-MAC),因为这样 MAC 覆盖了密文,攻击者无法通过篡改密文来影响解密结果。如果反过来,可能引入 padding oracle 之类的攻击。不过现代推荐直接用 AEAD 模式(如 AES-GCM、ChaCha20-Poly1305),把加密和认证打包解决,避免自己组合出错。
6.3 给不同基础读者的学习路径
如果你是刚接触密码学的开发者,建议按这个顺序深入:
- 先把 Hash 的性质搞清楚:抗碰撞、抗原像、雪崩效应。
- 理解长度扩展攻击的原理,这是理解 HMAC 为什么存在的关键。
- 动手实现一遍 HMAC,不要只用库函数,自己按公式写一遍。
- 研究 AEAD 模式,理解现代密码学"加密和认证不可分割"的设计哲学。
- 最后看密钥管理、侧信道防护这些工程细节。
如果你是为了面试或 CTF,重点放在长度扩展攻击、HMAC 构造、时序攻击这几个高频考点上。CTF 里经常出现"给了 MD5(secret + data) 让你伪造签名"的题目,识别出长度扩展攻击就能秒解。
最后分享一个我自己的习惯:每次设计签名方案时,先在纸上把"攻击者能拿到什么、能控制什么、想达到什么目的"列清楚,再对照 HMAC 的安全假设检查一遍。这个习惯帮我避开了好几次潜在的设计缺陷。密码学这东西,算法本身往往是安全的,出问题的永远是使用方式。