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
~$88M Lost: COLDCARD & LULA Exploits | BlockSec Weekly
Security Insights

~$88M Lost: COLDCARD & LULA Exploits | BlockSec Weekly

During the week of July 27 to August 2, 2026, two notable security incidents caused roughly $88M in losses across Bitcoin and BNB Chain. The highlighted COLDCARD incident was a hardware-wallet firmware entropy failure: a build guard that checked whether an RNG configuration macro existed rather than whether it was enabled routed seed generation to a deterministic software fallback, enabling an attacker to recover affected seeds and sweep at least 1,370 BTC (~$88M) across a series of on-chain waves. The LULA token on BNB Chain lost ~$578K to a business-logic flaw where an attacker-reachable path could trigger its privileged `recycle()` function, pulling LULA out of a PancakeSwap V2 pair, resyncing its reserves to the manipulated balance, and draining its liquidity.

Newsletter - July 2026
Security Insights

Newsletter - July 2026

July 2026's three largest DeFi incidents totaled approximately $67.9M in losses across Arbitrum and Solana. AFX Trade lost ~$24.15M after a supply chain attack compromised validator signing authority. Ostium's OLP vault was drained of ~$23.75M through compromised oracle infrastructure that submitted attacker-controlled prices. BonkDAO lost ~$20M when an attacker spent $4.4M to acquire enough voting power to pass a malicious treasury transfer with no timelock. All three incidents demonstrate that a protocol's security boundary extends far beyond smart contract code.

~$39.5M Lost: Allbridge, Wanchain & More | BlockSec Weekly
Security Audits

~$39.5M Lost: Allbridge, Wanchain & More | BlockSec Weekly

During the week of July 20-26, 2026, 8 notable security incidents resulted in approximately $39.5M in total losses across Solana, Ethereum, BNB Chain, Arbitrum, Zilliqa, and Cardano. The highlighted Allbridge Core incident (~$1.65M) exposed a Solana input validation flaw where the same Pool account was accepted in both swap roles, with analysis reconstructed entirely from the deployed program binary. Other analyzed incidents include Wanchain (~$500K, flawed message encoding in a Cardano bridge validator), Zilliqa (~$400K, flawed nonce generation in a Ledger app since 2019), and Lien Finance (~$542K, flawed validation logic in bond exchange).

Best Security Auditor for Web3

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

BlockSec Audit