DK27ss/ColdCard-38M-PoC
GitHub: DK27ss/ColdCard-38M-PoC
该项目是 Coldcard 硬件钱包种子熵减弱漏洞的概念验证,完整重建并验证了从 PRNG 状态空间恢复 BIP39 助记词的攻击流程。
Stars: 7 | Forks: 6
# ColdCard-38M-PoC
## 摘要
在 2021 年 3 月(固件 4.0.0)至 2026 年 7 月期间,Coldcard 设备上的
BIP39 种子生成**未使用硬件真随机数生成器
(TRNG)**。在向 `libsecp256k1` 进行加密迁移(提交
`b18723dd`,"First pass w/ libNgU",2021-03-01)之后,种子生成路径
通过链接时的符号解析,最终连接到了**MicroPython 附带的软件
回退 PRNG**,该 PRNG 仅由设备状态和计时状态进行播种。
后果:生成种子的有效空间在 Mk2/Mk3 上坍缩为一个
通用的 **2³² 空间**(参见[模型](#model):`pad = UID ^
SysTick` 是一个 32 位字,而 RTC 是静态的——完全不需要了解特定设备的信息),而在 Mk4/Mk5/Q 上约为 **~2⁷³**(Coinkite 的元组计数
估计为 ~2⁴⁰/~2⁷²;但实际上限更低)。知道钱包公共地址的攻击者
可以重放 PRNG 状态空间,重新生成
候选种子,并通过简单的地址比较识别出正确的种子——
而完全无需触及设备。只需对整个空间进行一次扫描,并与链上每个有余额的地址进行比对,即可一次性恢复**所有**存在漏洞的钱包。
本仓库包含了该攻击的完整重建过程,并进行了
端到端验证:在 CPU 上**不到两分钟**即可完成助记词恢复,并附带
签名证明。
### 受影响版本(来源:Coinkite)
| 固件 | 硬件 | 状态 |
|---|---|---|
| 1.0.0 – 3.2.2 (→ 2021年1月14日) | Mk1, Mk2, Mk3 | 不受影响 |
| 4.0.0 – 4.1.9 | Mk2, Mk3 | **受影响 — 最坏情况** (~2⁴⁰) |
| 5.0.0 – 5.5.1 | Mk4, Mk5 | 受影响 (~2⁷²) |
| 0.0.2Q – 1.4.1Q | Q | 受影响 (~2⁷²) |
| 6.x "X"/Edge | Mk4/Mk5/Q | 受影响 (~2⁷²) |
修复版本:4.2.0 (Mk3)、5.6.0 (Mk4/Mk5)、1.5.0Q (Q)、6.6.0X / 6.6.0QX (Edge)。
**更新无法修复已生成的种子**:必须生成新种子并
转移资金。例外情况:由 ≥ 50 次独立掷骰子创建的种子。
## 根本原因
该漏洞并非单一缺陷,而是**四个缺陷的链条**,每一个
表面上看起来都无害。
## 改变了熵源的迁移
在 2021 年 3 月之前,`shared/seed.py` 通过 `ckcc.rng_bytes()`
(Coinkite 特定于开发板的 TRNG,位于 `stm32/COLDCARD/rng.c`,"more paranoid",
带有健康检查)获取熵。提交 `b18723dd` 重写了 `make_new_wallet()`:
```
# shared/seed.py @ b18723dd
import ngu, uctypes, bip39, random # before: import tcc ; from ckcc import rng_bytes
async def make_new_wallet():
# Pick a new random seed.
await ux_dramatic_pause('Generating...', 4)
# always full 24-word (256 bit) entropy
seed = random.bytes(32) # before: rng_bytes(seed)
assert len(set(seed)) > 4 # TRNG failure
# hash to mitigate possible bias in TRNG
seed = ngu.hash.sha256s(seed)
```
在 `shared/random.py` 中使用了:
```
bytes = ngu.random.bytes
```
加密选择 (libsecp256k1) 是合理的。但集成却并非如此:
没有人验证过 `ngu.random.bytes` 实际调用的是**哪个 `rng_get()` 实现**。
## 定义为零并禁用 TRNG 的宏
```
// stm32/COLDCARD/mpconfigboard.h:77
#define MICROPY_HW_ENABLE_RNG (0)
```
Peter D. Gray (Coinkite) 自己解释道:他将该宏设置为零,
以为这样就能禁用 MicroPython 的**所有** RNG 代码("我们哪个版本
都不需要"),因为 Coldcard 有自己的 TRNG。但这个宏并不控制代码
包含——它只是选择在编译 `ports/stm32/rng.c` 时**包含哪个**
`rng_get()` 实现:
```
// external/micropython @ 8d866365, ports/stm32/rng.c
#if MICROPY_HW_ENABLE_RNG
uint32_t rng_get(void) { /* ... real STM32 TRNG (RNG->DR) ... */ }
#else // MICROPY_HW_ENABLE_RNG
// For MCUs that don't have an RNG we still need to provide a rng_get()...
STATIC uint32_t pyb_rng_yasmarang(void) { /* software PRNG */ }
uint32_t rng_get(void) { return pyb_rng_yasmarang(); }
#endif
```
由 `#if MICROPY_HW_ENABLE_RNG` 保护的 Coinkite 开发板特定 TRNG
(`stm32/COLDCARD/rng.c`)也被**排除在构建之外**。
## 毫无作用的保护:`#ifndef` 与值
libngu 确实有一个安全网:
```
// external/libngu/ngu/random.c:28-30
# ifndef MICROPY_HW_ENABLE_RNG
# error "请获取一个 HW TRNG"
# endif
```
`#ifndef` 测试的是宏**是否存在**,而不是它的值。它确实
被定义了——值为零。因此 `#error` 永远不会触发,构建通过,
而 `rng_get()` 符号(在 libngu 中声明为 `extern`)在链接时解析为
MicroPython 的**软件 PRNG**。两个具有相同
签名的实现,错误地被静默选中。
## 由设备/计时状态播种的回退机制
```
// ports/stm32/rng.c (fallback), first call:
pad = *(uint32_t *)MP_HAL_UNIQUE_ID_ADDRESS ^ SysTick->VAL;
n = RTC->TR;
d = RTC->SSR;
```
- `UID`:STM32 的唯一标识符——仅使用了其低 32 位,**而且
无需知道它们的值**:`pad = UID_low32 ^ SysTick->VAL` 是一个*单一的*
32 位字,因此无论 UID 是多少,整个表达式最多只有 2³² 种结果
(Block Engineering 的分析);
- `SysTick->VAL`:可预测的周期性递减计数器——在 Mk2/Mk3 上最多约 80,000 个
值,已包含在上文的 2³² pad 空间中;
- `RTC->TR` / `RTC->SSR`:**在冷启动时静态为 0**——板子配置
设置了 `MICROPY_HW_ENABLE_RTC (0)`
(`stm32/COLDCARD/mpconfigboard.h:17`),因此
RTC 从未被初始化。已在源代码中确认,无需硬件。
剩下的唯一未知数是 32 位的 `pad` 和之前 RNG
消耗的次数(参见下文的 skip 模型)。**~40 位** 是 Coinkite 保守估计的
元组数;实际的结构上限是 2³² × (skip 候选数)。
libngu 混合器增加了**零**熵:其内部的 Yasmarang 从一个
**常量**状态(`pad=0x0a8ce26f, n=69, d=233`)启动:
```
// external/libngu/ngu/random.c - my_random_bytes()
chip = CHIP_TRNG_32(); // = rng_get() -> MicroPython fallback
chip ^= my_yasmarang(); // deterministic, fixed initial state
memcpy(dest, &chip, MIN(4, count));
```
`seed.py` 中最终的 `sha256()`("哈希以减轻偏差")也是
确定性的:它不创造熵,只是对其进行转换。
## 为什么代码审查会漏掉它
- Coinkite "偏执的" TRNG **确实存在于二进制文件中**(用于其他
用途):审查验证了它的存在和质量,却没有验证从种子生成路径
能否到达该处。
- 两个 `rng_get()` 实现共享**相同的签名**:阅读代码时看不出问题,
链接时也看不出问题。
- 该漏洞存在于**两个子模块的边界**(libngu ↔
micropython):每一侧都能编译并通过各自的测试。
- `#ifndef` 保护在代码中提供了一种**被保护的错觉**。
- Coinkite:"我们使用了目前最好的 AI 模型之一来审查我们的
代码 [...] 它没有发现这个漏洞"。
教训:对于关键的熵代码,您必须验证**端到端的符号解析**
和**调用可达性**,而不仅仅是正确代码的存在与否。
热修复正是这样做的:显式排除回退 PRNG
目标 + 进行构建时检查,确保全局 `rng_get()` 来自
特定于开发板的目标,并且回退目标不导出任何符号。
## 模型
**该攻击不需要任何特定于设备的信息。** 搜索空间是通用的
(每个受影响的设备共享):
1. `pad` —— 一个 32 位字(`UID_low32 ^ SysTick->VAL`);2³² 个值可同时覆盖
每台设备和每次启动的计时;
2. `RTC->TR = RTC->SSR = 0` —— 静态的,构建中已禁用 RTC;
3. **skip** —— `make_new_wallet` 之前的先前 RNG 消耗,由
启动路径推导得出(见下文)。
### skip 模型(已更正)
两个生成器在每次调用 `ngu.random` 时都会推进:MicroPython
回退(chip)和 libngu 混合器。精确阅读 `external/libngu/ngu/random.c`:
- `my_random_bytes()` 和 `random_uint32()` 都以 1:1 的比例推进;
- `_rand_below()`(由 `uniform`/shuffles 使用)在第一次
尝试时推进两者,但它的拒绝循环执行 `pt ^= my_yasmarang()` —— 在重试时
**仅推进混合器**。
因此,正确的模型具有**两个独立的 skip**:`chip_skip`(精确的,
确定性的)和 `mixer_skip`(chip_skip + shuffle 重试次数)。
### 从启动路径得出的 skip 值(来源于代码,无需硬件)
在设备的**首次启动**时,`nvstore` 发现所有 32 个设置插槽
都是空的,并用无用数据填充了其中 3 个(`shared/nvstore.py`):
```
shuffle(32 slots) → 31 uniform calls (chip+mixer, plus retries)
3 slots × 16 × random.bytes(256) = 3072 words (1:1)
```
因此,在第一次会话中创建的钱包(典型的休眠钱包
模式)具有:
- `chip_skip = 3072 + 31 = 3103`,完全精确;
- `mixer_skip ≈ 3103 + E[shuffle 重试次数] ≈ 3161 ± 8`(几何拒绝
采样;平均值根据 `_rand_below` 的掩码宽度逐项计算得出)。
在后续会话中创建的钱包 skip 值很小(0–~30;登录
键盘 shuffle 默认是关闭的)。没有其他启动路径消费者触及
RNG(已验证:selftest、ux、display、menu、pincodes 都是干净的)。
### 流水线(未更改,已在 `v4` 分支上确认)
```
candidate (pad, chip_skip, mixer_skip)
→ MicroPython fallback seeded by pad, n=0, d=0
→ advance chip by chip_skip, mixer by mixer_skip
→ 8 × (rng_get() ⊕ libngu yasmarang) = random.bytes(32)
→ sha256s (single SHA-256) = BIP39 entropy (seed.py @v4)
→ 24-word mnemonic → PBKDF2-HMAC-SHA512 (×2048) = BIP39 seed
→ BIP32 m/84'/0'/0'/chain/index → bech32 address
→ compare with every funded address at once
```
v4 分支(Mk2/Mk3 固件 4.0.x–4.1.9)经过了逐行重新检查:
`random.bytes(32)` → `assert` → `sha256s` → 24 个字,与
存在漏洞的提交相比未作更改。(`sha256d` 以及 12/18 个字的种子仅存在于稍后的
Mk4/Q 代码中——与 Mk2/Mk3 人群无关。)
每个候选者的成本主要由 PBKDF2 决定(CPU 上约 0.9 毫秒)。地址匹配
**可以明确地识别出种子**并得出私钥。
## 2³² 和 2⁷³ 意味着什么
| 场景 | 空间 | 实际成本 |
|---|---|---|
| 标称 BIP39 目标 | 2¹²⁸ – 2²⁵⁶ | 物理上无法触及 |
| Mk2/Mk3,通用(任何设备,无需了解信息) | **2³² × skips** | 在小型 GPU 集群上需数小时;在租用的 2 块 GPU 上需约 1-2 天 |
| Mk2/Mk3,具有已知 UID 的单个钱包 | ~2¹⁶·³ (80,000 SysTick) | 在 CPU 上几秒钟 |
| Mk4/Mk5/Q(32 位重播种 × 回退) | ≤ 2⁷³ 上限 | 资源非常充足的攻击者;仍低于 128 位目标 |
通用的 2³² 空间是实际攻击的关键:一次枚举,
与链上每个有余额地址的布隆过滤器进行比对,即可同时恢复
**所有**存在漏洞的钱包。这就是为什么在一个晚上,在没有任何设备特定知识和没有长期运动战的情况下,约 500 个钱包会沦陷的原因。
## 保真度
`vuln_rng.py` 的每一行都映射到可验证的源代码:
- `MicroPythonFallbackRNG` ← `ports/stm32/rng.c` @ `8d866365`
(Coldcard/micropython),`#else MICROPY_HW_ENABLE_RNG` 分支;
- `LibNguYasmarang` / `ngu_random_bytes` ← `external/libngu/ngu/random.c` @
`2fbfefb1`,函数 `my_yasmarang()` 和 `my_random_bytes()`(包括
`chip == last → EFAULT` 检查以及 4 字节块中的小端序 `memcpy`);
- **skip 推进** ← 同一文件:先前的消耗以 1:1 推进 chip **和**
mixer(`my_random_bytes`, `random_uint32`),而在 `_rand_below()` 重试时
仅推进 mixer(`pt ^= my_yasmarang()`);
- `vulnerable_seed_entropy` ← `shared/seed.py::make_new_wallet()` @
`b18723dd`(`random.bytes(32)`, `assert len(set(seed)) > 4`, `sha256s`),
在 `v` 分支(固件 4.0.x–4.1.9)上重新确认完全一致。
三个实现之间进行了逐位交叉验证:
`vuln_rng.py` (Python)、`go_poc/` (Go)、`gpu_poc/` (CUDA),通过涵盖
每个流水线阶段(raw32、entropy、mnemonic、
seed64、privkey、hash160、address——包括分离的 chip/mixer skip)的已发布测试向量进行验证。
## 2026 年 7 月 30 日的盗窃案——现实世界的验证
在 2026 年 7 月 30 日 01:10 至 01:56 UTC 期间,一名未知攻击者清空了
**来自 501 个单签名钱包的 594.48 BTC(约 3800 万美元)**,然后对这些
资金进行了合并。此处重现的链上取证(`scan_sweep_raw.py`,
`victims_full.txt`):
- **合并地址**:`bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0`
——在**501 笔资金交易中准确接收了 594.47723261 BTC,每个
受害者地址一笔**;
- **指纹**:每笔扫描交易支付约 30 sat/vB,恰好有一个输出
(无找零),仅花费单签名前序输出——预计算的密钥由
自动化工具花费;
- **受害者特征**:2021–2026 年间注资的休眠钱包,例如
`bc1q8s0xt9rz04cumw6jesccqncnajrnvh36jmh6qg`(注资于 2022-08-11,被清空于
2026-07-30 01:43 UTC);
- **地址类型**:491× P2WPKH (BIP84)、5× P2PKH (`1…`, BIP44)、5×
P2SH-P2WPKH (`3…`, BIP49)——攻击者为每个种子推导了**多个脚本路径**,并为每个账户推导了多个地址索引(部分钱包
仅被部分清空,表明索引范围是有限的)。
我们的扫描器使用的受害者名单直接提取自合并地址的 501 笔
资金交易——即实际受到攻击的地址
精确集合。
## PoC 实现
| 路径 | 作用 |
|---|---|
| `vuln_rng.py` / `wallet_lib.py` | Python 参考副本(RNG 链,BIP39/32) |
| `make_victim.py` / `recover.py` | 原始的自有设备演示(旧版 skip 模型) |
| `go_poc/` | Go 移植版:多地址批处理,`-parallel`,**通用 pad 模式** |
| `gpu_poc/` | CUDA 扫描器:PBKDF2+BIP32+secp256k1 内核,多索引(`--indices`, `--change`),**分离的 chip/mixer skip** (`--chip-skip`)、检查点、受害者布隆匹配 |
| `scan_sweep.py` / `scan_sweep_raw.py` | 链上盗窃取证(区块下载 + 指纹过滤) |
| `victims_full.txt` | 501 个真实受害者地址(+对照组) |
### 实时验证的状态(GPU 扫描 vs 真实受害者)
- 推导流水线:**位精确**,已在设备上验证(在每个阶段恢复了
控制状态,并附有 ECDSA 证明);
- 截至目前在租用的 2× RTX PRO 6000 上进行的探测:skip 0–7、skip=3103
(锁步模型),随后分离 chip=3103 / mixer∈[3135,3195) —— 截至撰稿时**尚未恢复
真实的受害者**;覆盖率仍然只是 2³² pad 空间的
一小部分,且 skip 窗口可能仍然与受害者实际的
启动历史不匹配;
- CUDA 内核目前达到约 0.14 Mcand/s/GPU(理论峰值的 4%;ptxas 显示
有 2.7 KB 的堆栈帧和大量溢出——有 3-5 倍的优化
空间)。
### 关于旧版演示的说明
`make_victim.py` / `recover.py` 及其制品(`target.json`,
`victim_secret.json`)早于 skip 模型的更正:它们使用的是旧版的
仅 chip skip 推进。使用更正后的
`vuln_rng.py` 重新生成受害者,对于任何大于 0 的 skip 都会产生不同的
助记词;skip = 0 则保持不变。演示的结论(仅从地址即可
完全恢复)不受影响——现在该模型对于任何
启动历史都仅仅是完全精确的。
## 为什么这很严重
- **无需物理访问**:公开地址就足够了。
- **溯及既往**:几年前由受影响的固件生成的任何种子
仍然可以被破解,即使在固件更新之后也是如此。
- **隐蔽性**:设备"正常"工作;防故障测试
(`assert len(set(seed)) > 4`)通过,助记词有效,一切
看起来都很健康。
- **有利可图**:2³² 的通用攻击成本不到约 1,000 美元的云
GPU——远低于许多钱包的价值;这是一个经济问题,
而不是理论问题。
- **开源 ≠ 已审计**:该代码自 2021 年起就已经公开;该漏洞
存在于两个子模块的组合中,对于逐文件的
审查来说是不可见的。
## 参考
- [Coinkite — 熵技术背景](https://blog.coinkite.com/entropy-technical-backgrounder/)
- LLFOURN — *受影响 COLDCARD 世代的攻击成本模型*(由 Coinkite 链接)
- [Block — *COLDCARD 固件中可预测的 RNG 回退和 32 位重播种*](https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware)
- 存在漏洞的提交:`b18723dddb6d751c39978e4364b56b2414f68b47`
(Coldcard/firmware, 2021-03-01, "First pass w/ libNgU")
- 关键代码:`shared/seed.py`, `shared/random.py:11`、
`stm32/COLDCARD/mpconfigboard.h:17` (`MICROPY_HW_ENABLE_RTC`) 以及 `:77`
(`MICROPY_HW_ENABLE_RNG`)、`external/libngu/ngu/random.c:24-30`、
`ports/stm32/rng.c` (micropython @ `8d866365`)、`shared/nvstore.py:205-215`
(首次启动时的 RNG 消耗)
## 改变了熵源的迁移
在 2021 年 3 月之前,`shared/seed.py` 通过 `ckcc.rng_bytes()`
(Coinkite 特定于开发板的 TRNG,位于 `stm32/COLDCARD/rng.c`,"more paranoid",
带有健康检查)获取熵。提交 `b18723dd` 重写了 `make_new_wallet()`:
```
# shared/seed.py @ b18723dd
import ngu, uctypes, bip39, random # before: import tcc ; from ckcc import rng_bytes
async def make_new_wallet():
# Pick a new random seed.
await ux_dramatic_pause('Generating...', 4)
# always full 24-word (256 bit) entropy
seed = random.bytes(32) # before: rng_bytes(seed)
assert len(set(seed)) > 4 # TRNG failure
# hash to mitigate possible bias in TRNG
seed = ngu.hash.sha256s(seed)
```
在 `shared/random.py` 中使用了:
```
bytes = ngu.random.bytes
```
加密选择 (libsecp256k1) 是合理的。但集成却并非如此:
没有人验证过 `ngu.random.bytes` 实际调用的是**哪个 `rng_get()` 实现**。
## 定义为零并禁用 TRNG 的宏
```
// stm32/COLDCARD/mpconfigboard.h:77
#define MICROPY_HW_ENABLE_RNG (0)
```
Peter D. Gray (Coinkite) 自己解释道:他将该宏设置为零,
以为这样就能禁用 MicroPython 的**所有** RNG 代码("我们哪个版本
都不需要"),因为 Coldcard 有自己的 TRNG。但这个宏并不控制代码
包含——它只是选择在编译 `ports/stm32/rng.c` 时**包含哪个**
`rng_get()` 实现:
```
// external/micropython @ 8d866365, ports/stm32/rng.c
#if MICROPY_HW_ENABLE_RNG
uint32_t rng_get(void) { /* ... real STM32 TRNG (RNG->DR) ... */ }
#else // MICROPY_HW_ENABLE_RNG
// For MCUs that don't have an RNG we still need to provide a rng_get()...
STATIC uint32_t pyb_rng_yasmarang(void) { /* software PRNG */ }
uint32_t rng_get(void) { return pyb_rng_yasmarang(); }
#endif
```
由 `#if MICROPY_HW_ENABLE_RNG` 保护的 Coinkite 开发板特定 TRNG
(`stm32/COLDCARD/rng.c`)也被**排除在构建之外**。
## 毫无作用的保护:`#ifndef` 与值
libngu 确实有一个安全网:
```
// external/libngu/ngu/random.c:28-30
# ifndef MICROPY_HW_ENABLE_RNG
# error "请获取一个 HW TRNG"
# endif
```
`#ifndef` 测试的是宏**是否存在**,而不是它的值。它确实
被定义了——值为零。因此 `#error` 永远不会触发,构建通过,
而 `rng_get()` 符号(在 libngu 中声明为 `extern`)在链接时解析为
MicroPython 的**软件 PRNG**。两个具有相同
签名的实现,错误地被静默选中。
## 由设备/计时状态播种的回退机制
```
// ports/stm32/rng.c (fallback), first call:
pad = *(uint32_t *)MP_HAL_UNIQUE_ID_ADDRESS ^ SysTick->VAL;
n = RTC->TR;
d = RTC->SSR;
```
- `UID`:STM32 的唯一标识符——仅使用了其低 32 位,**而且
无需知道它们的值**:`pad = UID_low32 ^ SysTick->VAL` 是一个*单一的*
32 位字,因此无论 UID 是多少,整个表达式最多只有 2³² 种结果
(Block Engineering 的分析);
- `SysTick->VAL`:可预测的周期性递减计数器——在 Mk2/Mk3 上最多约 80,000 个
值,已包含在上文的 2³² pad 空间中;
- `RTC->TR` / `RTC->SSR`:**在冷启动时静态为 0**——板子配置
设置了 `MICROPY_HW_ENABLE_RTC (0)`
(`stm32/COLDCARD/mpconfigboard.h:17`),因此
RTC 从未被初始化。已在源代码中确认,无需硬件。
剩下的唯一未知数是 32 位的 `pad` 和之前 RNG
消耗的次数(参见下文的 skip 模型)。**~40 位** 是 Coinkite 保守估计的
元组数;实际的结构上限是 2³² × (skip 候选数)。
libngu 混合器增加了**零**熵:其内部的 Yasmarang 从一个
**常量**状态(`pad=0x0a8ce26f, n=69, d=233`)启动:
```
// external/libngu/ngu/random.c - my_random_bytes()
chip = CHIP_TRNG_32(); // = rng_get() -> MicroPython fallback
chip ^= my_yasmarang(); // deterministic, fixed initial state
memcpy(dest, &chip, MIN(4, count));
```
`seed.py` 中最终的 `sha256()`("哈希以减轻偏差")也是
确定性的:它不创造熵,只是对其进行转换。
## 为什么代码审查会漏掉它
- Coinkite "偏执的" TRNG **确实存在于二进制文件中**(用于其他
用途):审查验证了它的存在和质量,却没有验证从种子生成路径
能否到达该处。
- 两个 `rng_get()` 实现共享**相同的签名**:阅读代码时看不出问题,
链接时也看不出问题。
- 该漏洞存在于**两个子模块的边界**(libngu ↔
micropython):每一侧都能编译并通过各自的测试。
- `#ifndef` 保护在代码中提供了一种**被保护的错觉**。
- Coinkite:"我们使用了目前最好的 AI 模型之一来审查我们的
代码 [...] 它没有发现这个漏洞"。
教训:对于关键的熵代码,您必须验证**端到端的符号解析**
和**调用可达性**,而不仅仅是正确代码的存在与否。
热修复正是这样做的:显式排除回退 PRNG
目标 + 进行构建时检查,确保全局 `rng_get()` 来自
特定于开发板的目标,并且回退目标不导出任何符号。
## 模型
**该攻击不需要任何特定于设备的信息。** 搜索空间是通用的
(每个受影响的设备共享):
1. `pad` —— 一个 32 位字(`UID_low32 ^ SysTick->VAL`);2³² 个值可同时覆盖
每台设备和每次启动的计时;
2. `RTC->TR = RTC->SSR = 0` —— 静态的,构建中已禁用 RTC;
3. **skip** —— `make_new_wallet` 之前的先前 RNG 消耗,由
启动路径推导得出(见下文)。
### skip 模型(已更正)
两个生成器在每次调用 `ngu.random` 时都会推进:MicroPython
回退(chip)和 libngu 混合器。精确阅读 `external/libngu/ngu/random.c`:
- `my_random_bytes()` 和 `random_uint32()` 都以 1:1 的比例推进;
- `_rand_below()`(由 `uniform`/shuffles 使用)在第一次
尝试时推进两者,但它的拒绝循环执行 `pt ^= my_yasmarang()` —— 在重试时
**仅推进混合器**。
因此,正确的模型具有**两个独立的 skip**:`chip_skip`(精确的,
确定性的)和 `mixer_skip`(chip_skip + shuffle 重试次数)。
### 从启动路径得出的 skip 值(来源于代码,无需硬件)
在设备的**首次启动**时,`nvstore` 发现所有 32 个设置插槽
都是空的,并用无用数据填充了其中 3 个(`shared/nvstore.py`):
```
shuffle(32 slots) → 31 uniform calls (chip+mixer, plus retries)
3 slots × 16 × random.bytes(256) = 3072 words (1:1)
```
因此,在第一次会话中创建的钱包(典型的休眠钱包
模式)具有:
- `chip_skip = 3072 + 31 = 3103`,完全精确;
- `mixer_skip ≈ 3103 + E[shuffle 重试次数] ≈ 3161 ± 8`(几何拒绝
采样;平均值根据 `_rand_below` 的掩码宽度逐项计算得出)。
在后续会话中创建的钱包 skip 值很小(0–~30;登录
键盘 shuffle 默认是关闭的)。没有其他启动路径消费者触及
RNG(已验证:selftest、ux、display、menu、pincodes 都是干净的)。
### 流水线(未更改,已在 `v4` 分支上确认)
```
candidate (pad, chip_skip, mixer_skip)
→ MicroPython fallback seeded by pad, n=0, d=0
→ advance chip by chip_skip, mixer by mixer_skip
→ 8 × (rng_get() ⊕ libngu yasmarang) = random.bytes(32)
→ sha256s (single SHA-256) = BIP39 entropy (seed.py @v4)
→ 24-word mnemonic → PBKDF2-HMAC-SHA512 (×2048) = BIP39 seed
→ BIP32 m/84'/0'/0'/chain/index → bech32 address
→ compare with every funded address at once
```
v4 分支(Mk2/Mk3 固件 4.0.x–4.1.9)经过了逐行重新检查:
`random.bytes(32)` → `assert` → `sha256s` → 24 个字,与
存在漏洞的提交相比未作更改。(`sha256d` 以及 12/18 个字的种子仅存在于稍后的
Mk4/Q 代码中——与 Mk2/Mk3 人群无关。)
每个候选者的成本主要由 PBKDF2 决定(CPU 上约 0.9 毫秒)。地址匹配
**可以明确地识别出种子**并得出私钥。
## 2³² 和 2⁷³ 意味着什么
| 场景 | 空间 | 实际成本 |
|---|---|---|
| 标称 BIP39 目标 | 2¹²⁸ – 2²⁵⁶ | 物理上无法触及 |
| Mk2/Mk3,通用(任何设备,无需了解信息) | **2³² × skips** | 在小型 GPU 集群上需数小时;在租用的 2 块 GPU 上需约 1-2 天 |
| Mk2/Mk3,具有已知 UID 的单个钱包 | ~2¹⁶·³ (80,000 SysTick) | 在 CPU 上几秒钟 |
| Mk4/Mk5/Q(32 位重播种 × 回退) | ≤ 2⁷³ 上限 | 资源非常充足的攻击者;仍低于 128 位目标 |
通用的 2³² 空间是实际攻击的关键:一次枚举,
与链上每个有余额地址的布隆过滤器进行比对,即可同时恢复
**所有**存在漏洞的钱包。这就是为什么在一个晚上,在没有任何设备特定知识和没有长期运动战的情况下,约 500 个钱包会沦陷的原因。
## 保真度
`vuln_rng.py` 的每一行都映射到可验证的源代码:
- `MicroPythonFallbackRNG` ← `ports/stm32/rng.c` @ `8d866365`
(Coldcard/micropython),`#else MICROPY_HW_ENABLE_RNG` 分支;
- `LibNguYasmarang` / `ngu_random_bytes` ← `external/libngu/ngu/random.c` @
`2fbfefb1`,函数 `my_yasmarang()` 和 `my_random_bytes()`(包括
`chip == last → EFAULT` 检查以及 4 字节块中的小端序 `memcpy`);
- **skip 推进** ← 同一文件:先前的消耗以 1:1 推进 chip **和**
mixer(`my_random_bytes`, `random_uint32`),而在 `_rand_below()` 重试时
仅推进 mixer(`pt ^= my_yasmarang()`);
- `vulnerable_seed_entropy` ← `shared/seed.py::make_new_wallet()` @
`b18723dd`(`random.bytes(32)`, `assert len(set(seed)) > 4`, `sha256s`),
在 `v` 分支(固件 4.0.x–4.1.9)上重新确认完全一致。
三个实现之间进行了逐位交叉验证:
`vuln_rng.py` (Python)、`go_poc/` (Go)、`gpu_poc/` (CUDA),通过涵盖
每个流水线阶段(raw32、entropy、mnemonic、
seed64、privkey、hash160、address——包括分离的 chip/mixer skip)的已发布测试向量进行验证。
## 2026 年 7 月 30 日的盗窃案——现实世界的验证
在 2026 年 7 月 30 日 01:10 至 01:56 UTC 期间,一名未知攻击者清空了
**来自 501 个单签名钱包的 594.48 BTC(约 3800 万美元)**,然后对这些
资金进行了合并。此处重现的链上取证(`scan_sweep_raw.py`,
`victims_full.txt`):
- **合并地址**:`bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0`
——在**501 笔资金交易中准确接收了 594.47723261 BTC,每个
受害者地址一笔**;
- **指纹**:每笔扫描交易支付约 30 sat/vB,恰好有一个输出
(无找零),仅花费单签名前序输出——预计算的密钥由
自动化工具花费;
- **受害者特征**:2021–2026 年间注资的休眠钱包,例如
`bc1q8s0xt9rz04cumw6jesccqncnajrnvh36jmh6qg`(注资于 2022-08-11,被清空于
2026-07-30 01:43 UTC);
- **地址类型**:491× P2WPKH (BIP84)、5× P2PKH (`1…`, BIP44)、5×
P2SH-P2WPKH (`3…`, BIP49)——攻击者为每个种子推导了**多个脚本路径**,并为每个账户推导了多个地址索引(部分钱包
仅被部分清空,表明索引范围是有限的)。
我们的扫描器使用的受害者名单直接提取自合并地址的 501 笔
资金交易——即实际受到攻击的地址
精确集合。
## PoC 实现
| 路径 | 作用 |
|---|---|
| `vuln_rng.py` / `wallet_lib.py` | Python 参考副本(RNG 链,BIP39/32) |
| `make_victim.py` / `recover.py` | 原始的自有设备演示(旧版 skip 模型) |
| `go_poc/` | Go 移植版:多地址批处理,`-parallel`,**通用 pad 模式** |
| `gpu_poc/` | CUDA 扫描器:PBKDF2+BIP32+secp256k1 内核,多索引(`--indices`, `--change`),**分离的 chip/mixer skip** (`--chip-skip`)、检查点、受害者布隆匹配 |
| `scan_sweep.py` / `scan_sweep_raw.py` | 链上盗窃取证(区块下载 + 指纹过滤) |
| `victims_full.txt` | 501 个真实受害者地址(+对照组) |
### 实时验证的状态(GPU 扫描 vs 真实受害者)
- 推导流水线:**位精确**,已在设备上验证(在每个阶段恢复了
控制状态,并附有 ECDSA 证明);
- 截至目前在租用的 2× RTX PRO 6000 上进行的探测:skip 0–7、skip=3103
(锁步模型),随后分离 chip=3103 / mixer∈[3135,3195) —— 截至撰稿时**尚未恢复
真实的受害者**;覆盖率仍然只是 2³² pad 空间的
一小部分,且 skip 窗口可能仍然与受害者实际的
启动历史不匹配;
- CUDA 内核目前达到约 0.14 Mcand/s/GPU(理论峰值的 4%;ptxas 显示
有 2.7 KB 的堆栈帧和大量溢出——有 3-5 倍的优化
空间)。
### 关于旧版演示的说明
`make_victim.py` / `recover.py` 及其制品(`target.json`,
`victim_secret.json`)早于 skip 模型的更正:它们使用的是旧版的
仅 chip skip 推进。使用更正后的
`vuln_rng.py` 重新生成受害者,对于任何大于 0 的 skip 都会产生不同的
助记词;skip = 0 则保持不变。演示的结论(仅从地址即可
完全恢复)不受影响——现在该模型对于任何
启动历史都仅仅是完全精确的。
## 为什么这很严重
- **无需物理访问**:公开地址就足够了。
- **溯及既往**:几年前由受影响的固件生成的任何种子
仍然可以被破解,即使在固件更新之后也是如此。
- **隐蔽性**:设备"正常"工作;防故障测试
(`assert len(set(seed)) > 4`)通过,助记词有效,一切
看起来都很健康。
- **有利可图**:2³² 的通用攻击成本不到约 1,000 美元的云
GPU——远低于许多钱包的价值;这是一个经济问题,
而不是理论问题。
- **开源 ≠ 已审计**:该代码自 2021 年起就已经公开;该漏洞
存在于两个子模块的组合中,对于逐文件的
审查来说是不可见的。
## 参考
- [Coinkite — 熵技术背景](https://blog.coinkite.com/entropy-technical-backgrounder/)
- LLFOURN — *受影响 COLDCARD 世代的攻击成本模型*(由 Coinkite 链接)
- [Block — *COLDCARD 固件中可预测的 RNG 回退和 32 位重播种*](https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware)
- 存在漏洞的提交:`b18723dddb6d751c39978e4364b56b2414f68b47`
(Coldcard/firmware, 2021-03-01, "First pass w/ libNgU")
- 关键代码:`shared/seed.py`, `shared/random.py:11`、
`stm32/COLDCARD/mpconfigboard.h:17` (`MICROPY_HW_ENABLE_RTC`) 以及 `:77`
(`MICROPY_HW_ENABLE_RNG`)、`external/libngu/ngu/random.c:24-30`、
`ports/stm32/rng.c` (micropython @ `8d866365`)、`shared/nvstore.py:205-215`
(首次启动时的 RNG 消耗)标签:区块链安全, 密码学, 手动系统调用, 概念验证, 漏洞分析, 硬件钱包, 路径探测, 逆向工具