Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ColdCard-38M-PoC


summary

Between March 2021 (firmware 4.0.0) and July 2026, BIP39 seed generation on Coldcard devices did not use the hardware true random number generator (TRNG). Following a cryptographic migration to libsecp256k1 (commit b18723dd, "First pass w/ libNgU", 2021-03-01), the seed-generation path ended up wired — through link-time symbol resolution — to a software fallback PRNG shipped with MicroPython, seeded only from device and timing state.

Consequence: the effective space of generated seeds collapses to a single universal 2³² space on Mk2/Mk3 (see model: pad = UID ^ SysTick is one 32-bit word, and the RTC is static — no per-device knowledge is needed at all) and ~2⁷³ on Mk4/Mk5/Q (Coinkite's tuple-counting estimate is ~2⁴⁰/~2⁷²; the structural ceilings are lower). An attacker who knows a wallet's public address can replay the PRNG state space, regenerate candidate seeds, and identify the right one by simple address comparison — without ever touching the device. One single sweep of the space, checked against every funded address on-chain, recovers all vulnerable wallets at once.

This repository contains a complete reconstruction of the attack, validated end to end: mnemonic recovery in under two minutes on CPU, with a signature proof.

Affected versions (source: Coinkite)

Firmware Hardware Status
1.0.0 – 3.2.2 (→ Jan 14, 2021) Mk1, Mk2, Mk3 not affected
4.0.0 – 4.1.9 Mk2, Mk3 affected — worst case (~2⁴⁰)
5.0.0 – 5.5.1 Mk4, Mk5 affected (~2⁷²)
0.0.2Q – 1.4.1Q Q affected (~2⁷²)
6.x "X"/Edge Mk4/Mk5/Q affected (~2⁷²)

Fixes: 4.2.0 (Mk3), 5.6.0 (Mk4/Mk5), 1.5.0Q (Q), 6.6.0X / 6.6.0QX (Edge). Updating does not repair an already-generated seed: a new seed must be generated and funds migrated. Exception: seeds created with ≥ 50 independent dice rolls.


Root cause

The bug is not a single defect but a chain of four flaws, each harmless in appearance.

diagram_rng_chain

migration that changed the entropy source

Before March 2021, shared/seed.py drew entropy via ckcc.rng_bytes(), Coinkite's board-specific TRNG (stm32/COLDCARD/rng.c, "more paranoid", with health checks). Commit b18723dd rewrote 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)

with, in shared/random.py:

bytes = ngu.random.bytes

The cryptographic choice (libsecp256k1) was sound. The integration was not: nobody verified which rng_get() implementation ngu.random.bytes actually reached.

macro defined to zero that disables the TRNG

// stm32/COLDCARD/mpconfigboard.h:77
#define MICROPY_HW_ENABLE_RNG       (0)

Peter D. Gray (Coinkite) explains it himself: he set the macro to zero thinking it disabled all of MicroPython's RNG code ("we didn't need either version"), since Coldcard had its own TRNG. But this macro does not control code inclusion — it selects which rng_get() implementation is compiled in ports/stm32/rng.c:

// 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

Coinkite board-specific TRNG (stm32/COLDCARD/rng.c), guarded by #if MICROPY_HW_ENABLE_RNG, is also excluded from the build.

the guard that guards nothing: #ifndef vs value

libngu did have a safety net:

// external/libngu/ngu/random.c:28-30
# ifndef MICROPY_HW_ENABLE_RNG
# error "get a HW TRNG plz"
# endif

#ifndef tests whether the macro exists, not its value. It is defined — to zero. So the #error never fires, the build passes, and the rng_get() symbol (declared extern in libngu) resolves at link time to MicroPython's software PRNG. Two implementations with the same signature, the wrong one silently selected.

fallback is seeded from device/timing state

// 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 unique identifier — only its low 32 bits are used, and they do not need to be known: pad = UID_low32 ^ SysTick->VAL is a single 32-bit word, so the whole expression has at most 2³² results regardless of the UID (Block Engineering analysis);
  • SysTick->VAL: predictable periodic down-counter — at most ~80,000 values on Mk2/Mk3, already absorbed into the 2³² pad space above;
  • RTC->TR / RTC->SSR: static at 0 on a cold boot — the board config sets MICROPY_HW_ENABLE_RTC (0) (stm32/COLDCARD/mpconfigboard.h:17), so the RTC is never initialized. Confirmed in source, no hardware needed.

The only remaining unknowns are the 32-bit pad and the number of prior RNG consumptions (see skip model below). ~40 bits is Coinkite's conservative tuple count; the structural ceiling is 2³² × (skip candidates).

The libngu mixer adds zero entropy: its internal Yasmarang starts from a constant state (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));

The final sha256() in seed.py ("hash to mitigate bias") is also deterministic: it does not create entropy, it transforms it.

why reviews missed it

  • Coinkite "paranoid" TRNG was present in the binary (for other uses): reviews verified its existence and quality, not its reachability from the seed-generation path.
  • Both rng_get() implementations share the same signature: nothing to see when reading, nothing at link time.
  • The bug lived at the boundary of two submodules (libngu ↔ micropython): each side compiled and passed its tests.
  • The #ifndef guard gave an illusion of protection documented in the code.
  • Coinkite: "we used one of the best available AI models to review our code [...] it did not find this bug".

Lesson: for critical entropy code, you must verify end-to-end symbol resolution and call reachability, not just the presence of the right code. The hotfix does exactly that: explicit exclusion of the fallback PRNG object + a build-time check that the global rng_get() comes from the board-specific object and that the fallback object exports no symbols.


model

The attack needs nothing device-specific. The search space is universal (shared by every affected device):

  1. pad — one 32-bit word (UID_low32 ^ SysTick->VAL); 2³² values cover every device and every boot timing at once;
  2. RTC->TR = RTC->SSR = 0 — static, RTC disabled in the build;
  3. the skip — prior RNG consumptions before make_new_wallet, derived from the boot path (see below).

skip model (corrected)

Two generators advance through every ngu.random call: the MicroPython fallback (chip) and the libngu mixer. Reading external/libngu/ngu/random.c precisely:

  • my_random_bytes() and random_uint32() advance both 1:1;
  • _rand_below() (used by uniform/shuffles) advances both on the first attempt, but its rejection loop does pt ^= my_yasmarang()only the mixer advances on retries.

So the correct model has two independent skips: chip_skip (exact, deterministic) and mixer_skip (chip_skip + shuffle retries).

skip values from the boot path (source-derived, no hardware)

On the first boot of a device, nvstore finds all 32 setting slots blank and fills 3 of them with garbage (shared/nvstore.py):

shuffle(32 slots)            → 31 uniform calls (chip+mixer, plus retries)
3 slots × 16 × random.bytes(256) = 3072 words (1:1)

A wallet created during the first session (the typical dormant-wallet pattern) therefore has:

  • chip_skip = 3072 + 31 = 3103 exactly;
  • mixer_skip ≈ 3103 + E[shuffle retries] ≈ 3161 ± 8 (geometric rejection sampling; mean computed term-by-term from _rand_below mask widths).

Wallets created in a later session have small skips (0–~30; the login keypad shuffle is off by default). No other boot-path consumer touches the RNG (verified: selftest, ux, display, menu, pincodes are clean).

pipeline (unchanged, confirmed on branch 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

The v4 branch (Mk2/Mk3 firmware 4.0.x–4.1.9) was re-checked line by line: random.bytes(32)assertsha256s → 24 words, unchanged from the vulnerable commit. (sha256d and 12/18-word seeds only exist on later Mk4/Q code — irrelevant for the Mk2/Mk3 population.)

Per-candidate cost is dominated by PBKDF2 (~0.9 ms CPU). The address match identifies the seed unambiguously and yields the private key.

what 2³² and 2⁷³ mean

Scenario Space Practical cost
Nominal BIP39 target 2¹²⁸ – 2²⁵⁶ physically out of reach
Mk2/Mk3, universal (any device, no knowledge) 2³² × skips hours on a small GPU fleet; ~1-2 days on 2 rented GPUs
Mk2/Mk3, per-wallet with known UID ~2¹⁶·³ (80,000 SysTick) seconds on CPU
Mk4/Mk5/Q (32-bit reseed × fallback) ≤ 2⁷³ ceiling very well-resourced actor; still below the 128-bit target

The universal 2³² space is the key to the real attack: one enumeration, checked against a bloom filter of every funded address on-chain, recovers all vulnerable wallets simultaneously. That is how ~500 wallets fell in one night with no per-device knowledge and no long campaign.


fidelity

Every line of vuln_rng.py maps to a verifiable source:

  • MicroPythonFallbackRNGports/stm32/rng.c @ 8d866365 (Coldcard/micropython), #else MICROPY_HW_ENABLE_RNG branch;
  • LibNguYasmarang / ngu_random_bytesexternal/libngu/ngu/random.c @ 2fbfefb1, functions my_yasmarang() and my_random_bytes() (including the chip == last → EFAULT check and the little-endian memcpy in 4-byte blocks);
  • skip advance ← same file: prior consumptions advance chip and mixer 1:1 (my_random_bytes, random_uint32), with mixer-only advancement on _rand_below() retries (pt ^= my_yasmarang());
  • vulnerable_seed_entropyshared/seed.py::make_new_wallet() @ b18723dd (random.bytes(32), assert len(set(seed)) > 4, sha256s), re-confirmed identical on branch v4 (firmware 4.0.x–4.1.9).

Three implementations cross-validate each other bit-for-bit: vuln_rng.py (Python), go_poc/ (Go), gpu_poc/ (CUDA), via published test vectors covering every pipeline stage (raw32, entropy, mnemonic, seed64, privkey, hash160, address — including split chip/mixer skips).

the July 30, 2026 theft — real-world validation

Between 01:10 and 01:56 UTC on July 30, 2026, an unknown attacker swept 594.48 BTC (~$38M) from 501 single-signature wallets, then consolidated the funds. On-chain forensics reproduced here (scan_sweep_raw.py, victims_full.txt):

  • consolidation address: bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0 — received exactly 594.47723261 BTC in 501 funding transactions, one per victim address;
  • fingerprint: every sweep tx pays ~30 sat/vB, has exactly one output (no change), spends only single-sig prevouts — precomputed keys spent by an automated tool;
  • victim profiles: dormant wallets funded 2021–2026, e.g. bc1q8s0xt9rz04cumw6jesccqncnajrnvh36jmh6qg (funded 2022-08-11, drained 2026-07-30 01:43 UTC);
  • address types: 491× P2WPKH (BIP84), 5× P2PKH (1…, BIP44), 5× P2SH-P2WPKH (3…, BIP49) — the attacker derived multiple script paths per seed, and multiple address indices per account (some wallets were only partially drained, showing the index range was bounded).

The victim list used by our scanners is extracted directly from the 501 funding transactions of the consolidation address — the exact set of addresses that were actually hit.

PoC implementations

Path Role
vuln_rng.py / wallet_lib.py Python reference replica (RNG chain, BIP39/32)
make_victim.py / recover.py original owned-device demo (legacy skip model)
go_poc/ Go port: multi-address batches, -parallel, universal pad mode
gpu_poc/ CUDA scanner: PBKDF2+BIP32+secp256k1 kernels, multi-index (--indices, --change), split chip/mixer skips (--chip-skip), checkpoints, victim bloom matching
scan_sweep.py / scan_sweep_raw.py on-chain theft forensics (block download + fingerprint filtering)
victims_full.txt the 501 real victim addresses (+ controls)

status of the live validation (GPU sweep vs real victims)

  • derivation pipeline: bit-exact, validated on-device (control states recovered at every stage, ECDSA proof);
  • probes so far on the rented 2× RTX PRO 6000: skip 0–7, skip=3103 (lockstep model), then split chip=3103 / mixer∈[3135,3195) — no real victim recovered yet at the time of writing; coverage remains a small fraction of the 2³² pad space and the skip window may still not match the victims' actual boot histories;
  • the CUDA kernel currently reaches ~0.14 Mcand/s/GPU (4% of theoretical peak; ptxas shows a 2.7 KB stack frame and heavy spills — optimization headroom ×3–5).

note on the legacy demo

make_victim.py / recover.py and their artifacts (target.json, victim_secret.json) predate the skip-model correction: they use the old chip-only skip advance. Regenerating a victim with the corrected vuln_rng.py yields a different mnemonic for any skip > 0; skip = 0 is unchanged. The demo's conclusion (full recovery from the address alone) is unaffected — the model is now simply exact for any boot history.


why this is severe

  • No physical access needed: a public address is enough.
  • Retroactive: any seed generated by an affected firmware years ago remains crackable, even after a firmware update.
  • Silent: the device worked "normally"; the anti-failure test (assert len(set(seed)) > 4) passes, the words are valid, everything looks healthy.
  • Profitable: the 2³² universal attack costs less than ~$1,000 of cloud GPU — far below the value of many wallets; this is an economic problem, not a theoretical one.
  • Open source ≠ audited: the code had been public since 2021; the flaw lived in the composition of two submodules, invisible to file-by-file reviews.

References

  • Coinkite — Entropy Technical Backgrounder
  • LLFOURN — Attack-cost model for affected COLDCARD generations (linked by Coinkite)
  • Block — Predictable RNG Fallback and 32-Bit Reseed in COLDCARD Firmware
  • Vulnerable commit: b18723dddb6d751c39978e4364b56b2414f68b47 (Coldcard/firmware, 2021-03-01, "First pass w/ libNgU")
  • Key code: shared/seed.py, shared/random.py:11, stm32/COLDCARD/mpconfigboard.h:17 (MICROPY_HW_ENABLE_RTC) and :77 (MICROPY_HW_ENABLE_RNG), external/libngu/ngu/random.c:24-30, ports/stm32/rng.c (micropython @ 8d866365), shared/nvstore.py:205-215 (first-boot RNG consumption)

About

`rng_get()` links to MicroPython software `PRNG` instead of the hardware `TRNG`, shrinking seed entropy to `~2⁴⁰`

Resources

Stars

31 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages