אִם יִרְצֶה הַשֵּׁם
TL;DR
Between 30 July and early August 2026, attackers drained roughly 1,800+ BTC (~$116M at prices then) from Coldcard-generated addresses. No curve was cracked. BIP39 checksums passed. BIP32/84 derived valid SegWit keys. The failure was compositional: a build flag, a dead preprocessor guard, and a silent linker bind replaced the STM32 TRNG with MicroPython’s Yasmarang PRNG, then XORed it with a second Yasmarang whose state is public. The intended seed language (physical noise) collapsed to an enumerable boot-time alphabet of about 2^22 states on Mk2/Mk3.
This is a LANGSEC incident. The weird machine was not in Bitcoin’s consensus rules. It sat in the build boundary, where the C preprocessor and linker act as unchecked parsers of security contracts.
Kinetic Context
In a web app, a weak RNG yields a guessable session cookie. In a hardware wallet, it yields irreversible loss of bearer assets. Coldcard’s regression is shotgun parsing under another name: each component satisfied a local contract, and the composition accepted an input language nobody meant to accept (wallet seeds from a few million boot-jitter states instead of 2^128).
Earlier on this blog: CUE as Apotheosis of Langsec / Infrastructure as (Proven) Code.
1. The call chain as a language pipeline
Seed creation in Python:
# shared/seed.py (vulnerable era) seed = ngu.random.bytes(32) assert len(set(seed)) > 4 # "TRNG failure" return ngu.hash.sha256d(seed)
The comment promises TRNG bytes. The assert only rejects pathological constant streams. Downstream BIP39/BIP32 are total functions over whatever 32-byte string arrives. A counter will mint a valid wallet.
Inside libngu, each 32-bit chunk was supposed to mix hardware noise with a software whitener:
// ngu/random.c (intended shape) uint32_t chip = CHIP_TRNG_32(); // believed: STM32 TRNG chip ^= my_yasmarang(); // whitening
CHIP_TRNG_32() expanded to rng_get(). Libngu only declared:
extern uint32_t rng_get(void);
That is an open nonterminal in the build grammar: somewhere, a supplier of this symbol exists. The board’s real TRNG wrapper was named rng_get_or_fault() and was not exported as a global rng_get. MicroPython’s ports/stm32/rng.c, compiled because MICROPY_HW_ENABLE_RNG was 0, did export rng_get, as Yasmarang.
The linker did not choose between two candidates. There was only one global definition. Had the board exported rng_get, the link would have failed with a duplicate symbol. The defect is the absence of conflict: an ambiguous parse resolved to the unsafe production, with no diagnostic.
Intended recognizer: seed <- TRNG x whitening
Actual recognizer: seed <- Yasmarang_mpy XOR Yasmarang_libngu(public constants)
Commit b18723dd (1 March 2021, libNgU migration) introduced the path. It shipped for about 1,978 days.
2. The preprocessor as a false recognizer
Libngu tried to refuse builds without hardware RNG:
#ifndef MICROPY_HW_ENABLE_RNG #error "get a HW TRNG plz" #endif
On the STM32 port, MICROPY_HW_ENABLE_RNG is always defined (default 0 in mpconfigboard_common.h). #ifndef tests existence, not value. The error is unreachable on this platform. Dead code from the day it was committed.
No single value of the macro satisfies both sides of the tree. Coldcard boards set MICROPY_HW_ENABLE_RNG (0) on purpose ("we have our own version"). The board rng.c itself errors if the macro is 1. Flip libngu’s check to #if MICROPY_HW_ENABLE_RNG == 0 and every Coldcard build fails. The guard that works needs an explicit second nonterminal: an opt-in that the board’s hardware-backed rng_get comes from outside MicroPython’s module.
LANGSEC reading: the validation logic cannot reject the unsafe language. Its accepting set is the entire configuration space of interest.
3. Weird machine: Yasmarang, and why XOR added nothing
Yasmarang (Ilya Levin) is a tiny non-cryptographic PRNG. It never claimed one-wayness. MicroPython’s copy seeds once, on first use:
pad = *(uint32_t *)MP_HAL_UNIQUE_ID_ADDRESS ^ SysTick->VAL; n = RTC->TR; d = RTC->SSR;
Libngu runs a second copy of the same algorithm with hardcoded public state (pad = 0x0a8ce26f, n = 69, d = 233). The XOR in my_random_bytes therefore mixes:
- a device/boot-dependent Yasmarang stream, with
- a globally identical, attacker-recomputable stream (on Mk2/Mk3; newer models reseed one word from the secure element).
Entropy does not add under XOR with a known sequence:
H(A ⊕ B) ≤ H(A) + H(B)
When B is a public function of draw rank alone, H(A ⊕ B) = H(A). Whitening that looks like mixing is an invertible substitution. It scrambles byte appearance enough for casual inspection. It adds zero bits against an adversary who knows the firmware.
After the first step, MicroPython’s state collapses further: n = pad | 2 re-derives n from pad, so n is not an independent state word. On Mk2/Mk3 the RTC story is worse.
4. Why the RTC contributed zero (Mk2/Mk3)
Three independent facts AND together:
- The bootloader enables only
RCC_OSCILLATORTYPE_HSE. The LSE path never runs. The Mk3 BOM has no 32.768 kHz crystal, only an 8 MHz ceramic resonator. - Board config sets
MICROPY_HW_ENABLE_RTC (0), so MicroPython never callsrtc_init_start(). rtc_init_finalise()in the seeding path early-returns because the init flag was never raised.
So RTC->TR and RTC->SSR hold reset zeros. The generator’s initial state on these models is effectively:
pad = UID[31:0] ^ SysTick->VAL
n = d = dat = 0
From the first iteration, the entire output stream until power-off is a deterministic function of a single 32-bit pad. Reboot redraws pad. Within a boot, the weird machine is a pure automaton.
On Mk4/Mk5/Q the RTC is clocked from HSE/32 and measures uptime, not wall-clock calendar. The claim that "menu setup later injects 24 bits of wall time" does not hold. Cold start remains the hazard window.
5. From "~40 bits" to ~2^22: overlap is not addition
Early public bounds (~2^40 Mk3, ~2^72 with SE reseed) summed SysTick range, RTC calendar fields, and treated sources as independent slots. Two corrections dominate.
UID word is wafer geometry, not uniqueness. The code reads only the first 32 bits of the 96-bit STM32 unique ID: the X/Y die coordinates on the wafer. Lot and wafer number live in the unread words. Reachable positions on a 300 mm wafer for these dies are on the order of a few thousand (~11-12 bits), not 32. The high bytes of that word are structurally sparse (sign/magnitude layout with unused high bits).
XOR packs both sources into one word. Adding bit counts assumes separate registers. Here:
| Field | Bits touched | Notes |
|---|---|---|
| Wafer X | low half of UID word | Overlaps SysTick’s low range |
| Wafer Y | high half of UID word | Only high Y bits sit above SysTick |
| SysTick->VAL | 0…reload-1 (~80k @ 80 MHz / 1 ms on Mk3) | Down-counter phase at first draw |
Distinct (UID, SysTick) pairs therefore collide in pad. Enumeration (Wizardsardine) yields about 4,456,448 reachable pad values ≈ 2^22.09 on Mk3, not 2^28 from multiplying grid size by SysTick range. That is a search-space size, not a Shannon measurement. If boot timing is peaked, min-entropy is lower still.
Motion Abstract’s recovered-seed corridor (~33 ms of boot jitter, roughly 2^21.9) fits this picture: SysTick counts down through a highly repeatable MicroPython bring-up, so observed victims occupy a band inside the structural ceiling rather than the full counter range.
Against ~22 bits of effective alphabet, BIP39’s PBKDF2-HMAC-SHA512 (2048 iterations) is cheap. A single modern GPU walks the Mk3 candidate set in seconds. Sweeping draw-rank variants still fits in an hour-class budget. The on-chain record is the economic proof: thousands of addresses emptied without touching a device.
6. Why every functional test stayed green
BIP39, BIP32, and ECDSA are indifferent to how the 32 bytes were born. The test suite asked:
- Did we emit 24 words?
- Did the checksum pass?
- Do derived addresses sign validly?
All yes. Yasmarang’s marginal distribution looks flat enough to pass casual statistical batteries on a single boot. The safety property was different: independence of seed distributions across restarts (NIST SP 800-90B §3.1.4). That is a property of the ensemble, not of one trace.
Functional safety makes the same distinction: a function that "works" is not a controlled hazard. Dieharder on Coinkite’s desktop simulator (bytes from the host, not the chip) was another false recognizer. Five safeguards existed in the ecosystem. None rejected the unsafe language of link-time fallback.
7. LANGSEC lessons (engineering controls)
-
Linkage is a parse of security contracts.
extern uint32_t rng_get(void)is an unchecked assumption. Prefer distinct namespaced symbols, or require the production object to define the symbol and poison the fallback object in release builds so a wrong bind cannot succeed quietly. -
#ifndef FEATUREis not an assertion. Require#if FEATURE == 1or an explicit alternate production (EXTERNAL_RNG_GET == 1). Better: a negative CI job that deletes the HW object and demands an unresolved-symbol failure, and another that forces the unsafe flag and demands a compile failure. -
Whitening cannot mint entropy. XOR with public constants is a known bijection. Mix only when you can argue independence, and test the composition, not the brochure.
-
Assume the adversary at t = 0. Minimum entropy is power-on. If the architecture does not harvest physical noise before the first secret is derived, the rest of the stack is a facade around a small finite language.
-
Distributional gates beat single-boot batteries. Reboot N times, fingerprint seeds, fail the build on collision / mutual-information bounds. If you cannot automate restart testing, you do not have an entropy claim.
-
Comments are not types.
// Generate … TRNG bytesdid not constrain the linker. Treat security-critical sources the way LANGSEC treats untrusted input: one canonical interpretation, rejection on ambiguity, no shotgun fallback.
The Bottom Line
Coldcard did not fail because Bitcoin’s cryptography is weak. It failed because the build system accepted an ambiguous program whose only inhabiting implementation of rng_get was a non-cryptographic automaton seeded by a ~24-bit boot phase and a wafer coordinate word. BIP39 then acted as a total decoder for that small language.
If your recognizer cannot reject the unsafe input (at compile time, at link time, or across reboots), it will eventually accept it in production.
Sources
- Coinkite, Technical Deep Dive into the Entropy Issue
- Block Engineering disclosure (predictable RNG fallback; model-specific reseed limits)
- Wizardsardine, Coldcard: the technical autopsy of an entropy failure (2^22 pad enumeration, UID geometry, RTC proofs)
- MicroPython discussion: Coldcard postmortem from MicroPython’s perspective
- Galaxy Research / TRM Labs contemporaneous loss tallies
- Motion Abstract, The $116 Million Typo (observed boot-jitter band; GPU reproduction economics)
- NIST SP 800-90B §3.1.4 (restart / health testing)
- LANGSEC; prior dyb note: CUE / Infrastructure as (Proven) Code
BTC/USD totals vary by wave confirmation and price snapshot. Cite the dated source you rely on.
Related: chokmah.me