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 次独立掷骰子创建的种子。 ## 根本原因 该漏洞并非单一缺陷,而是**四个缺陷的链条**,每一个 表面上看起来都无害。 diagram_rng_chain ## 改变了熵源的迁移 在 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 消耗)
标签:区块链安全, 密码学, 手动系统调用, 概念验证, 漏洞分析, 硬件钱包, 路径探测, 逆向工具