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版本。在Commit 37e4af5451c260c1e7d429fe8972c4cb5e68ee59中,开发团队在mpconfigboard.h中写下了一行看似无害的配置:
// We have our own version of this code.
#define MICROPY_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!
STATIC mp_obj_t pyb_rng_get(void) {
return mp_obj_new_int(rng_get_or_fault() >> 2);
}
/// \\function rng_get_bytes()
/// Fill a buffer with random bits; caller must provide sized buffer.
STATIC mp_obj_t pyb_rng_get_bytes(mp_obj_t buffer_io) {
mp_buffer_info_t bufinfo;
mp_get_buffer_raise(buffer_io, &bufinfo, MP_BUFFER_WRITE);
mp_uint_t count = bufinfo.len;
if(count < 1) { mp_raise_ValueError(NULL); }
random_buffer(bufinfo.buf, count);
return mp_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外设:
static uint32_t rng_get_or_fault(void) {
rng_init();
uint32_t start = HAL_GetTick();
while (!(RNG->SR & RNG_SR_DRDY)) {
if (HAL_GetTick() – start >= RNG_TIMEOUT_MS) {
mp_raise_OSError(MP_EFAULT);
}
}
last_value = RNG->DR;
return last_value;
}
问题来了:Coldcard自定义代码确实“想要”使用硬件RNG,但它只保证在调用pyb_rng_get*或其内部random_buffer()时才会走这条路。而钱包创建流程并没有走这条路径。
2.2 钱包创建:真正的“凶手”在这里
钱包创建的实际入口在shared/seed.py中:
async def make_new_wallet(nwords):
# Pick a new random seed.
await ux_dramatic_pause('Generating…', 3)
seed = generate_seed()
words = await approve_word_list(seed, nwords)
if words:
await commit_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,系统静默回退到了MicroPython ports/stm32/rng.c中的pyb_rng_yasmarang:
#if MICROPY_HW_ENABLE_RNG
uint32_t rng_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 generator
static uint32_t pyb_rng_yasmarang(void) {
static bool seeded = false;
static uint32_t pad = 0, n = 0;
// … deterministic PRNG logic
}
uint32_t rng_get(void) {
return pyb_rng_yasmarang();
}
#endif
pyb_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_t rng_get(void) {
return rng_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()的实现上:
static void rng_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 中
#define MICROPY_HW_ENABLE_RNG (1) // 启用硬件RNG
效果:确保rng_get()指向硬件TRNG而非软件PRNG。
6.2 第二层:硬件TRNG的故障恢复
static void rng_init(void) {
// 检查是否有挂起的错误标志
if (RNG->SR & (RNG_SR_SEIS | RNG_SR_CEIS)) {
// 清除错误标志
RNG->SR = ~(RNG_SR_SEIS | RNG_SR_CEIS);
// 关闭RNG
RNG->CR &= ~RNG_CR_RNGEN;
// 等待关闭完成
for (volatile int i = 0; i < 100; i++);
// 重新开启
RNG->CR |= RNG_CR_RNGEN;
// 丢弃初始化后的前几个采样(参考手册建议)
for (int i = 0; i < 4; i++) {
while (!(RNG->SR & RNG_SR_DRDY));
(void)RNG->DR;
}
} else if (!(RNG->CR & RNG_CR_RNGEN)) {
__HAL_RCC_RNG_CLK_ENABLE();
RNG->CR |= RNG_CR_RNGEN;
// 同样丢弃初始采样
for (int i = 0; i < 4; i++) {
while (!(RNG->SR & RNG_SR_DRDY));
(void)RNG->DR;
}
}
}
效果:当硬件TRNG出现种子错误或时钟错误时,能够自动恢复,而不是永久锁死。
6.3 第三层:读取时的错误检查
static uint32_t rng_get_or_fault(void) {
rng_init();
uint32_t start = 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);
}
}
}
return RNG->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 中
#if MICROPY_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地址,最终都可能被清空。
这次事件也凸显了一个残酷的现实:即使密钥从未离开过冷钱包,如果它的生成方式存在缺陷,它仍然不安全。在自我托管的模式下,即使用户更新了固件,修复前生成的钱包也无法被补救。直到用户主动采取行动之前,相关钱包可能持续暴露在攻击风险中。
一行宏定义的错误,五年的潜伏,四十分钟的扫荡,八千九百万美元的蒸发。
安全不是“存在某个功能”,而是“每个路径都正确执行”。
