1. 从一个真实踩坑说起为什么我要把 Hash、MAC、HMAC 彻底掰开前阵子帮一个做后端的朋友排查接口签名问题他信誓旦旦跟我说“我用了 HMAC绝对安全”结果我一看代码md5(sign key)直接拼字符串做摘要连标准的 HMAC 结构都不是。更离谱的是他把这个值当成了“加密后的密文”传给前端还问我为什么别人能伪造。这件事让我意识到Hash、MAC、HMAC 这三个词在很多人脑子里是糊成一团的——都知道跟“摘要”“校验”有关但具体谁是谁、谁解决什么问题、谁不能当谁用一问就露馅。这篇东西就是把这个坑彻底填平。我会从密码学基础出发把 Hash哈希/杂凑、MAC消息认证码、HMAC基于哈希的消息认证码三者的定义、数学结构、安全目标、典型误用、代码实现、参数选择全部拆开讲。不管你是刚学密码学的学生、准备 CTF 的选手、还是天天写接口签名的后端看完都能清楚知道什么时候该用 Hash什么时候必须上 MAC什么时候 HMAC 是唯一正确选择以及为什么md5(data key)这种写法会被安全审计直接打回。核心关键词会贯穿全文Hash、MAC、HMAC、密码学、对称加密。我不会只给你背定义而是把每个设计决策背后的“为什么”讲透——为什么 HMAC 要套两层哈希、为什么长度扩展攻击能干掉朴素拼接、为什么 SM3 这类国产杂凑算法在合规场景里越来越常见。这些都是实际写代码、做安全评审时会真正遇到的问题。2. 先把三个概念的地基打牢它们各自到底在干什么2.1 Hash单向的“数字指纹”只保证完整性不保证来源Hash哈希函数也叫杂凑函数、散列函数干的事情很纯粹把任意长度的输入映射成固定长度的输出。比如 SHA-256 不管你是输入 1 个字节还是 1 个 G输出永远是 256 位32 字节。它有三个硬性要求单向性从输出推不回输入、抗碰撞性找不到两个不同输入产生同一输出、雪崩效应输入改一个 bit输出面目全非。生活里类比一下Hash 就像把一头牛绞成牛肉丸。你能确定这堆丸子来自某头牛但你没法把丸子拼回一头活牛。它的典型用途是完整性校验——你下载一个文件官方给一个 SHA-256 值你本地算一遍对比一致就说明文件没被篡改。系统自带的文件 hash 校验、Git 用 SHA-1 标识 commit、Vue 打包时给 js 文件名加 hash 做缓存失效全是这个逻辑。但这里有个致命认知误区Hash 不提供任何身份认证。因为 Hash 没有密钥任何人都能对任意数据算哈希。攻击者改了文件重新算一遍哈希贴上去你照样被骗。所以 Hash 只能回答“数据变没变”回答不了“数据是不是你发的”。2.2 MAC带密钥的“防伪封条”同时管完整性和来源MACMessage Authentication Code消息认证码就是为了补上 Hash 缺的那一环。它的输入是消息 密钥输出一个固定长度的认证标签tag。验证方用同一个密钥重新计算比对 tag 是否一致。因为攻击者不知道密钥就没法伪造 tag所以 MAC 同时保证了完整性和真实性数据确实来自持有密钥的一方。MAC 本质上依赖对称加密体系——收发双方共享同一个密钥。这跟数字签名不一样签名用私钥签、公钥验是非对称的MAC 是双方共享一把钥匙谁都能生成、谁都能验证。所以 MAC 的前提是密钥必须安全分发和保存一旦密钥泄露整个认证体系就崩了。MAC 不规定具体用什么算法实现。你可以用分组密码比如 AES-CMAC、SM4 的 CBC-MAC 模式来构造也可以用哈希函数来构造——后者就是 HMAC。所以 MAC 是一个功能概念HMAC 是它的一种具体实现。这个层级关系一定要理清很多人把 MAC 和 HMAC 当成并列的两个东西其实是包含关系。2.3 HMAC用哈希函数搭出来的标准 MAC专治各种拼接骚操作HMACHash-based Message Authentication Code是用哈希函数构造 MAC 的一种标准方案RFC 2104 定义。它的结构不是简单地把 key 拼在消息后面算哈希而是套了两层HMAC(K, m) H( (K ⊕ opad) || H( (K ⊕ ipad) || m ) )其中 K 是把密钥 K 补齐或哈希到哈希函数分组长度后的值ipad 是 0x36 重复填充opad 是 0x5c 重复填充⊕ 是异或|| 是拼接。为什么要搞得这么复杂核心目的是避免朴素拼接带来的长度扩展攻击。如果你用H(key || message)攻击者在不知道 key 的情况下可以利用 Merkle–Damgård 结构MD5、SHA-1、SHA-256 都是这个结构的特性在已知H(key || message)的基础上推算出H(key || message || padding || extra)的值。也就是说攻击者能在消息后面追加内容并算出合法 tag认证直接失效。HMAC 的内外两层结构把这个攻击路径堵死了。HMAC 的好处是只要底层哈希函数安全HMAC 就安全而且它不依赖哈希函数的碰撞抗性只依赖其伪随机性所以即使底层哈希被找到碰撞比如 MD5HMAC-MD5 在认证场景下短期内仍然可用当然新系统不该再选它。这就是为什么 HMAC 能成为工业界事实标准——TLS、JWT、API 签名、支付回调验签到处都是它。3. 三者对比一张表看清区别、联系和选型逻辑3.1 核心属性对照表维度HashMACHMAC是否带密钥否是对称密钥是对称密钥保证完整性是是是保证真实性否是是能否防伪造否能能典型算法MD5、SHA-1、SHA-256、SM3AES-CMAC、CBC-MAC、HMACHMAC-SHA256、HMAC-SM3输出长度固定如 256 位固定固定主要用途文件校验、索引、去重消息认证、接口验签消息认证、接口验签密钥分发不需要需要需要从表里能看出Hash 是“无锁的指纹”MAC 和 HMAC 是“带锁的封条”。HMAC 是 MAC 的子集是“用哈希实现的 MAC”。选型逻辑很直接只要涉及身份认证就必须用 MAC/HMAC绝不能用裸 Hash。3.2 为什么不能拿 Hash 当 MAC 用一个具体攻击演示假设你写了个接口签名sign sha256(userId amount secretKey)把 secretKey 拼在最后。这看起来“加了密钥”但其实是错的。问题在于如果攻击者能控制 userId 或 amount 的位置且拼接顺序可预测就可能构造出碰撞或利用结构弱点。更经典的错误是sign sha256(secretKey data)直接暴露在长度扩展攻击下。我用伪代码演示长度扩展攻击的思路仅用于理解原理实际利用需要具体条件# 假设服务端计算 tag sha256(secret message) # 攻击者已知 message 和 tag但不知道 secret # 利用 SHA-256 的 Merkle-Damgard 结构可以计算 # new_tag sha256(secret message padding append_data) # 而无需知道 secret这就是为什么标准做法必须是 HMAC而不是自己拼。HMAC 的两层结构让攻击者无法从已知 tag 推导出扩展后的 tag。我见过太多项目在签名逻辑上自己发明轮子最后被安全扫描一票否决返工成本极高。3.3 MAC 与对称加密的关系别把“认证”和“保密”搞混这里必须澄清一个高频混淆点MAC 不加密数据加密数据也不等于认证。对称加密比如 AES、SM4解决的是机密性——别人看不懂内容MAC/HMAC 解决的是完整性和真实性——内容没被改、来源可信。两者是正交的需求。实际系统里经常组合使用比如 AES-CBC HMAC-SHA256 的 Encrypt-then-MAC 模式先加密再对密文算 HMAC接收方先验 HMAC 再解密。这样既保密又防篡改。但顺序很关键Encrypt-then-MAC 是公认安全的组合而 MAC-then-Encrypt 历史上出过不少问题比如某些 padding oracle 攻击。现代更推荐直接用 AEAD 模式如 AES-GCM、SM4-GCM它把加密和认证一体化了但底层认证部分本质上还是 MAC 的思路。4. 动手实操从零实现并验证 Hash、MAC、HMAC4.1 环境准备与工具选择我用 Python 来演示因为hashlib和hmac是标准库不用装额外依赖跨平台也方便。如果你在 Mac 上系统自带 Python3Windows 下装个 Python 或者用 WSL 都行。命令行工具方面Linux/macOS 自带shasum、opensslWindows 可以用certutil -hashfile。先确认环境python3 --version # 应输出 Python 3.x如果你要做国产算法实验比如 SM3Python 标准库不直接支持需要装gmssl或snowland-smxpip install gmssl提示生产环境选算法时优先看合规要求。金融、政务类项目常要求 SM3/SM4互联网通用场景 SHA-256 HMAC-SHA256 足够。4.2 Hash 实操文件校验与雪崩效应观察先算一个字符串的 SHA-256import hashlib data bhello cryptography h hashlib.sha256(data).hexdigest() print(h) # 输出 64 位十六进制字符串再验证雪崩效应——改一个字符看输出变化h1 hashlib.sha256(bhello cryptography).hexdigest() h2 hashlib.sha256(bhello cryptographY).hexdigest() print(h1) print(h2) # 两个输出完全不同没有任何相似性文件校验的实际操作Linux/macOSshasum -a 256 yourfile.zipWindowscertutil -hashfile yourfile.zip SHA256把输出跟官方公布的值对比一致就说明文件完整。这就是“系统自带文件 hash 校验”的典型用法。注意如果官方只给了 MD5而你又在意安全最好找 SHA-256 的值因为 MD5 早已被证明可构造碰撞不适合做安全校验。4.3 HMAC 实操标准实现与错误写法对比标准 HMAC-SHA256import hmac import hashlib key bmy_secret_key_123 msg buserId1001amount99.00 tag hmac.new(key, msg, hashlib.sha256).hexdigest() print(tag)验证时用hmac.compare_digest做常量时间比较防止时序攻击expected hmac.new(key, msg, hashlib.sha256).hexdigest() received tag # 假设来自请求 if hmac.compare_digest(expected, received): print(验证通过) else: print(验证失败)这里有个关键细节绝对不要用比较 tag。普通字符串比较会在第一个不同字符处提前返回攻击者可以通过测量响应时间逐字节猜出正确 tag这叫时序攻击timing attack。compare_digest保证无论哪里不同比较耗时都一样。现在看错误写法也就是我朋友踩的坑# 错误示范一朴素拼接 bad1 hashlib.sha256(key msg).hexdigest() # 错误示范二拼接顺序反了 bad2 hashlib.sha256(msg key).hexdigest() # 错误示范三用 MD5 且拼接 bad3 hashlib.md5((msg key).encode()).hexdigest()这三种都不该出现在生产代码里。bad1面临长度扩展攻击bad2在某些构造下也有风险bad3双重问题MD5 碰撞 拼接。正确做法永远是hmac.new(key, msg, hashlib.sha256)。4.4 用 OpenSSL 命令行做 HMAC 验证有时候需要在 shell 脚本里验签OpenSSL 很方便echo -n userId1001amount99.00 | openssl dgst -sha256 -hmac my_secret_key_123输出就是 HMAC-SHA256 的十六进制值。注意-n不能省否则echo会带上换行符导致结果跟代码里算的不一致。这个坑我在对接第三方支付回调时踩过排查了半天才发现是换行符问题。5. 深入原理HMAC 结构、长度扩展攻击与算法选型5.1 HMAC 两层结构到底防住了什么回到 HMAC 公式H((K ⊕ opad) || H((K ⊕ ipad) || m))。内层哈希把密钥和消息绑定外层再用密钥的另一份异或值包一层。攻击者即使知道内层输出也无法在不掌握密钥的情况下构造外层输入因为外层需要K ⊕ opad。这就切断了长度扩展攻击的链条。另一个设计考量是密钥处理如果密钥比哈希分组长度长先哈希一次如果短补零到分组长度。这样无论密钥多长都能适配底层哈希且不引入额外弱点。这个细节在 RFC 2104 里有明确定义自己实现时容易漏掉。5.2 长度扩展攻击的完整逻辑链Merkle–Damgård 结构的哈希MD5、SHA-1、SHA-256是分块迭代的把消息填充到分组长度整数倍然后一块一块压缩每块的输出作为下一块的初始向量。问题在于最终输出就是最后一个块的压缩结果而它同时是“继续迭代的初始状态”。所以攻击者拿到H(secret || message)后把这个值当作初始向量从message || padding之后继续压缩append_data就能算出H(secret || message || padding || append_data)全程不需要知道 secret。SHA-3Keccak用的是海绵结构天然免疫长度扩展攻击。所以如果你的场景必须用H(key || data)这种拼接不推荐至少选 SHA-3。但更稳妥的做法还是直接用 HMAC别自己设计。5.3 算法选型MD5、SHA 家族、SM3 怎么选算法输出长度安全性现状推荐场景MD5128 位碰撞已实用化仅非安全场景缓存 key、去重SHA-1160 位碰撞已实用化逐步淘汰Git 也在迁移SHA-256256 位安全通用首选SHA-3可变安全需要抗长度扩展时SM3256 位安全国内合规场景选型原则安全场景一律 SHA-256 起步合规场景用 SM3MD5/SHA-1 只配做非安全用途。HMAC 的底层哈希跟着这个原则走HMAC-SHA256 是当前最通用的组合HMAC-SM3 用于国产化要求。注意网上流传的“win 下 ssl 证书使用了弱 hash 算法”这类问题本质就是还在用 MD5/SHA-1 签证书现代浏览器和系统会直接告警。升级到 SHA-256 是基本操作。6. 常见问题与排查技巧实录6.1 高频问题速查表问题现象可能原因排查方向验签总是失败编码不一致UTF-8/GBK统一用 UTF-8 字节序列验签总是失败多了换行符或空格检查 echo -n、trim 处理验签总是失败参数顺序不一致按字典序排序后拼接验签偶尔失败时间戳过期或随机数重复检查 nonce 和 timestamp 逻辑被安全扫描告警用了 md5(datakey)换成标准 HMAC性能瓶颈大文件全量读入内存算 HMAC分块流式计算6.2 独家避坑经验第一签名前一定要做参数规范化。我见过最离谱的 bug 是前端 JSON 序列化顺序和后端不一致导致同样的参数算出不同签名。解决办法是签名前把所有参数按 key 字典序排序拼成k1v1k2v2的规范字符串再算 HMAC。这个规范字符串的生成规则要写进接口文档双方严格对齐。第二密钥不要硬编码在代码里。我审过不少项目HMAC 密钥直接写在源码常量里一旦代码泄露密钥就废了。正确做法是从环境变量或密钥管理服务读取且不同环境开发/测试/生产用不同密钥。第三注意密钥长度。HMAC 的密钥建议至少跟哈希输出等长SHA-256 就是 32 字节。短密钥会降低安全强度。生成密钥用os.urandom(32)或secrets.token_bytes(32)别用random模块。第四时间戳和 nonce 是防重放的关键。光有 HMAC 只能防篡改防不了重放攻击——攻击者把整个请求原样再发一次HMAC 照样通过。所以要加 timestamp比如 5 分钟有效期和 nonce一次性随机数服务端缓存已用过的。这两个配合 HMAC 才是完整的接口安全方案。第五大文件用流式 HMAC。如果你要对几个 G 的文件算 HMAC别一次性读进内存。Python 的hmac对象支持update分块喂数据h hmac.new(key, digestmodhashlib.sha256) with open(bigfile.bin, rb) as f: for chunk in iter(lambda: f.read(8192), b): h.update(chunk) print(h.hexdigest())这样内存占用恒定几个 G 的文件也能算。6.3 CTF 里的密码学套路如果你在打 CTF 密码学方向Hash/MAC/HMAC 相关题目常见套路有长度扩展攻击给H(key||msg)让你伪造扩展消息、哈希碰撞给两个不同输入相同哈希、HMAC 密钥弱密钥空间小可爆破。遇到“the length of sign is 8”这种提示往往暗示签名被截断可能存在暴力枚举或碰撞空间缩小的问题。做这类题的核心是先判断用的是裸 Hash 还是 HMAC再看密钥是否可猜、结构是否有弱点。7. 把三者串起来一个完整的接口签名方案讲了这么多原理和坑最后给一个可以直接抄作业的完整方案。假设你要做一个开放 API 的请求签名需求是防篡改、防伪造、防重放。请求参数包括appId、timestamp、nonce、bizData。方案如下客户端和服务端共享一个appSecret32 字节随机值安全存储。客户端把除签名外的所有参数按 key 字典序排序拼成k1v1k2v2...的规范字符串canonical。计算sign HMAC-SHA256(appSecret, canonical)转十六进制。把sign一起发给服务端。服务端收到后先检查timestamp是否在 5 分钟内再检查nonce是否已用过用 Redis 缓存过期时间设为时间窗的 2 倍。服务端用同样规则重算 HMAC用compare_digest比对。全部通过才处理业务。这个方案里Hash 没有单独出现因为裸 Hash 不满足认证需求MAC 是功能目标HMAC 是具体实现。三者关系在这个方案里体现得清清楚楚。如果你把第 3 步换成sha256(canonical appSecret)整个方案的安全性就塌了——这就是为什么理解区别如此重要。我个人在实际项目中的体会是密码学这东西最怕“看起来差不多”。md5(datakey)和HMAC-SHA256(key, data)在代码上就差几个字符但安全强度差了一个数量级。每次做安全评审我都会重点看签名和验签这两段代码因为这里是最容易埋雷的地方。把 Hash、MAC、HMAC 的边界划清楚比背一百个算法名字都管用。
