密码学里最容易被混为一谈的三个概念大概就是 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它到底认证了什么MACMessage Authentication Code是一个更抽象的概念用密钥对消息生成一个认证标签验证方用同一密钥验证标签是否合法。它同时保证了完整性消息没被改和真实性消息确实来自持有密钥的一方。MAC 是一个目标不是一个具体算法。HMAC 是实现 MAC 的一种具体构造方式CMAC、GMAC、Poly1305 也都是 MAC 的具体实现。所以严格来说MAC 和 HMAC 的区别这个问法本身有点问题——HMAC 是 MAC 的一个子集。真正该问的是HMAC 相比其他 MAC 构造有什么特点。把这三个概念的关系理一下概念是否需要密钥核心目标典型算法Hash否完整性防意外损坏MD5、SHA1、SHA256、SM3MAC是完整性 真实性HMAC、CMAC、GMAC、Poly1305HMAC是完整性 真实性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 或 RS256HS256 对称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 还是 GBKURL encode 在哪一步做。密钥是否一致有没有多余的空格、换行是不是从配置文件读的时候带了引号。时间戳和 nonce是不是时间窗口过期了nonce 是不是被服务端判重了。我遇到过一次特别隐蔽的客户端用 Python 的dict拼接服务端用 Java 的TreeMap两边对数字类型参数的处理不同——Python 把1和1.0当不同值Java 统一成1。结果某个参数传浮点数时签名就对不上。后来统一规定所有参数先转字符串再拼接才解决。5.2 长度扩展攻击的真实复现为了让你直观感受这个攻击我用 Python 演示一下对MD5(secret data)的攻击思路仅用于理解原理# 假设攻击者已知 # - 原始消息 data amount100 # - 签名 sig MD5(secret data) # - secret 长度 8通过其他途径推测 # 攻击者想伪造 data padding amount9999 的签名 # 利用 hashpumpy 这类工具可以自动完成 import hashpumpy original_sig 已知的MD5值 original_data amount100 append_data amount9999 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_digestJava 用MessageDigest.isEqualGo 用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 再加密这个问题的标准答案是先加密再 MACEncrypt-then-MAC因为这样 MAC 覆盖了密文攻击者无法通过篡改密文来影响解密结果。如果反过来可能引入 padding oracle 之类的攻击。不过现代推荐直接用 AEAD 模式如 AES-GCM、ChaCha20-Poly1305把加密和认证打包解决避免自己组合出错。6.3 给不同基础读者的学习路径如果你是刚接触密码学的开发者建议按这个顺序深入先把 Hash 的性质搞清楚抗碰撞、抗原像、雪崩效应。理解长度扩展攻击的原理这是理解 HMAC 为什么存在的关键。动手实现一遍 HMAC不要只用库函数自己按公式写一遍。研究 AEAD 模式理解现代密码学加密和认证不可分割的设计哲学。最后看密钥管理、侧信道防护这些工程细节。如果你是为了面试或 CTF重点放在长度扩展攻击、HMAC 构造、时序攻击这几个高频考点上。CTF 里经常出现给了 MD5(secret data) 让你伪造签名的题目识别出长度扩展攻击就能秒解。最后分享一个我自己的习惯每次设计签名方案时先在纸上把攻击者能拿到什么、能控制什么、想达到什么目的列清楚再对照 HMAC 的安全假设检查一遍。这个习惯帮我避开了好几次潜在的设计缺陷。密码学这东西算法本身往往是安全的出问题的永远是使用方式。