作者:Johan & Lisa

编辑:77

本文人机共赏,导入 AI 即可复现。

背景

2026 年 7 月 30 日,链上有一批地址连续往外转,41 分钟,1,196 个单签地址被清空,约 1,082 枚比特币消失。这只是第一波。截至 8 月上旬,确认的损失至少 1,719 枚,约合 1.11 亿美元,涉及的地址超过 5,200 个,攻击前后分了三到四波。

最先让人想不通的是这些钱包的状态。钱大多躺在冷钱包里,有的几个月、几年没人动过,丢的是私钥这一层。私钥全部由 Coldcard 硬件钱包生成,主人没有额外点击过骰子熵,也没有开 BIP-39 passphrase。中招的都是最省事的用法。

影响范围按固件线分开看。Mk2、Mk3 的固件版本 4.0.1 到 4.1.9,有效熵只有大约 40 bit,最严重。Mk4、Mk5、Q 在特定固件版本上是大约 72 bit,仍然在离线爆破的范围内。

Coinkite 的反应不慢。7 月 30 日发出警告,第二天就放出修复固件,Mk2 和 Mk3 升到 4.2.0 以上,Mk4、Mk5 到 5.6.0 以上,Q 到 1.5.0Q 以上,同时销毁了漏洞版本的库存,暂停发货。

这场攻击跟远程入侵、供应链投毒都没有关系。攻击者没有碰过任何一台设备,他拿到私钥靠的是离线枚举,把种子的搜索空间一个接一个试过去。漏洞在设备端潜伏了好几年,直到外部研究者写出爆破器才浮出水面。慢雾随后把整条攻击链复现了一遍,这篇文章以 Mk3 固件 4.1.9 为例,为你还原数千枚比特币失窃的真相。

漏洞根因

Coldcard 生成种子,本来应该走 STM32L475 芯片自带的硬件 TRNG,也就是真随机数发生器。实际固件里,两处失误叠在一起,把种子变成了一台全球所有设备状态几乎相同的软件 PRNG。它叫 Yasmarang,Mk3 上的有效熵只剩大约 40 bit。

失误一 硬件 RNG 被主动关掉

第一处失误在构建配置里。Coldcard 在 mpconfigboard.h 中把 MicroPython 的硬件 RNG 支持显式关掉了。

// stm32/COLDCARD/mpconfigboard.h// ⚠️ 团队认为自己包装了 ckcc.rng_bytes,所以关掉 MicroPython 自带的#define MICROPY_HW_ENABLE_RNG (0)

第一次看到这行宏,会觉得没什么问题。Coinkite 有自己的 ckcc.rng_bytes,直接调 STM32 硬件 TRNG,比 MicroPython 移植层的实现更可控。坑在下面这个宏展开里。libngu 的 my_random_bytes() 通过 CHIP_TRNG_32() 这个宏去读随机数,宏展开后调的是 MicroPython STM32 移植层提供的 rng_get(),并且把它当成硬件 TRNG 用。而 rng_get() 正好在 MICROPY_HW_ENABLE_RNG 等于 0 的时候静默降级,掉进下一处失误。

失误二 rng_get() 的兜底被当成硬件 TRNG 用

我们最先盯着 libngu 的 random.c 看。宏展开一路追下去,最后停在 MicroPython 的 STM32 移植层,ports/stm32/rng.c 的 else 分支。

// external/libngu/ngu/random.c#ifdef MICROPY_PY_STM// ports/stm32/rng.cexternuint32_trng_get(void);# define CHIP_TRNG_SETUP()# define CHIP_TRNG_32() rng_get()#endif...voidmy_random_bytes(uint8_t *dest, uint32_t count){uint32_t chip = CHIP_TRNG_32(); // ⚠️ 以为在读硬件 TRNG, 实际可能拿到 Yasmarang 输出if (chip == last) ... // 连续两个字相同则报错 chip ^= my_yasmarang(); // 再异或一条全局常量 Yasmarang 流 (pad=0x0a8ce26f) ...}

rng_get() 由 ports/stm32/rng.c 提供。它用 #if MICROPY_HW_ENABLE_RNG 做值判断,宏非 0 就读 RNG->DR,也就是硬件 TRNG 本身。宏为 0 的时候,编译进 else 分支,返回的是软件 PRNG 的输出。Coldcard 的配置正好是 0,每一台出厂的 Mk3 都走这条路。

// external/micropython/ports/stm32/rng.c#if MICROPY_HW_ENABLE_RNGuint32_trng_get(void){ ...读 RNG->DR 硬件 TRNG...}#else// MICROPY_HW_ENABLE_RNG// ⚠️ 漏洞点: 兜底存在, 且种子几乎全部可预测staticuint32_tpyb_rng_yasmarang(void){ staticbool seeded = false; staticuint32_t pad = 0, n = 0, d = 0; staticuint8_t dat = 0; if (!seeded) { seeded = true; rtc_init_finalise(); pad = *(uint32_t *)MP_HAL_UNIQUE_ID_ADDRESS ^ SysTick->VAL; // pad = UID ^ SysTick n = RTC->TR; // n = RTC_TR d = RTC->SSR; // d = RTC_SSR } pad += dat + d * n; pad = (pad << pad="">> 29); n = pad | 2; d ^= (pad << pad="">> 1); dat ^= (char)pad ^ (d >> 8) ^ 1; return pad ^ (d << pad="">> 18) ^ (dat << 1);}uint32_trng_get(void){ return pyb_rng_yasmarang(); }#endif

这个兜底叫 Yasmarang,上世纪九十年代在论坛上流传的伪随机数发生器,不属于密码学算法,内部状态只有 128 bit,初始种子几乎全部来自可预测量。libngu 每读一次"硬件随机数",拿到的都是 Yasmarang 的输出,再异或上 libngu 自己那条全局常量流。绕到最后,整份"随机性"只剩 pad = UID ^ SysTick 这一个 32 bit 的值。

Yasmarang 的初始状态,几乎是全球统一常量

Yasmarang 的状态由四个 32 bit 变量组成,pad、n、d、dat。Mk3 上有两条 Yasmarang 实例,初始化如下。

libngu 静态实例 (ngu/random.c): pad = 0x0a8ce26f n = 69 d = 233 dat = 0 ← 全球所有设备完全相同rng_get() 兜底实例 (ports/stm32/rng.c): pad = UID ^ SysTick n = RTC_TR d = RTC_SSR dat = 0 ↑ ~16 bit 熵 ↑ 命中为 0 ↑ 命中为 0 (0xFF 只是上限校验)

libngu 那一路是编译期常量。0x0a8ce26f 写在 ngu/random.c 里,static uint32_t yasmarang_pad = 0x0a8ce26f, yasmarang_n = 69, yasmarang_d = 233,没有任何运行时输入。它跟 Yasmarang 原版的默认值不一样。原作者 Ilya Levin 的原始默认是 0xeda4baba,MicroPython 的 modurandom.c 里保留的正是原值。Coinkite 在 libngu 里把 pad 换成了 0x0a8ce26f,n 和 d 沿用原版。对攻击者来说,无论原版还是自定义值,都是公开可知的固定常量。这条流可以离线预计算,等于全球所有设备共享同一条 mixer 序列。

真正的变量只剩 rng_get() 兜底实例的 pad = UID ^ SysTick,拆开看是三块。

UID 是芯片熔丝里的 96 bit 序列号,固件只取低 32 位,读出格式是 (wafer_Y << 16) | wafer_X。X、Y 是晶圆坐标,Phase A 批次的取值几乎全部落在 0 到 72 区间,最多也就 0 到 255,等效熵大约 14 bit。

SysTick 是 rng_get() 第一次被调用时 SysTick->VAL 的倒计数值。芯片跑在 80 MHz,reload 是 80000,取值在 0 到 79999,等效熵大约 17 bit。

RTC_TR 和 RTC_SSR 是设备刚上电时 RTC 寄存器的值,应用还没初始化它。这两项各自算一个额外枚举维度,取值空间很小。rtc_tr 是 packed-BCD 的 RTC->TR,rtc_ssr 的上限是 MK3_RTC_SSR_MAX,0xFF。我们确认过的全部链上命中向量里,这两项都是 0。

把三块加起来,UID 大约 14 bit,SysTick 大约 17 bit,RTC 两项命中为 0,按键次数再贡献约 5 bit,整个种子生成的真实熵源是 36 到 37 bit。Coinkite 官方说的 about 40 bits,和代码逆向出的搜索空间完全吻合。这是一个 GPU 集群几天就能穷举完的空间。

攻击流程

Mk3 的首次开机,从代码到私钥,会依次经过三种典型的随机模式。攻击者的核心工作,就是在这些模式上分别建模,把 PRNG 的每一次 step 精确复原。

第一阶段 初始状态

设备上电后立刻发生两件事。

上电 └── libngu 静态 Yasmarang (ngu/random.c) pad=0x0a8ce26f n=69 d=233 dat=0 ← 全局常量 └── rng_get() 首次调用播种 (ports/stm32/rng.c) pad=UID^SysTick n=RTC_TR=0 d=RTC_SSR=0 dat=0

到这里,全部"随机性"已经被压进一个 32 bit 的 pad,外加两个可以枚举的小维度。全球 Mk3 的 pad 分布在一个可以穷举的小盒子里。

第二阶段 按键操作

首次开机会强制用户设置 PIN、按 OK 确认条款、在菜单里导航。每一次按键都会触发 shared/mempad.py 的 _start_scan(),其中 shuffle(self.scan_order) 调用三次 _rand_below()。scan_order 的长度等于 NUM_ROWS,也就是 4。shuffle 定义在 shared/random.py,它的 randbelow 直接绑定 libngu 的 ngu.random.uniform,也就是 C 层的 _rand_below。

# shared/random.pyimport ngurandbelow = ngu.random.uniform # 即 libngu random.c 的 _rand_belowbytes = ngu.random.bytes # 即 my_random_bytesdefshuffle(lst): # Fisher-Yates, 同 CPython random.py for i inreversed(range(1, len(lst))): j = randbelow(i + 1) # ← 每次消费 1 步 chip + 1 步 mixer(含拒绝采样重试) lst[i], lst[j] = lst[j], lst[i]# shared/mempad.py# 每次按键 -> _start_scan() -> shuffle(self.scan_order) # scan_order 长度 = NUM_ROWS = 4

这些按键消耗必须逐一建模。我们分析 v4.1.9 固件源代码后,确认了三种使用模式。

Profile A 是零售首次启动。接受条款前按两下,settings.save() 在 32 个槽里找空位,排除 my_pos 后剩下 31 个槽,也就是 30 次 _rand_below,然后输入 PIN、导航菜单再按若干次,最后 random_bytes(32) 取熵。

kpad_a × shuffle(4) # accept_terms 前按键 (kpad_a = 2)shuffle(31) # settings.save() 在 32 个槽中找空位, 排除 my_pos 后 31 槽 = 30 次 _rand_belowkpad_b × shuffle(4) # PIN 输入 + 菜单导航 (kpad_b 枚举 [4, 34])random_bytes(32) # 取熵

Profile B 是刷机或清空后的首次启动,NVRAM 是空白的。nvstore 先做一次 shuffle(32),然后 3 槽 × 16 块 × 256 字节的 blanking 以 lockstep 方式消费 3072 步,输出不回流,只推进状态。空白 NVRAM 引导会在这两步之后再多做一次 shuffle(32),然后按键消耗,最后 my_random_bytes(32)。

nvstore shuffle(32) # 31 次 _rand_belownvstore blanking 3×16×256B # 3 槽 × 16 块 × 256B = 3072 次 lockstep 消费, 输出不回流(只推进状态)empty-nvram-onboarding 剖面 # 空白 NVRAM 引导: 上面两步后再多一次 shuffle(32)按键消耗 (kpad_b 枚举 [4, 34]) # 每次 shuffle(4)my_random_bytes(32)

Profile C 是纸钱包。纸钱包就是把私钥印在纸上的冷存储方式,离线放着不碰网络,送人礼物或者长期保存都有人用。Coldcard 的 Paper Wallets 菜单专门生成这种可打印的钱包页。它的随机数消费最直接,已有钱包之后再次进入菜单,按键消耗 8 到 25 次,然后 my_random_bytes(32) 直接当作私钥用,连 BIP-39 词表都不走。

第三阶段 种子构造与地址派生

从 random_bytes(32) 到最终的比特币地址,Mk3 走的是这条流水线。

raw_bytes = random_bytes(32) # 8 次 (mpy_step ^ yas_step) 拼接 4 字节entropy = ngu.hash.sha256s(raw_bytes) # ⚠️ 单次 SHA-256,不是 sha256dmnemonic = BIP-39(entropy, wordlist=english) # 24 词seed = PBKDF2-HMAC-SHA512(mnemonic, "mnemonic", 2048)master = HMAC-SHA512("Bitcoin seed", seed)child = m/{44,49,84}"/0"/0"/0/0 # 首个接收地址address = bech32(hash160(compressed_pubkey)) # 默认 BIP-84

流水线上的每一步都是可复现的确定性函数,三种 profile 通用。攻击者只要把 pad 猜对,后面整条链都能离线重算。

第四阶段 GPU 侧的穷举与命中

攻击者的做法其实很规矩,四步。

第一步,构造候选 pad 集合。晶圆坐标 X、Y 都取 0 到 72,UID 空间大约 5200 个取值,和 SysTick 的 0 到 79999 做笛卡尔积,得到 4.24 亿个候选 pad。这个数字听着大,放到 GPU 上就是几天的事。

第二步,对每个 pad 再枚举按键次数,kpad_b 从 4 到 34。rtc_tr 和 rtc_ssr 的命中都是 0,放最外层当小循环。

第三步,在 GPU kernel 里跑完整种子流水线,sha256、BIP-39、PBKDF2 2048 轮、BIP-32、hash160、bech32。libngu 那条常量流可以离线预计算,kernel 按索引取词,省掉每个线程重复推进常量 PRNG 的开销。

第四步,用一个 hash160 的布隆过滤器或排序数组做匹配,O(log n) 的复杂度,目标是全网的单签 P2WPKH 地址集合。

这套搜索的代价可以量化。Apple M1 GPU 单机跑 Phase A 空间,72 乘 72 的 UID、8 万种 SysTick、31 种按键次数,大约 148 亿个候选,耗时约 8.6 天。数据中心级的 A100 集群,同样的空间压到数小时。Mk4、Mk5、Q 上大约 72 bit 的空间比 Mk3 高 2 的 32 次方倍,但漏洞同源,profile 建模思路一样,差别只是爆破器从单机换到集群、时间从数天拉到数周,这个成本攻击者依然愿意付。这也是它们同样出现在受害清单里的原因。

MistTrack 分析

本次事件涉及的资金规模较大,目前已有多方团队对该事件展开链上分析。本小节主要结合 Galaxy Research 已公开披露的数据以及慢雾在事件跟进过程中收集到的求助数据进行梳理。以下分析主要基于目前可观测的链上数据,后续如有新的地址或流向被识别,我们也将持续更新。

Wave 1

  • bc1qc779m8gec84k3t0ffvu0pps94zheht7lr7ueyn

该地址总收入 398.4859 BTC,均转入地址 bc1qx76cae2706qd5q576feh7xq8rfcsjpf2htfhe3:

目前地址 bc1qx76cae2706qd5q576feh7xq8rfcsjpf2htfhe3 余额 398.4759 BTC:

  • bc1qh0l7q0mca3ln7wsl9luwns0jc9jhgrtft025l4

该地址总收入 0.7732 BTC,均转入地址 bc1q8jy96fe5lf8vfugydnte3cguk92gpev7kwtp3q:

  • bc1qdaarag7729c2n4l2wnyt3hkhfpcs66n98z7uuh

该地址总收入 88.8505 BTC,均转入地址 bc1q8jy96fe5lf8vfugydnte3cguk92gpev7kwtp3q:

目前地址 bc1q8jy96fe5lf8vfugydnte3cguk92gpev7kwtp3q 余额 89.6233 BTC:

  • bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0

该地址总收入 594.4773 BTC,目前余额 32.4506 BTC,其余 562.0267 BTC 均转入地址 bc1qq85v2c926eg6pgxhwp6q7lf6cnsz80qs3fcu9r:

目前地址 bc1qq85v2c926eg6pgxhwp6q7lf6cnsz80qs3fcu9r 余额 562.0214 BTC:

Wave 2

  • bc1qsjrf5ze5tmulz7y2x4pc7qaex2a35sanp3rqlx

该地址总收入 45.9067 BTC,均转入地址 bc1qtfrwa4j6rmj9rsgspv6a0yjumkg39js2numu75:

目前地址 bc1qtfrwa4j6rmj9rsgspv6a0yjumkg39js2numu75 余额 45.9025 BTC:

  • bc1qmd5m5ktv7m5ffujxv4248fxv36myvdx79n8jp6

该地址总收入 30.1848 BTC,均转入地址 bc1qez89sph5tgghmf36u79hq62h8n2xqqka05dt6n:

目前地址 bc1qez89sph5tgghmf36u79hq62h8n2xqqka05dt6n 余额 30.1846 BTC:

Wave 3

Wave 3 涉及约 293 个 P2WSH 类型的 Vault 地址,其中 1 Park 对应 1 个 P2WSH Vault。目前这些 Vault 地址中的资金均未发生转出,涉及金额合计约 207.73 BTC。

Wave 4

  • bc1q0rvn88w08j75k4h48lf9fvhan7unjp7vjf5q6m

该地址总收入 64.9037 BTC,已转入 Wasabi Wallet:

其他公开提到的相关地址

  • bc1p0l0xs2a0ffn2d9pek28k3vm9rjr2p0c5hvdlu03gpdwgzdgpscnq6qlk0h

该地址总收入 17.6709 BTC,其中 4.4372 BTC 转入地址 bc1prjnvz77lhd3t6kdxt34x4yzgwu4qfdgyges8h6qhwldj60z5mcqs7pprpr 暂未转出;剩余 6 BTC 通过 THORChain 兑换为 204.7 ETH 并跨链到以太坊地址 0x41b7529a411eea979a8d468bdebd36b0ad703268:

目前地址 0x41b7529a411eea979a8d468bdebd36b0ad703268 余额 4.7073 ETH,且已将 200 ETH 转入 Tornado Cash:

MistTrack 将持续监控上述相关地址及后续资金流向,并根据链上资金异动情况更新分析。

总结

把整条链串起来看,一次构建配置和一层移植兜底叠加,把本该由硬件 TRNG 提供的随机性,压成了 32 到 72 bit 的软件 PRNG。Coinkite 在 24 小时内推出修复固件,销毁漏洞版本库存,暂停数据清除来配合执法,反应速度值得肯定。

但已经生成的旧种子,固件更新救不回来。随机性在生成那一刻就定死了,补丁只能保证新种子走对路。受影响的人要先把设备升到修复版本,重新生成种子,转一小笔钱测试,确认地址没问题,再迁移全部资金。旧设备先留着,后续调查可能还要用到。

声明:本文来自慢雾科技,版权归作者所有。文章内容仅代表作者独立观点,不代表安全内参立场,转载目的在于传递更多信息。如有侵权,请联系 anquanneican@163.com。