Brief Summary
Beginning 30 July 2026, funds were swept from COLDCARD Bitcoin hardware wallets across multiple waves that continued to unfold in the following days. There was no single on-chain attack transaction and no vulnerable contract; the loss came from an offline seed-recovery problem followed by on-chain sweeps. As of 7 August 2026, on-chain tracking had verified a floor of about 1,405 BTC (~$91M at the $64,700 7 August price) drained from roughly 4,925 addresses [1], wave-level attribution reached about 1,433 BTC across ten waves [2], and private-channel reconciliation with victims put the figure as high as 2,055 BTC (~$133M) [3].
The root cause was a wallet entropy failure: a 2021 firmware migration routed seed generation through ngu.random.bytes(), which fell back to a deterministic software generator instead of the intended STM32 hardware RNG. For affected devices, this collapsed seed recovery from a cryptographically infeasible problem into an offline search, letting an attacker enumerate candidate seeds and match them against public wallet data to recover the private keys. Building on our earlier weekly analysis of the incident, this deep dive quantifies the residual entropy for each affected device generation, traces how victims and stolen funds were identified on-chain, and examines a separate post-hotfix firmware regression that can deny service before login.
Background
COLDCARD is a Bitcoin hardware wallet. Like other self-custody wallets, its security ultimately depends on the unpredictability of the seed phrase generated during wallet setup. A BIP-39 seed phrase is human-readable, but the underlying security property is still the entropy of the random bytes used to create it. If the seed-generation output is predictable, the wallet's security collapses, however carefully the seed is later stored.
COLDCARD spans the Mk1 through Mk5 hardware revisions and the Q model. Mk2 and Mk3 use the legacy firmware line. Mk4, Mk5, and Q use separate Standard and Edge release tracks.
Most COLDCARD application logic is Python running on MicroPython, with native C modules providing hardware and cryptographic operations. A secure seed-generation path should read 32 bytes from the STM32 hardware RNG, fail if the peripheral stalls or repeats a value, hash the result, and then encode it as BIP-39 words. In v3.2.2, make_new_wallet() followed this path:
make_new_wallet()
|- ckcc.rng_bytes(seed)
| `- random_buffer()
| |- read STM32 RNG->DR
| `- fail on timeout or repeated output
|- SHA-256(seed)
`- BIP-39 seed words
Vulnerability Analysis
The root cause was a build and integration error around MICROPY_HW_ENABLE_RNG. COLDCARD's production board configuration set this macro to 0, because the firmware used its own board-local hardware-RNG wrapper rather than MicroPython's hardware-RNG implementation. However, the wallet-generation path had been migrated to ngu.random.bytes(32), and libngu's STM32 path ultimately depended on the global rng_get() symbol.
The affected path entered libngu's my_random_bytes(). For each output word, it XORed the value returned by CHIP_TRNG_32() with its own Yasmarang output:
generate_seed()
`- ngu.random.bytes(32)
`- my_random_bytes() [libngu]
|- CHIP_TRNG_32()
| `- rng_get() [MicroPython]
| `- Yasmarang A
| `- UID + SysTick + RTC
|
|- my_yasmarang() [libngu]
| `- Yasmarang B
| |- Mk2/Mk3: public initial state
| `- Mk4/Q/Mk5: 32-bit pad reseed
|
`- output = Yasmarang A XOR Yasmarang B
Yasmarang A was MicroPython's fallback behind rng_get(), initialized from device and timer state. Yasmarang B belonged to libngu: it used public initial values on Mk2/Mk3, while Mk4/Q/Mk5 replaced only its 32-bit pad with secure-element-derived data. The board-local STM32 RNG still existed, but ngu.random.bytes() did not call it.
The mismatch is visible directly in the build configuration. The Mk4 mpconfigboard.h disabled MicroPython's hardware-RNG branch:
// We have our own version of this code.
#define MICROPY_HW_ENABLE_RNG (0)
Libngu's CHIP_TRNG_32() still treated the macro as sufficient proof of a hardware RNG because it checked only whether the macro existed, then called rng_get():
extern uint32_t rng_get(void);
#define CHIP_TRNG_32() rng_get()
#ifndef MICROPY_HW_ENABLE_RNG
#error "get a HW TRNG plz"
#endif
That guard misses the dangerous case: a macro defined as 0 is still defined. The build therefore succeeded, and rng_get() resolved to MicroPython's STM32 RNG module instead of COLDCARD's separate board-local wrapper (random32() / random_buffer(), exposed to Python as ckcc.rng_bytes). In MicroPython's rng_get() selection, MICROPY_HW_ENABLE_RNG == 0 selected the software fallback branch:
#if MICROPY_HW_ENABLE_RNG
// STM32 hardware RNG
#else
// Yasmarang software fallback
#endif
Mk2/Mk3: About 40 Bits
The compiled pyb_rng_yasmarang() fallback initialized and advanced Yasmarang as follows:
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;
n = RTC->TR;
d = RTC->SSR;
}
pad += dat + d * n;
pad = (pad << 3) + (pad >> 29);
n = pad | 2;
d ^= (pad << 31) + (pad >> 1);
dat ^= (char)pad ^ (d >> 8) ^ 1;
return pad ^ (d << 5) ^ (pad >> 18) ^ (dat << 1);
}
The variables occupy 104 bits, but state size is not entropy. dat starts at zero, and the other values are fixed metadata or correlated timer readings rather than independent secrets. Under the loose Mk2/Mk3 model used for the approximate 40-bit figure:
| Input | Candidate values | Enumeration cost |
|---|---|---|
Known UID_low32 |
1 |
2^0 |
SysTick->VAL |
80,000 |
2^16.29 |
RTC->TR time of day |
86,400 |
2^16.40 |
RTC->SSR subsecond |
256 |
2^8 |
Treating all timer fields as independent gives an intentionally broad ceiling:
80,000 * 86,400 * 256
= 1,769,472,000,000
= 2^40.69 candidate initial states
An exhaustive search therefore takes at most 2^40.69 trials and, under a uniform-position assumption, about 2^39.69 trials on average. This is an enumeration upper bound, not 40 bits of cryptographic entropy. If the RTC registers are static during a normal cold boot, only SysTick remains, reducing the ceiling to about 2^16.29. If the UID, timers, and number of earlier RNG calls are known, there is exactly one stream: 2^0. Unknown call history adds only the number of plausible execution traces, not a fresh entropy source. Likewise, generating eight 32-bit words for a 256-bit seed does not multiply the search space: every word is determined by the same initial state.
At the libngu layer, my_random_bytes() mixed the MicroPython fallback above with libngu's separate Yasmarang generator. The source does not literally contain chip = rng_get(): CHIP_TRNG_32() expands to rng_get(). On Mk2/Mk3, the second generator began from public constants:
static uint32_t yasmarang_pad = 0x0a8ce26f, yasmarang_n = 69, yasmarang_d = 233;
static uint8_t yasmarang_dat = 0;
uint32_t chip = CHIP_TRNG_32();
// ... adjacent-output health check ...
chip ^= my_yasmarang();
Both streams were therefore reproducible once the MicroPython fallback state and call history were known. XOR changed the output values but added no entropy. Even if UID_low32 is unknown, UID_low32 ^ SysTick still collapses both inputs into one 32-bit pad; their nominal bit counts cannot be added.
Mk4/Q/Mk5: About 72 Bits
Later models kept the same two-generator construction, but rng_seeding() added secure-element material to libngu's generator during boot:
a = callgate.read_rng(1) # SE1
b = callgate.read_rng(2) # SE2
n = ngu.hash.sha256d(a + b)
n, = ustruct.unpack('I', n[0:4])
ngu.random.reseed(n)
Although 40 bytes entered the hash, only its first four bytes reached reseed() [4]. The random_reseed() implementation then replaced only libngu's 32-bit pad word:
STATIC mp_obj_t random_reseed(mp_obj_t arg)
{
yasmarang_pad = mp_obj_get_int_truncated(arg);
return mp_const_none;
}
The other libngu state words kept their public values, and the MicroPython fallback was not reseeded. The approximate 72-bit figure combines the 32-bit libngu reseed with a loose upper bound for the later models' timer state:
MicroPython fallback:
120,000 SysTick values * 86,400 RTC times * 256 subseconds
= 2^41.27 states
Libngu secure reseed:
2^32 values
Combined ceiling:
2^41.27 * 2^32 = 2^73.27 candidates
Average enumeration:
2^73.27 / 2 = 2^72.27 trials
This is the source of the "about 72 bits" figure. It is an average attack-work estimate, not 72 bits supplied by the secure elements. The timer fields are correlated and may be reconstructed; if the MicroPython fallback state is known, only the 2^32 reseed values remain, averaging 2^31 trials. Hashing the final 32 random bytes cannot increase the number of possible seeds.
Affected Versions
| Device and track | Outside this regression | Affected seed-generating firmware | Effective bit security | First fixed release |
|---|---|---|---|---|
| Mk1 | Through v3.0.6 | None | - | N/A |
| Mk2/Mk3 | Through v3.2.2 | v4.0.0-v4.1.9 (official advisory from v4.0.1) | About 40 bits when affected | v4.2.0 |
| Mk4/Mk5 Standard | N/A | Before v5.6.0 | About 72 bits before fix; at least 128 bits after | v5.6.0 |
| Q Standard | N/A | Before v1.5.0Q | About 72 bits before fix; at least 128 bits after | v1.5.0Q |
| Mk4/Mk5 Edge | N/A | Before v6.6.0X | About 72 bits before fix; at least 128 bits after | v6.6.0X |
| Q Edge | N/A | Before v6.6.0QX | About 72 bits before fix; at least 128 bits after | v6.6.0QX |
The relevant version is the firmware that generated the seed, not the firmware currently installed. New seeds generated on or after the fixed releases use the corrected path, but updating does not repair an existing seed. The official affected range for Mk2/Mk3 begins at v4.0.1 [5], while source-level analysis also includes v4.0.0 [4]. Independent dice entropy can increase the seed's security, while a strong BIP-39 passphrase adds a separate barrier without repairing the seed itself [6].
Best Security Auditor for Web3
Validate design, code, and business logic before launch
Victim and Fund Tracking
There was no on-chain exploit transaction; recovery was necessarily offline. Targeting a seed generated by affected firmware, an attacker could constrain and enumerate the candidate RNG states described above, replay each candidate's seed-generation stream, derive the resulting wallet keys, and match them against public wallet data, then sweep any matched funded wallet on-chain. This only reached wallets derivable from the seed alone: a strong, unique BIP-39 passphrase mixes independent, user-supplied entropy the RNG flaw never touched into key derivation through PBKDF2, placing such wallets outside pure seed enumeration. The wallets that were swept were necessarily ones without such protection [6]. Because the theft surfaced only as on-chain sweeps rather than a traceable exploit, identifying the victims and following the funds became a matter of on-chain forensics.
Several independent efforts tracked the stolen funds: public tracking sites (Coldcard Sweep Watch [1], coldcard.rip [2], and the Coldcard Hack Tracker [7]) and a private-channel reconciliation by Galaxy Research [3], with their reported totals compared below. Because Coldcard Sweep Watch published its methodology, we use it to illustrate the identification process, a feedback loop between off-chain reports and on-chain analysis:
- Off-chain anchors. Victims and researchers supplied public addresses or transaction IDs, plus device and seed-generation context where available. Each report was treated as a lead and checked on-chain; no seed phrase, private key, or xpub was required.
- On-chain expansion. Starting from confirmed anchors, scanners searched relevant blocks for the same sweep characteristics: wallets emptied without change, similar input types, tight timing, repeated fee rates, common destinations, or later co-spends.
- Off-chain cross-checks. New victim reports, researcher datasets, and service attribution were used to confirm or reject candidate waves. Verified clusters and heuristic candidates remained separate.
Bitcoin identifies addresses, not people or wallet models. One wallet may control many addresses, so address count is not victim count. The first widely reported major wave, 960188, accounted for 594.48 BTC; continued scanning and reporting raised the total. As of 7 August 2026, Coldcard Sweep Watch reported a verified floor of about 1,405.07 BTC (~$91M at the $64,700 7 August price) from roughly 4,925 addresses [1], while the 3 August coldcard.rip snapshot attributed up to 1,433.13 BTC gross, 1,432.48 BTC to destinations after fees, from 5,477 addresses across ten waves [2]. A separate private-channel reconciliation by Galaxy Research, drawn from correspondence with victims, put the figure higher still, from about 1,596 BTC up to 2,055 BTC (~$133M at the same price) [3]. The differences reflect evidence thresholds, discovery time, and which confirmation channel each tracker relies on.
Funds were then followed through three observed layers: swept source addresses, direct sweep destinations (holding), and subsequent consolidation destinations (vault). The table shows the distinct address count at each layer:
| Pattern | Example routes | Tracking consequence |
|---|---|---|
| Many sweeps to one or two holding addresses, then one vault | 960183: 204 -> 2 -> 1; 960188: 500 -> 1 -> 1 |
Destination convergence makes the cluster comparatively strong and easy to follow |
| Sweeps stop at holding addresses without an onward consolidation | 960352: 352 -> 1 -> 0; 960668: 795 -> 1 -> 0 |
The holding address remains trackable, but there is no later co-spend to strengthen attribution |
| Fresh destinations per sweep, sometimes followed by separate vaults | 960359: 13 -> 13 -> 0; 960395: 1,918 -> 294 -> 293 |
A shared-collector detector fails; grouping depends on timing, fee rate, transaction template, and off-chain corroboration |
When tracked outputs move, analysis follows splits, merges, and peel chains while conserving value after fees and capping attribution at the swept amount. Funds entering an exchange or other commingled service reduce confidence; the service is not added to the attacker cluster.
A Fix That Needed Fixing: A Potential Bricking Regression?
Separately from the entropy failure, the hotfix that resolved it introduced a distinct firmware regression [8, 9]. In restoring the hardware RNG path, the fix left a hardware seed-error condition unhandled, which can deny service before login and drew claims of permanently bricked devices. How far that stronger claim holds turns on the register-level details.
The STM32 hardware RNG exposes a control register (RNG_CR), a status register (RNG_SR), and a 32-bit data register (RNG_DR). The relevant status is:
| Bit | Role |
|---|---|
RNGEN |
Enables the RNG and its analog noise sources |
DRDY |
Indicates that data is ready in RNG_DR; software must still reject zero |
SECS / SEIS |
Current seed-health-test failure / latched seed-error status |
CECS / CEIS |
Current RNG-clock fault / latched clock-error status |
The distinction between current and latched status matters. SECS describes the present noise-source condition, while SEIS records that a seed error occurred until software clears it. On the Mk4/Q-family STM32L4S, a seed error stops new random-number generation; on the Mk3 STM32L4, data may remain available but must not be trusted. Clock errors are separate and do not establish this seed-error lockup.
The required recovery sequence depends on the STM32 generation:
| Device family | Documented seed-error recovery |
|---|---|
| Mk3 STM32L4 (RM0351, RNG error management [10]) | Clear SEIS, then clear and set RNGEN |
| Mk4/Q-family STM32L4S (RM0432, RNG error management [11]) | Clear SEIS, read and discard 12 RNG_DR words, then confirm SEIS remains clear |
The 31 July entropy hotfix correctly made rng_get() resolve to the hardware TRNG, but the Mk4/Q-family rng_get_or_fault() implements no seed-error recovery. rng_init() only acts when RNGEN is clear, while the read loop checks only DRDY. If a seed error leaves RNGEN enabled but suppresses DRDY, initialization becomes a no-op; each read waits 10 ms and raises OSError(EFAULT) without clearing SEIS.
This can reach the UI before login. Both the number-pad mempad._start_scan() and Q keyboard._start_scan() shuffle their scan order from the keypress interrupt. An RNG exception there can block PIN entry and the normal firmware-upgrade menu for the remainder of that hardware session.
The code-level failure path is credible, but the stronger "permanent brick attack" claim is not established. The RNG control and status bits reset to zero on a hardware reset, so a single transient error should not permanently damage the peripheral; persistence across a full power cycle has not been demonstrated. Nor is there a demonstrated remote or reliably controlled way to trigger the seed-health-test failure. An X post [8] called the bricking confirmed, but the PR it pointed to, the community-submitted PR #692 [9], has its own author state that they analyzed the fault with a register mock, did not reproduce it on real Mk4/Q hardware, and did not independently confirm the field reports. The best-supported classification is therefore a potential pre-login denial-of-service and reliability regression, not a confirmed permanent bricking attack.
The maintainers' own fix, PR #693 [12], checks the seed-error flags, adds bounded recovery and retries, rejects suspect samples, and catches only the expected keypad error; it was merged on 5 August 2026, superseding the community PR #692 [9], which was closed unmerged on 4 August 2026.
Conclusion
This incident was a wallet entropy failure that turned seed recovery from cryptographically infeasible into an offline search problem for affected devices and workflows. The key engineering failure was that the shipped firmware did not prove that the security-critical seed-generation API actually reached the intended hardware RNG. Build guards must check both macro existence and macro value, fallbacks for cryptographic entropy must fail closed, and CI should verify symbol provenance and end-to-end entropy flow in the final firmware image. For affected users, the remedy is not a firmware update: updating does not repair a seed already generated under the flawed path, and adding a passphrase afterward does not protect funds already held at that seed's addresses. Those funds must be moved to a wallet built from a new seed on a fixed release; only funds that were already behind a strong, unique passphrase stayed outside seed-alone enumeration [6].
References
- [1] Coldcard Sweep Watch — verified drained-address set and loss tracker
- [2] coldcard.rip — incident ledger, routes, and attribution
- [3] Galaxy Research — COLDCARD loss estimate from victim reports
- [4] Block Engineering — Predictable RNG Fallback and 32-Bit Reseed in COLDCARD Firmware
- [5] Coinkite — COLDCARD security advisory and affected firmware ranges
- [6] Coinkite — Technical deep dive into the entropy issue
- [7] Coldcard Hack Tracker — live wave-by-wave sweep totals
- [8] X post claiming the post-hotfix RNG fault could permanently brick the device
- [9] Coldcard firmware PR #692 — community TRNG fault-recovery proposal (closed unmerged)
- [10] STMicroelectronics RM0351 — STM32L4 RNG registers and error management
- [11] STMicroelectronics RM0432 — STM32L4S RNG registers and error management
- [12] Coldcard firmware PR #693 — merged bounded-retry TRNG recovery and keypad error handling



