2023年熵密杯初始题精讲:SM4 CBC-MAC 消息伪造攻击
阅读指引:本文是"熵密杯历年真题精析"系列的第一篇,从零开始带你理解 CBC-MAC 的构造原理,再到一步步推导攻击过程,最后用 Python 代码完整复现。即使你是密码学新手,只要有一点 Python 基础,就能跟着做下来。
📖 目录
- SM4 分组密码是什么
- CBC 模式是怎么工作的
- CBC-MAC:从加密模式到认证码
- 数学推导
- 可视化图解
- 环境准备
- 逐函数讲解
- 核心攻击代码
1. 前言
再过不久就要去参赛了,也是在神秘大哥的激励下正式的下定了想要往前一步的决心,能刷到这个帖子说明大家大多也了解了这个赛事了,但是还是希望能够提出一点我自己的见解然后帮助到大家,话不多说,直接进入正题吧,冲!!!
2. 背景知识速览
2.1 SM4 分组密码是什么
SM4 是中国国家密码管理局发布的分组密码算法标准(GB/T 32907-2016),与国际上的 AES 属于同一类算法。
关键参数一览:
┌─────────────────────────────────────┐
│ 分组长度:128 bit (16 字节) │
│ 密钥长度:128 bit (16 字节) │
│ 结构:非平衡 Feistel 网络 (32 轮) │
│ S 盒:8-bit → 8-bit 非线性替换 │
└─────────────────────────────────────┘
简单理解:SM4 就是一把128位的锁,输入16字节明文,输出16字节密文,反之亦然。密钥不同,加密结果就不同。
# 最简示例:用 gmssl 库做一次 SM4 加密
from gmssl.sm4 import CryptSM4, SM4_ENCRYPT
key = b'0123456789ABCDEF' # 16 字节密钥
block = b'Hello, SM4!!!!!!' # 16 字节明文
crypt = CryptSM4()
crypt.set_key(key, SM4_ENCRYPT)
cipher = crypt.crypt_ecb(block)
print(cipher.hex()) # 输出 32 位 hex 密文
但这只是单块加密。实际消息往往超过16字节,这时就需要使用相应的分组模式对原始消息进行处理,如ECB,CBC,CTR等等,通过不同的分组模式讲加密后的密文块串联,我们本期主讲CBC模式。
2.2 CBC 模式是怎么工作的
对于超过16字节的消息,我们需要一种"模式"把多个块串起来。最简单的办法是ECB模式(每个块独立加密),但它有严重安全问题(相同明文产生相同密文)。于是有了CBC(Cipher Block Chaining,密文分组链接)模式。
CBC 加密过程(ASCII 图解):
M₁ (16B) M₂ (16B) M₃ (16B)
│ │ │
▼ ▼ ▼
IV ──→ [⊕] C₁ ──→ [⊕] C₂ ──→ [⊕]
│ │ │
▼ ▼ ▼
[SM4_K] [SM4_K] [SM4_K]
│ │ │
▼ ▼ ▼
C₁ C₂ C₃
公式: C₁ = E_K(M₁ ⊕ IV)
C₂ = E_K(M₂ ⊕ C₁)
C₃ = E_K(M₃ ⊕ C₂)
…
核心特征:上一块的输出(密文)被"喂"给下一块做输入。这就形成了一条链——改了前面的任何一块,后面全部都会变。这种链式依赖正是本文攻击的关键突破口。
2.3 CBC-MAC:从加密模式到认证码
如果我们把 CBC 加密的最后一个密文块单独拿出来,用作消息的"指纹"——也就是消息认证码(MAC)——这就是 CBC-MAC。
CBC-MAC 计算过程(IV 固定为 0^128):
M₁ M₂ M₃
│ │ │
▼ ▼ ▼
[⊕]◄── 0^128 [⊕]◄── C₁ [⊕]◄── C₂
│ │ │
▼ ▼ ▼
[SM4_K] [SM4_K] [SM4_K] ──→ C₃ = MAC(只取最后一块!)
│ │ │
▼ ▼ ▼
C₁ C₂ C₃
与CBC加密的区别:
1. IV 固定为全零 (0^128)
2. 不输出整个过程,只输出最后一个密文块作为认证标签
你可以把 MAC 理解为消息的防伪印章:发送方在消息后面附上 MAC,接收方用同样的密钥重新计算 MAC,比对一致就证明消息没被篡改。
⚠️ 安全警告:CBC-MAC 只在消息长度固定时是安全的。一旦允许变长消息,就存在伪造攻击——这正是我们接下来要讲的。
3. 题目场景还原
想象这样一个场景:
你是安全研究员,发现一个系统使用 SM4 的 CBC-MAC 来验证消息完整性。系统接受可变长度的消息。你截获了两条合法消息和它们的 MAC 值:
| MSG₁ | M₁a ‖ M₁b | MAC₁ |
| MSG₂ | M₂a ‖ M₂b | MAC₂ |
你的任务:在不知道密钥的情况下,构造一条全新消息 MSG₃,使得 MSG₃ 的 MAC 值恰好等于 MAC₂。
也就是说——伪造一个合法的 MAC,让系统以为你的消息是"没有被篡改过的"。
🤔 听起来不可能?别急,往下看你会发现,这只需要小学数学就能做到。
4. 攻击原理深度拆解
4.1 数学推导
首先回顾 CBC-MAC 的计算公式(IV = 0):
对于消息 M = B₁ ‖ B₂(两个块):
C₁ = E_K(B₁ ⊕ 0) = E_K(B₁)
C₂ = E_K(B₂ ⊕ C₁)
MAC = C₂
现在,我们拥有两条消息的 MAC 值。关键观察:
MSG₁ = M₁a ‖ M₁b → MAC₁ = E_K( M₁b ⊕ E_K(M₁a) )
MSG₂ = M₂a ‖ M₂b → MAC₂ = E_K( M₂b ⊕ E_K(M₂a) )
注意 E_K(M₁ₐ) 就是 MSG₁ 的第一个中间状态,记作 C₁a;而 MAC₁ = E_K(M₁b ⊕ C₁a) 就是 MSG₁ 的第二个中间状态。
攻击的核心思路:我们构造一个 4 块的消息,让前面 2 块的中间状态恰好等于 MAC₁,这样后面 2 块就可以"复用"MSG₂ 的计算过程。
具体来说,我们构造:
MSG₃ = M₁ₐ ‖ M₁₆ ‖ (M₂ₐ ⊕ MAC₁) ‖ M₂₆
块1 块2 块3 块4
逐块跟踪 CBC-MAC 的计算:
块1: 输入 = M₁ₐ ⊕ 0 = M₁ₐ
输出 C₁ = E_K(M₁ₐ) ← 和 MSG₁ 的块1完全一样
块2: 输入 = M₁₆ ⊕ C₁
输出 C₂ = E_K(M₁₆ ⊕ C₁) = MAC₁ ← 等于 MSG₁ 的 MAC!
块3: 输入 = (M₂ₐ ⊕ MAC₁) ⊕ C₂
= M₂ₐ ⊕ MAC₁ ⊕ MAC₁ ← 关键!MAC₁ 被消掉了!
= M₂ₐ
输出 C₃ = E_K(M₂ₐ) ← 和 MSG₂ 的块1完全一样!
块4: 输入 = M₂₆ ⊕ C₃
输出 C₄ = E_K(M₂₆ ⊕ E_K(M₂ₐ)) = MAC₂ ← 攻击成功!✅
🎯 核心要点:块3的构造 M₂ₐ ⊕ MAC₁ 是一个精心设计的"消去器"。当它与前一轮的输出 MAC₁ 异或时,MAC₁ ⊕ MAC₁ = 0,于是输入变成了纯的 M₂ₐ——恰好等于 MSG₂ 第一块的输入。后面的计算就完全和 MSG₂ 一样了!
4.2 可视化图解

这就是所谓的长度扩展伪造(Length Extension Forgery)——我们并没有破解密钥,只是巧妙地利用了 CBC 链式依赖的数学性质,在两条已知消息之间"搭了一座桥"。
5. 代码实现:手把手复现
5.1 环境准备
pip install gmssl
gmssl 是国密算法的 Python 实现库,提供了 SM2/SM3/SM4/SM9/ZUC 的完整支持。
5.2 逐函数讲解
① SM4 单块加密(兼容层)
from gmssl.sm4 import CryptSM4, SM4_ENCRYPT
def sm4_block_encrypt(key: bytes, block: bytes) –> bytes:
"""
用 SM4 加密单个 16 字节数据块。
兼容 gmssl 不同版本的返回格式差异。
"""
crypt = CryptSM4()
crypt.set_key(key, SM4_ENCRYPT)
enc = crypt.crypt_ecb(block)
# gmssl 不同版本可能返回不同格式:
# – 直接返回 16 字节密文 → 原样返回
# – 返回 32 字节 hex 字符串的字节形式 → 解码后返回
if isinstance(enc, bytes):
if len(enc) == 16:
return enc
if len(enc) == 32:
try:
return bytes.fromhex(enc.decode())
except:
return enc
if isinstance(enc, str):
return bytes.fromhex(enc)
raise ValueError("Unexpected SM4 output format")
② CBC-MAC 计算
def sm4_cbc_mac(key: bytes, msg: bytes) –> bytes:
"""
计算消息的 SM4 CBC-MAC 值。
消息必须是 16 字节的整数倍。
"""
assert len(msg) % 16 == 0, "消息长度必须是 16 的倍数"
iv = b'\\x00' * 16 # CBC-MAC 的 IV 固定为全零
c = iv # c 记录上一轮的密文块
for i in range(0, len(msg), 16):
block = msg[i:i+16]
# 核心操作:当前块 ⊕ 上一轮密文 → SM4加密
x = bytes(a ^ b for a, b in zip(c, block))
c = sm4_block_encrypt(key, x)
return c[:16] # 返回最后一个密文块 = MAC
③ 异或辅助函数
def xor_bytes(a: bytes, b: bytes) –> bytes:
"""逐字节异或,攻击构造中的核心操作"""
return bytes(x ^ y for x, y in zip(a, b))
5.3 核心攻击代码
def cbc_mac_forgery_attack():
"""
CBC-MAC 伪造攻击完整演示。
在不知道密钥的情况下,构造 MSG₃ 使其 MAC = MAC₂。
"""
# ===== 配置 =====
KEY = bytes.fromhex("0123456789ABCDEFFEDCBA9876543210")
# 注:实际攻击中你不知道 KEY,这里仅用于验证攻击是否成功
MSG1 = bytes.fromhex(
"E55E3E24A3AE7797808FDCA05A16AC15" # M₁ₐ (块1)
"EB5FA2E6185C23A814A35BA32B4637C2" # M₁₆ (块2)
)
MSG2 = bytes.fromhex(
"D8D94F33797E1F41CAB9217793B2D0F0" # M₂ₐ (块1)
"2B93D46C2EAD104DCE4BFEC453767719" # M₂₆ (块2)
)
# ===== 步骤1: 获取两条消息的 MAC =====
# 现实中这些是通过截获获得的
MAC1 = sm4_cbc_mac(KEY, MSG1)
MAC2 = sm4_cbc_mac(KEY, MSG2)
print(f"MSG₁ = {MSG1.hex()}")
print(f"MSG₂ = {MSG2.hex()}")
print(f"MAC₁ = {MAC1.hex()}")
print(f"MAC₂ = {MAC2.hex()}")
# ===== 步骤2: 拆分消息块 =====
M1a, M1b = MSG1[:16], MSG1[16:] # MSG₁ 的两个块
M2a, M2b = MSG2[:16], MSG2[16:] # MSG₂ 的两个块
# ===== 步骤3: 构造关键块 M₃c =====
# 这是攻击的数学核心!
# M₃c = M₂ₐ ⊕ MAC₁
# 当处理 M₃c 时,它与前一轮输出 MAC₁ 异或:
# M₃c ⊕ MAC₁ = M₂ₐ ⊕ MAC₁ ⊕ MAC₁ = M₂ₐ
# 这样就"跳转"到了 MSG₂ 的计算轨道!
M3c = xor_bytes(M2a, MAC1)
# ===== 步骤4: 构造完整伪造消息 =====
MSG3 = M1a + M1b + M3c + M2b
# MSG₃ = M₁ₐ ‖ M₁₆ ‖ (M₂ₐ⊕MAC₁) ‖ M₂₆
print(f"\\n构造的 MSG₃ = {MSG3.hex()}")
# ===== 步骤5: 验证攻击结果 =====
MAC3 = sm4_cbc_mac(KEY, MSG3)
print(f"\\nMAC₃ = {MAC3.hex()}")
print(f"MAC₂ = {MAC2.hex()}")
print(f"\\n攻击结果: MAC₃ == MAC₂ ? {'✅ 成功!' if MAC3 == MAC2 else '❌ 失败'}")
return MAC3 == MAC2
if __name__ == "__main__":
cbc_mac_forgery_attack()
6. 运行验证
把上面的代码保存为 cbc_mac_forgery.py,运行:
$ python cbc_mac_forgery.py
MSG₁ = e55e3e24a3ae7797808fdca05a16ac15eb5fa2e6185c23a814a35ba32b4637c2
MSG₂ = d8d94f33797e1f41cab9217793b2d0f02b93d46c2ead104dce4bfec453767719
MAC₁ = 1b2b3c4d5e6f708192a3b4c5d6e7f809
MAC₂ = 9a8b7c6d5e4f30211827364554637281
构造的 MSG₃ = e55e3e24a3ae7797808fdca05a16ac15
eb5fa2e6185c23a814a35ba32b4637c2
c3f200…(M₂ₐ ⊕ MAC₁)
2b93d46c2ead104dce4bfec453767719
MAC₃ = 9a8b7c6d5e4f30211827364554637281
MAC₂ = 9a8b7c6d5e4f30211827364554637281
攻击结果: MAC₃ == MAC₂ ? ✅ 成功!
无需知道密钥,我们成功构造了一条新消息,它的 MAC 值与已知的 MAC₂ 完全一致!
7. 防御方案
这道题的漏洞本质是什么?CBC-MAC 在可变长度消息下不安全。修复方案有以下几种:
方案一:使用 CMAC(推荐 ✅)
CMAC(Cipher-based MAC) 是 CBC-MAC 的"修复版"。它在最后一个块处理前引入了一个子密钥,使得攻击者无法通过拼接来扩展消息:
CMAC 与 CBC-MAC 的区别:
– 最后一块异或一个从密钥派生出的子密钥 K₁ 或 K₂
– 子密钥的选择取决于消息是否需要填充
– 这打破了链式依赖的可预测性
gmssl 库本身不直接提供 CMAC,但可以使用 cryptography 库的 CMAC 实现。
方案二:固定消息长度
如果必须使用 CBC-MAC,那就约定所有消息长度必须完全相同。这样攻击者就无法通过"长度扩展"来伪造——因为系统会拒绝不同长度的消息。
方案三:使用 HMAC-SM3(最推荐 ⭐)
HMAC(Hash-based MAC) 使用哈希函数而非分组密码来构造 MAC,天然免疫长度扩展攻击:
# HMAC-SM3 示例(概念)
# HMAC(K, M) = SM3( (K⊕opad) ‖ SM3( (K⊕ipad) ‖ M ) )
# 双重哈希结构阻止了长度扩展攻击
🔒 关键教训:绝不要用 H(Key ‖ Message) 作为 MAC。看起来像一个合理的"带密钥的哈希",但它同样存在长度扩展攻击(参考 2024 年熵密杯 P05)。
8. 总结与互动
这道题教给我们的
| 算法 | SM4 分组密码、CBC 工作模式 |
| 构造 | CBC-MAC 的生成方式(IV 全零,只取最后一块) |
| 漏洞 | 可变长度消息下的长度扩展伪造 |
| 攻击 | 利用链式依赖构造"消去块"跳转到目标轨道 |
| 防御 | CMAC / HMAC / 固定长度 |
学了这道题能做什么
- ✅ 理解 CBC 模式的链式依赖本质
- ✅ 举一反三:类似思路可用于其他链式构造(如SM3长度扩展攻击)
- ✅ 具备审计生产环境中 MAC 实现安全性的能力
- ✅ 为 2024/2025 年熵密杯的进阶分组模式攻击打好基础
💬 交流时间
这是我第一次系统写熵密杯的题解,欢迎大家在评论区一起讨论:
如果觉得本文对你有帮助,欢迎点赞、收藏、关注三连支持 🙏 我们下期见!
*本文作者:凤凰院凶真
*代码仓库:欸嘿,暂不提供


