SecondFi 钱包事件:一份面向开发者的加密学事后剖析
背景
2026 年 6 月 21 日至 23 日,SecondFi(原 Yoroi)Cardano 钱包中的一个致命漏洞导致约 1610 万 ADA(当时价值约 240 万美元)从 374 个用户钱包中被盗。根源?仅仅是在 Ed25519 签名实现中少写了一行代码。
作为开发者,我们常常把加密库当作黑盒来用。这次事件残酷地提醒我们:如何使用加密原语,和原语本身同样重要。让我们一步步拆解到底出了什么问题、为什么会发生,以及如何避免重蹈覆辙。
技术拆解
Ed25519 签名本该怎么做
Ed25519 是 RFC 8032 定义的一种确定性签名方案。与 ECDSA(其随机数重用已是众所周知的灾难)不同,Ed25519 的设计初衷就是彻底消除随机数失败的风险, 它从私钥材料和消息中确定性地派生出临时随机数(nonce)。
对于 Cardano 的扩展 Ed25519 变体,钱包存储一个 64 字节的扩展私钥,分为两部分:
- kL(第 0–31 字节):签名标量
- kR(第 32–63 字节):秘密随机数后缀(每个钱包独有,永不公开)
正确的签名流程如下:
r = SHA-512(kR || M) mod L # 随机数由秘密 + 消息派生
R = r * B # R 是公开的
k = SHA-512(R || A || M) mod L # 挑战标量
S = (r + k * kL) mod L # 签名分量
最终签名是 (R, S) 对。这个设计安全的原因:
- 确定性:同一消息 + 同一密钥 = 同一签名(无需依赖 RNG)
- 秘密参与:随机数 r 依赖 kR,而 kR 永远不会离开设备
- 可验证:任何人都可通过 [S]B = R + [k]A 验证,无需知道私钥
SecondFi 实际做了什么
SecondFi Android 10.0.3 版本在从 BIP32-Ed25519 派生层向签名适配器层传递数据时,遗漏了关键的 kR 秘密前缀。只有签名标量被传给了底层签名器。
有缺陷的实现计算随机数的方式是:
r = SHA-512(M) mod L
仅此而已。没有任何秘密成分,只有交易体哈希, 而这是链上公开可见的。
为什么这是灾难性的
当随机数可以公开计算时,攻击者只需一条链上签名就能恢复私钥。
给定任何交易中的公开值:
- M:被签名的消息(交易体)
- R:公开随机数点
- A:公钥
- S:签名分量
攻击者计算:
r = SHA-512(M) mod L # 和漏洞签名器一样
k = SHA-512(R || A || M) mod L # 和验证器一样
s = (S – r) * inverse(k) mod L # 恢复私钥
一条签名,一笔交易,你的私钥就归别人了。
安全研究员 Charles Guillemet 现场演示了这一点:他从主网上拉取签名,仅凭链上信息就重建了私钥。
时间线
| 6 月 8 日 | SecondFi Android 10.0.3 发布,包含有缺陷的签名器 |
| 6 月 12 日 | 早期用户交易已经开始泄露密钥 |
| 6 月 21–23 日 | 两个独立攻击者团伙系统性清空 374 个钱包 |
| 6 月 22–23 日 | SecondFi 承认问题,进入维护模式 |
| 6 月 22 日 | SecondFi 紧急将约 1.29 亿 ADA 转移至第三方托管机构 |
| 6 月 24 日+ | 补丁发布 |
| 7 月 22 日 | SecondFi 宣布永久关闭 |
根本原因:未经审计的 SDK
漏洞是在 6 月 8 日引入的,当时一个名为 trantor 的未经审计的第三方实验性 SDK(由一名独立开发者发布在 npm 上)替换了 EMURGO 此前审计过的签名模块。
这是一个关键的教训:绝不能在生产环境中用未经审计的代码替换经过审计的加密代码, 尤其不能跳过完整的安全审查。
Tibane Labs 的取证报告证实,有漏洞的签名器正是 trantor SDK,它替换了经过验证的 EMURGO 构建版本。问题不在于 SDK 本身的意图,而在于它如何与 Cardano 扩展 Ed25519 签名流程集成, 秘密前缀根本没有通过适配器层传递。
代码级分析
有漏洞的实现(简化版)
import hashlib
# 漏洞:完全省略了秘密前缀 kR
def flawed_sign(private_key_scalar, message):
# 缺失:kR(秘密随机数前缀)
# 本应是:r = SHA512(kR || message) mod L
# 有漏洞:随机数只由消息派生
r = SHA512(message) % L # 可公开计算!
R = r * B
k = SHA512(R || public_key || message) % L
S = (r + k * private_key_scalar) % L
return (R, S)
正确的实现(RFC 8032)
import hashlib
def correct_sign(extended_secret_key, message):
# extended_secret_key = kL (32 字节) + kR (32 字节)
kL = extended_secret_key[0:32]
kR = extended_secret_key[32:64]
# 正确:随机数由秘密前缀 + 消息派生
r = SHA512(kR || message) % L # 秘密且不可预测
R = r * B
k = SHA512(R || public_key || message) % L
S = (r + k * kL) % L
return (R, S)
概念验证(玩具示例)
下面这个简化的 PoC 展示了漏洞原理:
import hashlib
L = 2**127 – 1 # 不是真正的 Ed25519 阶,仅为演示
def H(*parts):
h = hashlib.sha512()
for p in parts:
h.update(p if isinstance(p, bytes) else str(p).encode())
return int.from_bytes(h.digest(), "little") % L
def inv(x):
return pow(x, –1, L)
# 受害者的私钥(玩具示例)
secret_s = 98765432123456789
public_A = secret_s # 真实 Ed25519 中 A = s * B
M = b"cardano tx body hash, toy example"
# 漏洞签名器:前缀被丢弃,随机数仅由消息决定
r = H(M)
R = r # 真实 Ed25519 中 R = r * B
k = H(R, public_A, M)
S = (r + k * secret_s) % L
print("[受害者签名]")
print("A =", public_A)
print("R =", R)
print("S =", S)
# 攻击者:从公开数据恢复私钥
r_public = H(M)
recovered_s = ((S – r_public) * inv(k)) % L
print("\\n[攻击者]")
print("恢复出的私钥:", recovered_s)
print("匹配:", recovered_s == secret_s)
修复方案:应该怎么做
1. 使用经过审计的库
永远不要自己造加密轮子。 使用成熟、审计过的库:
- Rust:ed25519-dalek(标准且经过审计的实现)
- JavaScript/TypeScript:@noble/ed25519 或 tweetnacl
- Python:pynacl(libsodium 绑定)
对于 Cardano,请使用 Cardano Serialization Library(@emurgo/cardano-serialization-lib),它经过审计并遵循正确的扩展 Ed25519 规范。
2. 在所有层级保留秘密前缀
在通过适配器层传递密钥时,确保完整的扩展私钥(kL 和 kR 两者)都保留:
// 正确:传递完整的 64 字节扩展私钥
fn sign_transaction(extended_secret: &[u8; 64], message: &[u8]) -> Signature {
let kL = &extended_secret[0..32];
let kR = &extended_secret[32..64];
// kR 必须用于随机数派生
let r = sha512(kR, message);
// …
}
3. 绝不用未经审计的代码替换已审计代码
trantor SDK 在 6 月 8 日替换了 EMURGO 的审计实现。这种变更本应触发:
- 完整的安全审查
- 加密正确性验证测试
- 分阶段发布并伴随监控
4. 实施加密正确性的回归测试
测试随机数派生是否正确:
#[test]
fn test_nonce_derivation_includes_secret_prefix() {
let secret = generate_test_secret();
let message = b"test message";
let sig1 = sign(secret, message);
let sig2 = sign(secret, message);
// 确定性:同一密钥 + 同一消息 = 同一签名
assert_eq!(sig1, sig2);
// 不同消息应产生不同随机数
let sig3 = sign(secret, b"different message");
assert_ne!(sig1, sig3);
// 关键:验证随机数不是简单的 SHA512(message)
// (这需要访问内部状态或已知答案测试)
}
5. 审计所有第三方依赖
trantor SDK 由一名独立开发者发布,从未被审计。在集成任何加密依赖之前:
- 审查源代码
- 检查已知漏洞
- 验证维护者的信誉
- 考虑进行全面安全审计
开发者的核心要点
-
Ed25519 设计上是确定性的——随机数由 SHA-512(secret_prefix || message) 派生。如果省略秘密前缀,随机数就变成了公开可计算的。
-
当随机数可预测时,一条签名就足以恢复你的私钥。这比 ECDSA 随机数重用更糟糕, 后者通常需要两条签名。
-
漏洞是通过用未经审计的代码替换已审计代码引入的。加密代码不是“即插即用”的, 集成方式至关重要。
-
一旦密钥在链上被泄露,它就永远泄露了。区块链交易是不可逆的。EMURGO 警告用户,将受影响的助记词导入其他钱包并不能降低风险, 被泄露的地址必须彻底放弃。
-
攻击窗口只有短短两周(6 月 8 日至 6 月 21 日),但已有 374 个钱包被清空。当漏洞如此容易利用时,攻击者可以行动得极快。
最后的思考
这不是一次复杂的攻击。没有零日漏洞,没有钓鱼活动,没有智能合约漏洞,也没有助记词被偷。攻击者只是读取了区块链,然后做了一些算术。
正如 MyCrypto 创始人 Taylor Monahan 所指出的,这比 2011 年早期的比特币钱包漏洞还要严重。这是一次流程上的失败, 在未审查的情况下替换审计过的加密代码,未能测试集成,然后将其推送给数百万用户。
SecondFi 将永久关闭。374 名用户失去了他们的资金。一位支持 Cardano 九年的用户损失了为退休而积攒的 998,000 ADA。
所有这一切,只是因为少写了一行代码。
网硕互联帮助中心




评论前必须登录!
注册