Back to Blog

COLDCARD Incident: When a Wallet's "Random" Seed Wasn't Random

Code Auditing
August 7, 2026
15 min read
Key Insights
  • COLDCARD's losses trace to a single silent build-and-integration error, not a broken cryptographic algorithm: a guard checked whether an RNG macro existed rather than whether it was enabled, routing seed generation onto a deterministic software fallback instead of the hardware RNG.

  • Because the weakness was in the seeds themselves, the attack ran offline with no on-chain exploit transaction, collapsing seed recovery to ~40 bits on Mk2/Mk3 and ~72 bits on Mk4/Q/Mk5; the only on-chain activity was the sweeps of stolen funds.

  • A seed generated under the flawed firmware stays compromised even after an update — exposed funds must be moved to a new seed.

  • Cryptographic entropy paths must fail closed and be verified end-to-end in the shipped firmware, not just at compile time.

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:

  1. 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.
  2. 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.
  3. 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.

Explore MetaSleuth Investigation

Trace flows and build evidence for investigations

Try now for free

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

Sign up for the latest updates
~$10.26M Lost: Term Finance, MAYAChain | BlockSec Weekly
Security Insights

~$10.26M Lost: Term Finance, MAYAChain | BlockSec Weekly

During the week of August 17-23, 2026, two notable security incidents resulted in approximately $10.26M in total losses across Ethereum and MAYAChain. The highlighted Term Finance incident (~$8.5M) was a flawed governance design rather than a coding bug: each vault ships its own on-chain DAO whose support-threshold and participation checks are purely relative, with no absolute floor; with almost no one taking part in governance, there was no electorate to vote a proposal down and no guardian to cancel one, so an attacker acquired a supermajority of a vault's voting power for roughly 0.5 ETH and, after the execution delay elapsed, drained six of Term's vaults for approximately $8.5M in total. MAYAChain (~$1.76M) lost funds to a chain of accounting and state-validation defects, where a single crafted deposit made valid withdrawals appear to have failed, triggered a recovery path that inflated a low-liquidity pool's recorded native-token balance with no real backing, and let the attacker drain the inflated value by adding and withdrawing liquidity.

Harmony Cross-Shard ONE Mint + ~$47M Key Losses | BlockSec Weekly
Security Insights

Harmony Cross-Shard ONE Mint + ~$47M Key Losses | BlockSec Weekly

During the week of August 10-16, 2026, 5 notable security incidents are featured, involving approximately $47M in quantified losses, with the detailed analysis focused on a chain-implementation flaw in the Harmony Layer-1. Harmony suffered unauthorized minting of native ONE through a cross-shard receipt replay: destination shards derived the receipt spent-marker from unauthenticated MerkleProof.ShardID and BlockNum fields instead of the signed source header, so an already-credited receipt could be replayed with no matching source-shard debit. Approximately 3.01T ONE was forged, but its nominal value far exceeds the token's market capitalization and is neither realizable nor confirmed realized loss, so Harmony is excluded from the total; the ~$47M came from private-key compromises (Unknown Whale Wallet ~$25M, Kite ~$14M, and Coinsbuy ~$7.9M) plus a Fox business-logic flaw (~$117K).

~$1.6M Lost: Moke Token, LpdFi Exploits | BlockSec Weekly
Security Insights

~$1.6M Lost: Moke Token, LpdFi Exploits | BlockSec Weekly

During the week of August 3-9, 2026, 2 notable security incidents on BNB Chain resulted in approximately $1.6M in total losses, both from price manipulation. The highlighted LpdFi incident (~$697K) reused the same manipulable PancakeSwap pair reserves for both order valuation and interest redemption, letting the attacker inflate a position's principal and reshape the pool to redeem an oversized interest claim. Moke Token (~$906K) combined a manipulable spot price with duplicated LP dividend accounting to claim inflated MOKE and collect the resulting BNB dividends multiple times.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit