Thursday, August 6, 2026

The Coldcard incident

A vulnerability in the Coldcard hardware wallet's Bitcoin key generation allowed attackers to steal more than 2,000 BTC (about $120 million). The vulnerability had existed since March 2021 and was only exploited a few days ago. The immediate root cause was the code using a less safe/lower entropy random number generator, which allowed the attackers to find wallet addresses and their keys by brute force.

For processes that require high security (like creating bitcoin wallet keys), you have to use cryptographically secure random number generators. On a PC, you might use python's secret module. On small embedded devices, you often don't have access to high-level libraries, so the firmware must implement a secure entropy source itself.

The relevant code depends on a fork of MicroPython maintained by Coldcard (a heavily stripped-down Python implementation for microcontrollers) and on the libngu library, reportedly maintained by Coinkite's CTO. The code expected a function named rng_get(), but Coldcard's implementation exposed differently named functions, so the linker silently selected MicroPython's software PRNG. The problem can be summarized as:

[ Intended Architecture ]
  Entropy Source (STM32 Hardware TRNG) ──► Cryptographically Secure Seed (128-bit)

[ Actual Vulnerable Execution ]
  Silent Check Failure (MICROPY_HW_ENABLE_RNG == 0)
    │
    ▼
  Fallback: MicroPython Software PRNG (Yasmarang)
    │
    ├─► Initialized by: Device Unique ID (UID) + Timer Registers
    └─► Entropy drop: 128-bit (Standard) ──► ~40 bits (Mk2/Mk3)

Details: The weak-entropy path (the software Yasmarang PRNG) only happened because MICROPY_HW_ENABLE_RNG was defined as 0. In libngu (ngu/random.c):

#ifndef MICROPY_HW_ENABLE_RNG
# error "get a HW TRNG plz"
#endif
...
extern uint32_t rng_get(void);      // "I need a function called rng_get()"
#define CHIP_TRNG_32()   rng_get()  // then calls it

This only checks whether the macro exists, not its value. Because it was defined (even as 0), the #error never fired and the build continued. It declares rng_get but does not define (implement) it. It expects whoever builds the final firmware to supply a real function with that exact name. In MicroPython (ports/stm32/rng.c):

#if MICROPY_HW_ENABLE_RNG
// real STM32 hardware TRNG path for rng_get()
#else
// Yasmarang software PRNG fallback for rng_get()
#endif

When the value is 0, the #else branch is taken → the weak software PRNG is compiled in and linked as rng_get(). In Coldcard’s own board RNG files (e.g. stm32/COLDCARD*/rng.c):

#if MICROPY_HW_ENABLE_RNG
#error "this code replaces normal RNG module"
#endif

They intentionally set the macro to 0 so their custom hardware RNG code would be used instead of MicroPython’s. But that custom code never exported the exact rng_get() symbol that libngu expected. Coldcard’s custom RNG code (in files like stm32/COLDCARD/rng.c or stm32/COLDCARD_MK4/rng.c) implements its own, more paranoid hardware TRNG logic. However, it only provided functions under different names, such as:

  • rng_get_or_fault()
  • random_buffer()
  • pyb_rng_get() (the Python-visible version)

It never defined a global function literally named rng_get(). MicroPython’s own ports/stm32/rng.c does define a function named rng_get(). Because MICROPY_HW_ENABLE_RNG was set to 0, the linker picks this weaker version:

uint32_t rng_get(void) {
  return pyb_rng_yasmarang(); // software PRNG
}

When the final firmware binary is built, the linker has to resolve every extern reference.

libngu says: “I need a symbol called rng_get.”

Coldcard’s custom code does not supply a symbol with that exact name.

MicroPython’s code does supply a symbol with that exact name (the software version).

So the linker binds libngu’s call to MicroPython’s software rng_get().

That’s why the custom hardware TRNG code was present in the source tree and even reviewed, yet was never actually used for seed generation. The call path never reached it because the symbol name didn’t match.

If the board-specific RNG file (e.g. stm32/COLDCARD/rng.c or the Mk4 equivalent) had contained a global function literally named rng_get, then the linker would have seen two definitions of the same symbol, would produce a duplicate symbol / multiple definition error and the build would fail.

The solution is to make the ownership of the entropy source explicit and fail-closed, instead of relying on a fragile combination of preprocessor flags and linker symbol resolution. Stop going through libngu / ngu.random for seed generation. Keep the critical path (wallet seed creation) on Coldcard’s own known-good function that already talks directly to the hardware TRNG. Only use ngu.random for non-critical things (if at all).

Another good practice would be to write unit tests to verify the of the pass/key generation. Those tests could look like the following (of course, they should be much more sophisticated than this):

# 1. Generate many seeds using the exact production call path
seeds = set()
n = 50000;
for _ in range(n):
  raw = rng.generate_bytes(32)
  seeds.add(raw)
print(f"len(seeds): {len(seeds)}")
# 2. Basic sanity: should almost never collide
assert len(seeds) == n, "Collisions detected – entropy is far too low"

People are reporting that using widely available AI without access to current events on the internet did not reveal the bug, so this incident is not an obvious coding mistake.

Music: Orhan Gencebay - Hatasız Kul Olmaz