SM2 签名 k 值重用攻击:从数学原理到工程防护的完整指南

密码学 · 2026-09-23 · 3 阅读

在国密 SM2 数字签名的工程实现中,开发者最常忽视的一个问题就是签名随机数 k 的安全性。

你可能听说过:

  • 签名必须用"安全的随机数"
  • k 不能重复使用
  • 重复 k 会导致私钥泄露
但这些只是常识性的警告。真正的问题是:为什么 k 重复就能恢复私钥?攻击者具体怎么做到的?你的代码有没有这个漏洞?

本文将从数学原理出发,完整推导 SM2 k 重用攻击,并通过真实案例和可运行代码演示如何实施这种攻击。

一、SM2 签名算法回顾

1.1 标准签名流程

根据 GM/T 0003.2-2012《SM2 密码算法 第 2 部分:数字签名算法》,SM2 签名的核心流程如下:

CODE
输入:私钥 d_A、消息 M
输出:签名 σ = (r, s)

步骤 1:计算 Za = SM3(ENTL_A || ID_A || a || b || xG || yG || xA || yA)
步骤 2:计算 e = SM3(Za || M)
步骤 3:选择随机整数 k ∈ [1, n-1]
步骤 4:计算 (x₁, y₁) = [k]G
步骤 5:计算 r = (e + x₁) mod n
        若 r = 0 或 r + k = n,返回步骤 3
步骤 6:计算 s = (1 + d_A)^(-1) × (k - r×d_A) mod n
        若 s = 0,返回步骤 3
步骤 7:输出签名 σ = (r, s)

关键参数:

参数含义要求
k随机数(nonce)每次签名必须不同,范围 [1, n-1]
r签名的第一个分量由 k 和消息共同决定
s签名的第二个分量由 k、r、私钥共同决定
n曲线基点的阶256 位素数

1.2 k 值的核心地位

在 SM2 签名公式中,k 扮演了一次性掩码的角色:

CODE
s = (1 + d_A)^(-1) × (k - r×d_A) mod n

这个公式的本质是:用 k 将私钥 d_A 隐藏起来,生成签名 s。只有知道 k 的人才能从 (r, s) 反推出 d_A。

这就是为什么 k 必须保密且不能重复——一旦两个签名使用了相同的 k,攻击者就能建立方程组,直接解出私钥。

二、k 重用攻击的数学推导

2.1 攻击场景

假设攻击者截获了同一私钥对两条不同消息 M₁ 和 M₂ 生成的签名:

CODE
签名1:σ₁ = (r₁, s₁),其中 r₁ = (e₁ + x₁) mod n
签名2:σ₂ = (r₂, s₂),其中 r₂ = (e₂ + x₂) mod n

关键:两个签名使用了相同的 k,因此 x₁ = x₂,r₁ = r₂

2.2 方程建立

根据 SM2 签名公式,我们可以写出两个方程:

CODE
s₁ = (1 + d_A)^(-1) × (k - r₁×d_A) mod n
s₂ = (1 + d_A)^(-1) × (k - r₂×d_A) mod n

由于 r₁ = r₂ = r(因为 k 相同),两个方程简化为:

CODE
s₁ = (1 + d_A)^(-1) × (k - r×d_A) mod n
s₂ = (1 + d_A)^(-1) × (k - r×d_A) mod n

等等,这两个方程右边完全一样!这意味着什么?

这意味着如果攻击者发现两个签名的 s 值不同,但 r 值相同,就能确认 k 被重用了。

2.3 私钥恢复

现在推导如何从相同的 k 恢复私钥 d_A:

第一步:恢复 k

从签名公式变形:

CODE
s₁ × (1 + d_A) = k - r×d_A  (mod n)
s₂ × (1 + d_A) = k - r×d_A  (mod n)

两式相减:

CODE
(s₁ - s₂) × (1 + d_A) = 0  (mod n)

等等,这还是不对。让我重新推导。

正确的推导是从原始的签名方程出发:

CODE
s₁ = (1 + d_A)^(-1) × (k - r×d_A) mod n
s₂ = (1 + d_A)^(-1) × (k - r×d_A) mod n

如果 k 相同,那么 s₁ 应该等于 s₂(当且仅当消息的 e 值相同)。但如果 e₁ ≠ e₂,则 r₁ ≠ r₂,这时 k 重用攻击更复杂。

实际攻击场景:大多数 k 重用攻击发生在签名者固定 k 值的情况下(如 Sony PS3 漏洞),此时无论消息如何,k 保持不变。

让我们重新推导固定 k 的攻击:

CODE
已知:两个签名 (r₁, s₁) 和 (r₂, s₂) 使用相同的 k
其中:r₁ = (e₁ + x₁) mod n, r₂ = (e₂ + x₂) mod n
由于 k 相同,x₁ = x₂,但 r₁ ≠ r₂(因为 e₁ ≠ e₂)

签名公式:
s₁ = (1 + d_A)^(-1) × (k - r₁×d_A) mod n
s₂ = (1 + d_A)^(-1) × (k - r₂×d_A) mod n

解方程:

CODE
s₁ × (1 + d_A) = k - r₁×d_A  (mod n)
s₂ × (1 + d_A) = k - r₂×d_A  (mod n)

两式相减:

CODE
(s₁ - s₂) × (1 + d_A) = (r₂ - r₁) × d_A  (mod n)

展开并整理:

CODE
(s₁ - s₂) + (s₁ - s₂)×d_A = (r₂ - r₁)×d_A
(s₁ - s₂) = [(r₂ - r₁) - (s₁ - s₂)] × d_A
(s₁ - s₂) = (r₂ - r₁ - s₁ + s₂) × d_A

最终得到私钥公式:

CODE
d_A = (s₁ - s₂) × (r₂ - r₁ - s₁ + s₂)^(-1) mod n

2.4 验证公式正确性

让我用 Python 验证这个攻击公式:

三、历史真实案例

3.1 Sony PlayStation 3 漏洞(2010 年)

漏洞描述: Sony PS3 在签名系统更新文件时,使用了固定的 k = 0 进行 ECDSA 签名。攻击者只需两个签名就能恢复私钥,进而伪造任意系统更新。

影响:

  • 全球 PS3 用户面临安全风险
  • Sony 不得不紧急发布安全补丁
  • 此漏洞被详细记录在学术论文中
数学原因: 当 k = 0 时,签名公式退化为:
CODE
s = (1 + d_A)^(-1) × (0 - r×d_A) = -(1 + d_A)^(-1) × r×d_A
这使得私钥恢复变得异常简单。

3.2 Android 比特币钱包漏洞(2013 年)

漏洞描述: Android 比特币钱包的签名实现中存在 bug,导致多个交易使用了相同的 k 值。攻击者通过分析区块链上的交易签名,恢复了用户的私钥,盗取了比特币。

影响:

  • 多个用户钱包被盗
  • 比特币社区损失约 60,000 美元
  • 引发对整个加密货币生态系统安全性的质疑
根本原因: 该钱包使用伪随机数生成器(PRNG)而非密码学安全随机数生成器(CSPRNG)生成 k 值,在特定条件下会产生重复的 k。

3.3 共同特征

这些案例有一个共同点:签名实现中没有正确使用 CSPRNG 生成 k 值。这可能源于:

  • 使用了不安全的随机数生成器(如 rand())
  • 随机数生成器被重置或种子泄露
  • 代码 bug 导致 k 值被硬编码
  • 并行签名场景下随机数生成竞争条件

四、攻击演示代码

4.1 完整攻击脚本

以下代码演示了如何从两个重用 k 的 SM2 签名中恢复私钥:

4.2 运行结果

五、防御方案

5.1 使用确定性签名(RFC 6979)

最可靠的防御是使用确定性签名,即 k 值由私钥和消息唯一确定,避免随机数质量问题。

RFC 6979 标准(适用于 ECDSA,SM2 也可参考):

CODE
k = HMAC-SHA256(K, v || 0x00 || point || h)

其中:

  • K:由私钥派生的密钥
  • v:初始状态值
  • point:私钥对应的公钥点
  • h:消息哈希
优点:
  • 消除随机数生成器的质量依赖
  • 同一消息和私钥总是产生相同签名(有利于测试)
  • 无法通过随机数质量问题导致 k 重用
缺点:
  • 签名可预测(同一个消息的签名固定)
  • 某些场景可能需要真正的随机性

5.2 使用密码学安全随机数生成器

如果必须使用随机 k,确保使用 CSPRNG:

PYTHON
import os

# ❌ 错误:使用非密码学安全的随机数
k = os.urandom(32)  # 在旧版 Python 中可能不安全

# ✅ 正确:使用密码学安全随机数
k = os.urandom(32)  # Python 3.6+ 默认使用 CSPRNG
# 或使用专门的库
from Crypto.Random import get_random_bytes
k = get_random_bytes(32)

Python 标准库说明:

  • Python 3.6+ 中,os.urandom() 使用 /dev/urandom(Linux)或 CryptGenRandom(Windows),都是 CSPRNG
  • 但为了保险起见,建议使用专门的密码学库

5.3 签名后验证 k 值

在签名完成后,检查新生成的 k 是否与之前的 k 冲突:

5.4 监控异常签名模式

在生产环境中,监控以下异常模式:

  • 相同 r 值:如果两个签名的 r 值相同但消息不同,可能存在 k 重用
  • 签名时间间隔:如果签名操作过于频繁,可能存在并发问题
  • 日志审计:记录每次签名的 k 值哈希(不记录明文 k),便于事后审计
PYTHON
import hashlib

def log_signature_k(k_hex: str):
    """记录 k 值的哈希,用于审计"""
    k_hash = hashlib.sha256(k_hex.encode()).hexdigest()
    logger.info(f"Signature with k_hash={k_hash}")

5.5 使用经过审计的密码库

不要自己实现 SM2 签名,使用经过安全审计的库:

库语言审计状态
gmsslC/Python国内广泛使用,有社区维护
TongsuoC/Go阿里巴巴开源,经过生产验证
Bouncy CastleJava业界标准,经过广泛审计
node-sm2JavaScript有安全审计
避免使用:
  • 自己编写的签名代码
  • 未经审计的第三方库
  • 过时的库版本(可能存在已知漏洞)

六、实际工程建议

6.1 代码审查清单

在代码审查时,检查以下关键点:

  • [ ] 签名函数是否使用 CSPRNG 生成 k
  • [ ] 是否检查 k 值的唯一性
  • [ ] 是否有签名失败重试机制(重试时 k 不能重复)
  • [ ] 是否在多线程环境下安全(每个线程有独立的 k 生成器)
  • [ ] 日志中是否泄露 k 值或私钥

6.2 测试用例

添加以下测试用例:

6.3 性能与安全平衡

确定性签名(RFC 6979)的性能略优于随机签名,因为避免了随机数生成开销。但在大多数应用场景下,这个差异可以忽略不计。

建议:

  • 对安全性要求高的场景:使用确定性签名
  • 对签名不可预测性有要求的场景:使用 CSPRNG + 重复检查

七、总结

SM2 签名的 k 值重用攻击是一个严重的实现级安全漏洞,其后果是私钥完全泄露。本文从数学原理、真实案例、攻击代码到防御方案,完整介绍了这一攻击模式。

关键要点:

  • k 值必须保密且唯一:每次签名使用不同的随机数 k
  • 数学推导:两个使用相同 k 的签名可以建立方程组,直接解出私钥
  • 历史教训:Sony PS3 和 Android 比特币钱包的漏洞都源于 k 值问题
  • 防御措施:使用确定性签名(RFC 6979)、CSPRNG、k 值监控、审计库
工程实践:

  • 不要自己实现签名算法
  • 使用经过安全审计的密码库
  • 添加 k 值重复检测
  • 定期审查签名相关代码
记住:密码学的安全性不仅取决于算法本身,更取决于实现的质量。一个看似无害的实现 bug(如 k 值重用)可能导致灾难性的安全后果。

参考