Coldcard硬件钱包RNG漏洞深度剖析: 一个宏定义如何导致8800万美元比特币被盗
Coldcard硬件钱包“熵崩溃”事件深度技术复盘,一个宏定义如何导致8800万美元蒸发
2026年7月30日,一场针对Coldcard硬件钱包的攻击震惊了整个加密世界。攻击者在41分钟内清空了1,196个比特币地址,盗走1,082.65 BTC,按当时价格计算价值约7020万美元。此后攻击持续发酵,截至8月初,累计已有约1,367枚比特币从4,585个地址中被盗,损失接近8900万美元。
这不是一次物理入侵,不是供应链攻击,也不是钓鱼诈骗。攻击者从未接触过任何一台受害者的实体设备。他们只是读懂了五年前一行代码的“潜台词”。
这不是“你的密钥,不是你的币”的反面——密钥确实从未离开过冷钱包,但它的出生证明本身就是伪造的。
作为开发者,我们需要回答一个问题:一行宏定义,怎么就能让一个以“安全”为卖点的硬件钱包全军覆没?
一、漏洞的“出生证明”:2021年3月的那次提交
漏洞的根源可以追溯到2021年3月1日发布的Coldcard固件4.0.0版本。在Commit37e4af5451c260c1e7d429fe8972c4cb5e68ee59中,开发团队在mpconfigboard.h中写下了一行看似无害的配置:
// We have our own version of this code.#defineMICROPY_HW_ENABLE_RNG(0)在MicroPython STM32端,MICROPY_HW_ENABLE_RNG这个宏控制着默认硬件随机数生成器(RNG)的绑定和编译路径。将其设为0的直接后果是:默认硬件RNG路径不会作为通用rng_get()的后端使用。
开发者为什么要把它设成0?根据社区分析,当时开发团队在整合自有RNG封装(libngu)时,与MicroPython的现有实现发生了编译冲突。证据显示,开发者在冲突后选择将MICROPY_HW_ENABLE_RNG设为0以允许固件编译通过,这是一个典型的“先让编译跑起来”的决策,但在安全攸关的场景下,代价是毁灭性的。
二、代码层面的完整剖析:漏洞是如何“静默生效”的
2.1 自定义RNG:看上去很美的“替代方案”
Coldcard开发团队的注释说他们“会自行实现RNG”。在自定义的rng.h中,他们确实声明了两个MicroPython对象:
MP_DECLARE_CONST_FUN_OBJ_0(pyb_rng_get_obj);MP_DECLARE_CONST_FUN_OBJ_1(pyb_rng_get_bytes_obj);对应的实现为:
/// \function pyb_rng_get()/// Return a 30-bit hardware generated random number: or fail!STATICmp_obj_tpyb_rng_get(void){returnmp_obj_new_int(rng_get_or_fault()>>2);}/// \function rng_get_bytes()/// Fill a buffer with random bits; caller must provide sized buffer.STATICmp_obj_tpyb_rng_get_bytes(mp_obj_tbuffer_io){mp_buffer_info_tbufinfo;mp_get_buffer_raise(buffer_io,&bufinfo,MP_BUFFER_WRITE);mp_uint_tcount=bufinfo.len;if(count<1){mp_raise_ValueError(NULL);}random_buffer(bufinfo.buf,count);returnmp_const_none;}MP_DEFINE_CONST_FUN_OBJ_0(pyb_rng_get_obj,pyb_rng_get);MP_DEFINE_CONST_FUN_OBJ_1(pyb_rng_get_bytes_obj,pyb_rng_get_bytes);rng_get_or_fault()本身确实读取了STM32硬件RNG外设:
staticuint32_trng_get_or_fault(void){rng_init();uint32_tstart=HAL_GetTick();while(!(RNG->SR&RNG_SR_DRDY)){if(HAL_GetTick()-start>=RNG_TIMEOUT_MS){mp_raise_OSError(MP_EFAULT);}}last_value=RNG->DR;returnlast_value;}问题来了:Coldcard自定义代码确实“想要”使用硬件RNG,但它只保证在调用pyb_rng_get*或其内部random_buffer()时才会走这条路。而钱包创建流程并没有走这条路径。
2.2 钱包创建:真正的“凶手”在这里
钱包创建的实际入口在shared/seed.py中:
asyncdefmake_new_wallet(nwords):# Pick a new random seed.awaitux_dramatic_pause('Generating...',3)seed=generate_seed()words=awaitapprove_word_list(seed,nwords)ifwords:awaitcommit_new_words(words)该调用进入Coldcard的shared/random.py模块。钱包初始化使用的是ngu.random.bytes,而不是pyb.rng()。自定义的pyb_rng_get_obj并未自动覆盖ngu.random.bytes。
这就像你在厨房装了一台高级净水器,但日常喝的水却是从水龙头直接接的,因为水管根本没接对。
2.3 静默回退:Yasmarang的“幽灵”
由于MICROPY_HW_ENABLE_RNG被设为0,系统静默回退到了MicroPythonports/stm32/rng.c中的pyb_rng_yasmarang:
#ifMICROPY_HW_ENABLE_RNGuint32_trng_get(void){// use STM32 hardware RNG...}#else// For MCUs that don't have an RNG we still need to provide a rng_get() function// A pseudo-RNG is not really ideal but we go with it for now.// Yasmarang random number generatorstaticuint32_tpyb_rng_yasmarang(void){staticbool seeded=false;staticuint32_tpad=0,n=0;// ... deterministic PRNG logic}uint32_trng_get(void){returnpyb_rng_yasmarang();}#endifpyb_rng_yasmarang是一个确定性软件伪随机数生成器(PRNG),由芯片唯一ID和定时器寄存器初始化,且在初始化之后不再收集新的熵。
更致命的是:libngu库在检查这个宏时,只看它是否被定义(#ifdef),而不检查它是否被启用(#if)。宏被定义为0,但#ifdef仍然为真,于是编译通过,系统安静地绑定了MicroPython的软件备用方案。
2.4 Makefile的“障眼法”
开发团队在Makefile中明确排除了stm32/rng.c的编译:
# Do not compile MicroPython's fallback PRNG. The board-specific rng.c # provides rng_get(), and this empty object satisfies the upstream object list. $(BUILD)/rng.o: CFLAGS += -Dpyb_rng_yasmarang=error-do-not-want-this $(BUILD)/rng.o: $(ECHO) "SKIP stm32/rng.c" $(Q)$(CC) $(CFLAGS) -x c -c /dev/null -o $@开发团队自认为已排除MicroPython的备用PRNG,但实际上,MICROPY_HW_ENABLE_RNG=0这个设置在编译时静默地将rng_get()指向了pyb_rng_yasmarang。Makefile跳过了rng.c的编译,但宏定义仍然在发挥作用,导致了一个“幽灵PRNG”的存在。
热修复后,stm32/rng.o改为从/dev/null编译,rng_get()解析为:
uint32_trng_get(void){returnrng_get_or_fault();}这才真正读取了硬件TRNG,可惜为时已晚。
2.5 熵值崩溃:从128位到40位
根据Coinkite的官方评估:
| 型号 | 受影响固件版本 | 有效熵值 | 预期熵值 |
|---|---|---|---|
| Mk2/Mk3 | 4.0.0 – 4.1.9 | ~40位 | 128位 |
| Mk4/Mk5 | 5.6.0之前 | ~72位 | 128位 |
| Coldcard Q | 1.5.0Q之前 | ~72位 | 128位 |
Block工程团队并未给出一个确切数字,但设定了低于240.7**和**273.3的条件上限,并特别警告后者并不等同于73位的密码学安全强度。
对于Mk2/Mk3:在已知UID、定时器状态和调用历史的情况下,种子生成几乎是确定性的。对于较新型号:虽然安全元件在启动时加入了熵,但最终只保留了4个字节(32位),搜索空间仍然极为有限。
40位熵意味着什么?2^40 ≈ 1万亿,在现代计算能力面前,这个搜索空间完全可以暴力破解。
Block的分析指出:攻击者只要能确定或充分限定设备UID、定时器状态以及此前的RNG调用历史,就无需接触设备即可离线重现候选输出流。
三、攻击者的四步棋
第一步:识别漏洞
攻击者通过分析Coldcard开源固件代码,发现了MICROPY_HW_ENABLE_RNG (0)配置以及pyb_rng_yasmarang的存在。他们确认了不同型号的熵值差异,并锁定了攻击目标,2021年3月之后、修复固件发布之前创建的所有单签名钱包。
第二步:重建候选助记词空间
攻击者利用PRNG的确定性本质,种子来自芯片唯一ID、定时器状态等可预测信息,在自己的计算环境中批量生成所有可能的低熵助记词组合。
第三步:链上地址匹配
将生成的每个候选助记词按照BIP-32/BIP-44标准推导出对应的比特币地址,然后与公开的区块链UTXO数据进行比对,筛选出存有资金的地址。
第四步:批量自动化盗取
一旦匹配成功,直接推导私钥并发起转账。整个过程完全不需要接触受害者的实体设备。
第一波攻击的自动化程度令人咋舌,所有交易使用相同的30 sat/vB手续费率,且无任何找零输出,这是典型的自动化工具在批量操作的痕迹。
Galaxy Research指出,这种扫掠模式“看起来与币主自己选择转移币的行为相同”,这也是为什么攻击如此难以被提前发现。
四、完整时间线
| 时间 | 事件 |
|---|---|
| 2021年3月1日 | Coldcard固件4.0.0发布,引入MICROPY_HW_ENABLE_RNG (0)配置错误 |
| 2021年3月 – 2026年7月 | 漏洞持续存在于后续版本中,潜伏超过5年 |
| 2026年7月30日 01:10-01:51 UTC | 第一波攻击:41分钟内从1,196个地址盗走1,082.65 BTC |
| 2026年7月30日 | Coinkite发布安全警告 |
| 2026年7月31日 | Coinkite扩大警告范围至Mk4、Mk5和Q型号;发布紧急修复固件 |
| 2026年7月31日后 | 第二波、第三波攻击:累计受害地址达4,585个,被盗约1,367枚BTC |
| 2026年8月2日之后 | 疑似第四波攻击启动,累计损失可能超过1.14亿美元 |
一个值得深思的细节:首波攻击发生在Coinkite公开披露漏洞的同一天,攻击者可能比厂商更早发现了这个漏洞。Coinkite首席执行官Rodolfo Novak曾表示,AI可能已经在公司开源固件中发现了这个存在五年的漏洞。
五、热修复暴露的第二个问题
2026年7月31日的热修复(commit ca724637)将rng_get()正确解析到了板级TRNG访问器。但随即暴露了第二个严重问题:rng_get_or_fault()没有针对STM32 RNG错误标志的恢复路径。
问题出在rng_init()的实现上:
staticvoidrng_init(void){if(!(RNG->CR&RNG_CR_RNGEN)){__HAL_RCC_RNG_CLK_ENABLE();RNG->CR|=RNG_CR_RNGEN;// TODO: throw out some samples?}}根据STM32参考手册(RM0432 §25.3.7和RM0351),当发生种子错误时:
SECS被设置,SEIS锁存DRDY停止断言RNGEN保持设置状态
恢复的正确做法是:清除SEIS,然后toggle RNGEN(关闭→再开启)。但rng_init()只检查RNGEN,在故障状态下它什么也不做,直接返回。rng_get_or_fault()随后忙等待10ms然后抛出异常,每次调用都如此,跨Python层重启也不恢复。
更麻烦的是,rng_get()现在被用在了键盘扫描路径上:
# mempad.py / keyboard.py :: _start_scan()shuffle(self.scan_order)# "We scan in random order, because Tempest."->random.randbelow->ngu.random.uniform->_rand_below()->CHIP_TRNG_32()->rng_get()_start_scan()在每次按键中断(press_irq,最高60Hz)时被调用,在登录之前就运行。每次按键可能触发3次以上的TRNG读取,每次最坏情况10ms。一旦OSError在那里抛出,用户会看到一个错误屏幕,无法输入PIN,也无法进入升级菜单。
这正是热修复后部分用户报告的“卡在错误屏幕/无法启动/疑似变砖”问题的根源。
一个旨在修复安全漏洞的补丁,却因为硬件错误处理逻辑的缺失,导致设备变成“电子砖头”。这提醒我们:安全修复本身也需要安全测试。
六、正确修复方案(完整版)
6.1 第一层:正确的宏配置
// 在 mpconfigboard.h 中#defineMICROPY_HW_ENABLE_RNG(1)// 启用硬件RNG效果:确保rng_get()指向硬件TRNG而非软件PRNG。
6.2 第二层:硬件TRNG的故障恢复
staticvoidrng_init(void){// 检查是否有挂起的错误标志if(RNG->SR&(RNG_SR_SEIS|RNG_SR_CEIS)){// 清除错误标志RNG->SR=~(RNG_SR_SEIS|RNG_SR_CEIS);// 关闭RNGRNG->CR&=~RNG_CR_RNGEN;// 等待关闭完成for(volatileinti=0;i<100;i++);// 重新开启RNG->CR|=RNG_CR_RNGEN;// 丢弃初始化后的前几个采样(参考手册建议)for(inti=0;i<4;i++){while(!(RNG->SR&RNG_SR_DRDY));(void)RNG->DR;}}elseif(!(RNG->CR&RNG_CR_RNGEN)){__HAL_RCC_RNG_CLK_ENABLE();RNG->CR|=RNG_CR_RNGEN;// 同样丢弃初始采样for(inti=0;i<4;i++){while(!(RNG->SR&RNG_SR_DRDY));(void)RNG->DR;}}}效果:当硬件TRNG出现种子错误或时钟错误时,能够自动恢复,而不是永久锁死。
6.3 第三层:读取时的错误检查
staticuint32_trng_get_or_fault(void){rng_init();uint32_tstart=HAL_GetTick();while(!(RNG->SR&RNG_SR_DRDY)){if(HAL_GetTick()-start>=RNG_TIMEOUT_MS){mp_raise_OSError(MP_EFAULT);}}// 在读取之前,再次检查是否有错误标志锁存if(RNG->SR&(RNG_SR_SEIS|RNG_SR_CEIS)){rng_init();// 会清除错误并重新初始化start=HAL_GetTick();while(!(RNG->SR&RNG_SR_DRDY)){if(HAL_GetTick()-start>=RNG_TIMEOUT_MS){mp_raise_OSError(MP_EFAULT);}}}returnRNG->DR;}效果:确保不会将硬件故障时的“坏数据”当作有效随机数返回。
6.4 第四层:构建时的防御性检查
在构建系统中加入编译时检查:
# 在构建脚本中 ifneq ($(MICROPY_HW_ENABLE_RNG),1) $(error MICROPY_HW_ENABLE_RNG must be set to 1 for secure builds) endif或者使用#if而非#ifdef进行条件编译:
// 在 libngu 中#ifMICROPY_HW_ENABLE_RNG==1// 使用硬件RNG#else#error"Hardware RNG is not enabled - this build is insecure!"#endif效果:从构建层面杜绝配置错误再次发生。
6.5 第五层:端到端测试
增加测试用例,验证钱包创建流程实际使用的是硬件RNG而非软件PRNG。例如:
- 在测试环境中mock硬件RNG并验证其被调用
- 通过统计检验确认输出的随机性符合预期
- 验证最终链接符号:确保
rng_get的最终链接指向预期的实现
七、给开发者的八个教训
1. 永远不要将硬件RNG的启用状态设为0
硬件钱包的随机数生成器是其安全性的基石。任何绕过硬件RNG的配置,无论出于什么原因(哪怕是“编译冲突”),都是一个巨大的危险信号。
2. 检查宏时使用#if而非#ifdef
区分“宏被定义”和“宏被启用为真值”。这个差异在安全攸关的场景下可能是致命的。
3. 确保所有随机数生成路径都经过验证
不能只验证“存在硬件RNG”,还要验证“实际使用了硬件RNG”。钱包创建流程所用的随机数来源,必须和硬件RNG是同一条路径。
4. 端到端测试不可省略
必须测试从种子生成到地址推导的完整流程,确认熵源符合预期。Kraken CSO Nick Percoco对此评论道:“审计人员可以验证设备中是否存在经批准的随机数生成器,但无法确认生产固件实际使用了它”。
5. 硬件TRNG需要正确的错误处理
即使正确连接了硬件TRNG,也必须实现完整的错误检测和恢复逻辑。否则,一次硬件故障就可能让设备永久锁死。
6. 构建系统应包含安全断言
如果某个配置对安全性至关重要,构建系统应该在编译时检查它是否正确设置,而不是静默地使用不安全的备用方案。
7. Fail Closed,而非Fail Open
在安全攸关的系统中,当配置不确定时,应该拒绝构建(Fail Closed),而不是静默回退到不安全的方案(Fail Open)。
8. 开源不等于自动安全
这个漏洞在公开代码中潜伏了五年多。开源代码需要持续的、专业的安全审计,特别是针对运行时实际执行的代码路径,而不仅仅是“存在哪些功能”。
八、用户该怎么办?
如果你是Coldcard用户,请注意以下几点:
更新固件无法修复已经生成的种子。必须在新固件上生成全新种子并迁移资金。
受影响型号及固件版本:
- Mk2/Mk3:固件4.0.1 – 4.1.9
- Mk4/Mk5:标准固件5.6.0之前 / Edge 6.6.0X之前
- Coldcard Q:标准固件1.5.0Q之前 / Edge 6.6.0QX之前
使用至少50次公平、独立且私密的骰子投掷生成的种子不受此漏洞影响。
一个强且唯一的BIP-39口令可以创建独立钱包,但Coinkite仍建议更换种子。
Galaxy Research负责人Alex Thorn发出了一个令人不寒而栗的警告:2021年3月固件漏洞之后创建的每一个单签名Coldcard地址,最终都可能被清空。
这次事件也凸显了一个残酷的现实:即使密钥从未离开过冷钱包,如果它的生成方式存在缺陷,它仍然不安全。在自我托管的模式下,即使用户更新了固件,修复前生成的钱包也无法被补救。直到用户主动采取行动之前,相关钱包可能持续暴露在攻击风险中。
一行宏定义的错误,五年的潜伏,四十分钟的扫荡,八千九百万美元的蒸发。
安全不是“存在某个功能”,而是“每个路径都正确执行”。
