Author: Johan & Lisa
Edit: 77
Context
On July 30, 2026, a series of addresses on the blockchain sequentially transferred out their funds; within 41 minutes, 1,196 single-signature addresses were emptied, resulting in approximately 1,082 bitcoins disappearing. This was only the first wave. By early August, confirmed losses totaled at least 1,719 bitcoins, equivalent to approximately $111 million, involving over 5,200 addresses, with the attack occurring in three to four waves.
What’s most puzzling is the status of these wallets. Most of the funds are sitting in cold wallets, untouched for months or even years—the issue lies at the level of the private keys. All private keys were generated by Coldcard hardware wallets, and the owners did not add any additional dice entropy or enable a BIP-39 passphrase. The compromised wallets all used the most straightforward, default setup.
The impact range is analyzed separately by firmware line. For Mk2 and Mk3 with firmware versions 4.0.1 to 4.1.9, the effective entropy is only about 40 bits, which is the most severe case. For Mk4, Mk5, and Q on specific firmware versions, the entropy is approximately 72 bits, still within the range of offline brute-force attacks.
Coinkite responded swiftly. On July 30, they issued a warning, and the next day released a patched firmware: Mk2 and Mk3 updated to version 4.2.0 or higher, Mk4 and Mk5 to 5.6.0 or higher, and Q to 1.5.0Q or higher. They also destroyed all inventory of the vulnerable versions and suspended shipments.
This attack is unrelated to remote intrusion or supply chain poisoning. The attacker never touched any device; he obtained the private key through offline brute-forcing, systematically testing each possible seed in the search space. The vulnerability had lain dormant on the device for years until external researchers developed a brute-force tool, bringing it to light. Muan later reproduced the entire attack chain. This article begins with the firmware code, using Mk3 firmware version 4.1.9 as an example, to reconstruct the single line of code that enabled the mass draining of thousands of Coldcard devices.
Root cause of the vulnerability
The Coldcard is designed to generate seeds using the built-in hardware TRNG (true random number generator) of the STM32L475 chip. However, two errors in the firmware combined to turn the seed into a software PRNG with nearly identical state across all devices worldwide. This PRNG is called Yasmarang, reducing the effective entropy on the Mk3 to approximately 40 bits.
Mistake 1: The hardware RNG was actively disabled.
The first mistake is in the build configuration: Coldcard explicitly disables MicroPython's hardware RNG support in mpconfigboard.h.
// stm32/COLDCARD/mpconfigboard.h
// ⚠️ The team has implemented their own version of ckcc.rng_bytes, so disable MicroPython's built-in version
#define MICROPY_HW_ENABLE_RNG (0)
When first seeing this macro, it may seem harmless. Coinkite has its own ckcc.rng_bytes, which directly calls the STM32 hardware TRNG and offers more control than the MicroPython port layer implementation. The trap lies in the expansion of this macro below: libngu’s my_random_bytes() reads random numbers through the CHIP_TRNG_32() macro, which, after expansion, calls rng_get() provided by the MicroPython STM32 port layer and treats it as a hardware TRNG. However, rng_get() silently degrades when MICROPY_HW_ENABLE_RNG is set to 0, leading to the next mistake.
Mistake 2: The fallback of rng_get() was incorrectly treated as a hardware TRNG
We initially examined libngu's random.c. Following the macro expansions step by step, we eventually landed on the else branch in ports/stm32/rng.c, part of MicroPython's STM32 port layer.
// external/libngu/ngu/random.c
#ifdef MICROPY_PY_STM
// ports/stm32/rng.c
extern uint32_t rng_get(void);
# define CHIP_TRNG_SETUP()
# define CHIP_TRNG_32() rng_get()
#endif
...
void my_random_bytes(uint8_t *dest, uint32_t count) {
uint32_t chip = CHIP_TRNG_32(); // ⚠️ Assumes reading from hardware TRNG, but may actually receive Yasmarang output
if (chip == last) ... // Reports error if two consecutive words are identical
chip ^= my_yasmarang(); // XORs with a global constant Yasmarang stream (pad=0x0a8ce26f)
...
}
rng_get() is provided by ports/stm32/rng.c. It evaluates the value using #if MICROPY_HW_ENABLE_RNG; if the macro is non-zero, it reads from RNG->DR, which is the hardware TRNG itself. If the macro is zero, the else branch is compiled in, returning the output of the software PRNG. Coldcard’s configuration is exactly 0, so every出厂 Mk3 follows this path.
// external/micropython/ports/stm32/rng.c
#if MICROPY_HW_ENABLE_RNG
uint32_t rng_get(void) {
... read RNG->DR hardware TRNG ...
}
#else // MICROPY_HW_ENABLE_RNG
// ⚠️ Vulnerability point: Fallback exists, and seed is almost entirely predictable
static uint32_t pyb_rng_yasmarang(void) {
static bool seeded = false;
static uint32_t pad = 0, n = 0, d = 0;
static uint8_t dat = 0;
if (!seeded) {
seeded = true;
rtc_init_finalise();
pad = *(uint32_t *)MP_HAL_UNIQUE_ID_ADDRESS ^ SysTick->VAL; // pad = UID ^ SysTick
n = RTC->TR; // n = RTC_TR
d = RTC->SSR; // d = RTC_SSR
}
pad += dat + d * n;
pad = (pad <> 29);
n = pad | 2;
d ^= (pad <> 1);
dat ^= (char)pad ^ (d >> 8) ^ 1;
return pad ^ (d <> 18) ^ (dat << 1);
}
uint32_t rng_get(void) { return pyb_rng_yasmarang(); }
#endif
This fallback is called Yasmarang, a pseudo-random number generator that circulated on forums in the 1990s; it is not a cryptographic algorithm, has an internal state of only 128 bits, and its initial seed is almost entirely derived from predictable sources. Each time libngu reads a "hardware random number," it receives the output of Yasmarang, then XORs it with libngu’s own global constant stream. In the end, the entire "randomness" reduces to just one 32-bit value: pad = UID ^ SysTick.
The initial state of Yasmarang is nearly a global universal constant.
The state of Yasmarang consists of four 32-bit variables: pad, n, d, and dat. There are two instances of Yasmarang on the Mk3, initialized as follows.
libngu static instance (ngu/random.c): pad = 0x0a8ce26f, n = 69, d = 233, dat = 0 ← Identical across all devices globally
rng_get() fallback instance (ports/stm32/rng.c): pad = UID ^ SysTick, n = RTC_TR, d = RTC_SSR, dat = 0
↑ ~16 bits of entropy ↑ Hit as 0 ↑ Hit as 0 (0xFF is only an upper bound check)
The libngu stream is a compile-time constant. The value 0x0a8ce26f is hardcoded in ngu/random.c as static uint32_t yasmarang_pad = 0x0a8ce26f, yasmarang_n = 69, yasmarang_d = 233, with no runtime input. It differs from the original default values in Yasmarang; the original author Ilya Levin’s default was 0xeda4baba, which is preserved in MicroPython’s modurandom.c. Coinkite changed the pad value to 0x0a8ce26f in libngu while retaining the original n and d values. For an attacker, both the original and the customized values are publicly known fixed constants. This stream can be precomputed offline, effectively meaning all devices globally share the same mixer sequence.
The only remaining variable is the fallback instance of rng_get(), where pad = UID ^ SysTick, which breaks down into three components. The UID is a 96-bit serial number stored in the chip's fuses; the firmware uses only the lower 32 bits, formatted as (wafer_Y << 16) | wafer_X. X and Y are wafer coordinates, and for Phase A batches, values almost entirely fall within the range of 0 to 72, at most 0 to 255, yielding approximately 14 bits of entropy.
SysTick is the countdown value of SysTick->VAL when rng_get() is first called. The chip runs at 80 MHz with a reload value of 80,000, yielding values between 0 and 79,999, equivalent to approximately 17 bits of entropy.
RTC_TR and RTC_SSR are the values of the RTC registers immediately after power-on, before the application has initialized them. Each of these represents an additional enumeration dimension with a very small value space. rtc_tr is the packed-BCD-encoded RTC->TR, and rtc_ssr has a maximum value of MK3_RTC_SSR_MAX, which is 0xFF. In all on-chain hit vectors we have verified, both of these values are 0.
Adding the three components together, the UID contributes approximately 14 bits, SysTick contributes about 17 bits, both RTC entries contribute 0 bits, and button presses add roughly 5 bits, resulting in a true entropy source of 36 to 37 bits for the entire seed generation. Coinkite’s claim of “about 40 bits” aligns perfectly with the search space revealed through code reverse engineering—a space that can be brute-forced by a GPU cluster in a few days.
Attack process
During the first boot of Mk3, from code to private key, the random numbers are consumed through three typical random number consumption profiles. The attacker's core task is to model each of these profiles and precisely reconstruct every step of the PRNG.
Phase One: Initial State
Two things happen immediately after the device is powered on.
Power on
└── libngu static Yasmarang (ngu/random.c) pad=0x0a8ce26f n=69 d=233 dat=0 ← Global constant
└── rng_get() initial call seeding (ports/stm32/rng.c) pad=UID^SysTick n=RTC_TR=0 d=RTC_SSR=0 dat=0
At this point, all "randomness" has been compressed into a 32-bit pad, plus two small dimensions that can be enumerated. The global Mk3 pad is confined to a small, exhaustible box.
Stage 2: Button Operations
On first setup, the user is forced to set a PIN, confirm terms by pressing OK, and navigate through the menu. Each keypress triggers _start_scan() in shared/mempad.py, which calls _rand_below() three times via shuffle(self.scan_order). The length of scan_order equals NUM_ROWS, which is 4. shuffle is defined in shared/random.py, and its randbelow is directly bound to libngu's ngu.random.uniform, which is the C-level _rand_below.
# shared/random.py
import ngu
randbelow = ngu.random.uniform # Equivalent to _rand_below in libngu random.c
bytes = ngu.random.bytes # Equivalent to my_random_bytes
def shuffle(lst):
# Fisher-Yates, same as CPython random.py
for i in reversed(range(1, len(lst))):
j = randbelow(i + 1) # ← Consumes 1 step chip + 1 step mixer (including rejection sampling retries)
lst[i], lst[j] = lst[j], lst[i]
# shared/mempad.py
# Each key press -> _start_scan() -> shuffle(self.scan_order) # scan_order length = NUM_ROWS = 4
These key consumption patterns must be modeled individually. After analyzing the v4.1.9 firmware source code, we confirmed three usage patterns.
Profile A is a retail first-time setup. Press twice before accepting terms; settings.save() searches for an empty slot among 32 slots, excluding my_pos, leaving 31 slots—resulting in 30 calls to _rand_below—then enter the PIN, navigate the menu, press several more times, and finally obtain entropy with random_bytes(32).
kpad_a × shuffle(4) # keypress before accept_terms (kpad_a = 2)
shuffle(31) # settings.save() searches for an empty slot among 32 slots, excluding my_pos, leaving 31 slots = 30 calls to _rand_below
kpad_b × shuffle(4) # PIN entry + menu navigation (kpad_b enumerates [4, 34])
random_bytes(32) # gather entropy
Profile B is the first boot after flashing or wiping, with NVRAM empty. nvstore first performs one shuffle(32), then consumes 3072 steps in lockstep across 3 slots × 16 blocks × 256 bytes, outputting no feedback and advancing only the state. For a blank NVRAM boot, an additional shuffle(32) is performed after these two steps, followed by key consumption, and finally my_random_bytes(32).
nvstore shuffle(32) # 31 calls to _rand_below
nvstore blanking 3×16×256B # 3 slots × 16 blocks × 256B = 3072 lockstep consumption, output does not loop back (state progresses only)
empty-nvram-onboarding profile # Blank NVRAM onboarding: one additional shuffle(32) after the two steps above
Key press consumption (kpad_b enum [4, 34]) # Each shuffle(4)
my_random_bytes(32)
Profile C is a paper wallet. A paper wallet is a cold storage method in which the private key is printed on paper and kept offline, away from any network connections—commonly used for gifting or long-term storage. The Coldcard’s Paper Wallets menu is specifically designed to generate printable wallet pages. Its entropy consumption is the most direct: after an existing wallet has been created, re-entering the menu requires pressing the buttons 8 to 25 times, after which my_random_bytes(32) is used directly as the private key, bypassing the BIP-39 word list entirely.
Phase Three: Seed Construction and Address Derivation
From random_bytes(32) to the final Bitcoin address, Mk3 follows this pipeline.
raw_bytes = random_bytes(32) # Concatenate 4 bytes from 8 iterations of (mpy_step ^ yas_step)
entropy = ngu.hash.sha256s(raw_bytes) # ⚠️ Single SHA-256, not sha256d
mnemonic = BIP-39(entropy, wordlist=english) # 24-word mnemonic
seed = PBKDF2-HMAC-SHA512(mnemonic, "mnemonic", 2048)
master = HMAC-SHA512("Bitcoin seed", seed)
child = m/{44,49,84}'/0'/0'/0/0 # First receiving address
address = bech32(hash160(compressed_pubkey)) # Default BIP-84
Each step in the pipeline is a deterministic, reproducible function, common to all three profiles. Once an attacker correctly guesses the pad, they can offline-recalculate the entire chain.
Stage 4: Brute Force and Hit on GPU
The attacker’s approach is actually quite straightforward, in four steps.
Step one: Construct the candidate pad set. Take X and Y wafer coordinates from 0 to 72, combine them with the UID space of approximately 5,300 values, and perform a Cartesian product with SysTick values from 0 to 79,999, resulting in 424 million candidate pads. Although this number sounds large, it takes only a few days on a GPU.
Step two: Enumerate the number of key presses for each pad, with kpad_b ranging from 4 to 34. Since both rtc_tr and rtc_ssr have zero hits, place them in the outermost loop as the inner loop.
Step three: Run the full seed pipeline in the GPU kernel—SHA-256, BIP-39, PBKDF2 for 2048 rounds, BIP-32, Hash160, and Bech32. The constant stream from libngu can be precomputed offline; the kernel retrieves words by index, eliminating the overhead of each thread independently advancing the constant PRNG.
Step four: Match using a Bloom filter or sorted array of hash160 values, with O(log n) complexity, targeting the set of all single-signature P2WPKH addresses on the network.
The cost of this search can be quantified. Running Phase A space on a single Apple M1 GPU—with a 72x72 UID, 80,000 SysTick variations, and 31 key press counts—yielding approximately 14.8 billion candidates—took about 8.6 days. A data center-grade A100 cluster can compress the same search to just a few hours. The ~72-bit space on Mk4, Mk5, and Q is 2^32 times larger than that of Mk3, but the vulnerabilities share the same origin, and the profiling modeling approach remains identical; the only difference is that the brute-force tool shifts from a single machine to a cluster, extending the time from days to weeks—a cost attackers are still willing to pay. This is why they appear equally on victim lists.

