SharpSec
security research

Four Coldcard Bugs, Reported and Fixed

Reviewing the Coldcard Q's dice-roll entropy turned up four bugs in its seed, multisig, USB, and QR paths. Reported to Coinkite and fixed in 5.6.1 / 1.5.1Q.

12 min read
Red dice resting on a hardware-wallet circuit board on an electronics workbench

What We Checked

The Coldcard Q is a widely used Bitcoin hardware wallet, the device that holds your private keys offline. Earlier this year Coinkite disclosed a years-long RNG bug that produced weak entropy on affected units, and told owners to regenerate their seeds. The dice-roll path sidesteps that hardware RNG entirely, you supply the randomness by hand, so it's the fallback worried users were pointed at. We took our own Q and checked it first: does it preserve the entropy you roll?

It does. The seed is SHA-256 of your rolls and nothing else, which we later confirmed against the compiled bytes on the shipped device, not just the source. Reading the code around it is where the review turned things up: four bugs, in the dice input path and in the multisig, USB-download, and QR-reader code next to it. We reported all four privately, each with a working patch. Coinkite turned the fixes around in days: it merged the dice patch, reworked the other three with extra hardening, shipped them in Coldcard 5.6.1 (Mk4) and 1.5.1Q (Q), and credited the work. The QR-reader bug is Q-only, so its fix ships in 1.5.1Q alone.

#AreaSeverityAttacker needsFixed in
1Dice entropy gateHighuser holds a digit key5.6.1, 1.5.1Q
2Multisig cosigner dedupHighvictim imports attacker's config5.6.1, 1.5.1Q
3dwld PSRAM downloadMediumUSB access to the device5.6.1, 1.5.1Q
4BBQr uninitialized readMediumvictim scans attacker's QR1.5.1Q (Q)

The bugs affect Coldcard Q 1.5.0Q and the equivalent Mk4/Mk5 firmware before 5.6.1. Reported privately in August 2026, fixed and shipped on 20 August 2026. Each bug below has the code and a link to the vulnerable source.

Bug 1: The Dice Gate Counts Key-Repeats, Not Rolls

Severity High · Code seed.py, ux_q1.py · Fix PR #721

The seed math is honest, and dead simple. Pick the dice-only path and the device doesn't touch its RNG: the seed is SHA-256 of the digits you roll, and nothing else.

# shared/seed.py  dice-only master seed
seed = b''                 # starts empty: no RNG, no TRNG
md = sha256(seed)
while 1:
    ch = await pr.wait()
    if ch in '123456':     # a physical roll, 1 to 6
        count += 1
        md.update(ch)      # streamed straight into the hash
    elif ch in KEY_ENTER:  # you press OK to finish
        ...                # entropy gates check `count`
        break
seed = md.digest()         # SHA-256 of your rolls, and nothing else

Full function: add_dice_rolls in shared/seed.py.

You can reproduce it off the device: printf '<your rolls>' | sha256sum gives the same 32 bytes the Coldcard derives. After an RNG failure that's the reassuring part, the dice-only seed leans on your dice and one hash, not on any hardware you have to trust.

So your entropy is exactly the rolls the device counts, about 2.585 bits each (log2(6)), which is why 256-bit security takes about a hundred rolls. The device enforces that floor with the two gates at the tail of the loop above: a minimum roll count, and a distribution check that no single digit runs away with it. Both read one variable, count.

# shared/seed.py
if count < threshold and judge_them:                              # min-roll gate
    ...
bad_dist = any((v / count) > 0.30 for _, v in counter.items())   # distribution gate

Here's the break. count counts key events, not distinct physical rolls, and the dice screen leaves auto-repeat on. The digits 1 to 6 aren't in need_release, the set of keys that must be let go before they fire again:

# shared/ux_q1.py
rep_delay = 20 if self.num_repeats else 200
...
if self.last_key and numpad.key_pressed == self.last_key:
    self.num_repeats += 1
    return self.last_key

PressRelease in shared/ux_q1.py.

Hold a digit past 200 ms and the firmware re-fires it several times a second, each one a fresh count += 1 on a value you already chose. A held 4 reads as dozens of rolls and adds almost no entropy. So count sails past 99 while your real entropy is a handful of bits, and both gates wave it through.

4 key presses
what you rolled
auto-repeat
phantom rolls, several/sec
counter 99: accepted as 256-bit
real entropy: ~10 bits

The floor is four physical key presses. The distribution gate rejects any single value above 30%, so three digits at about 33% each would trip it; four distinct digits at roughly 25% pass. Four keys, held a moment each, are counted as more than ninety rolls, clear both gates, and are accepted as 256-bit entropy while carrying about ten bits. We confirmed this on the Q; the Mk4 and Mk5 share the same counter logic, and the same fix shipped in 5.6.1. Even an honest user is a hair short: ninety-nine rolls is 255.91 bits, just under the mark, so roll the hundredth.

Precondition: the user holds a digit key while rolling. It's a footgun, not a remote attack, but it silently bypasses the gate that's supposed to enforce 256 bits, which is why we rate it High.

Bug 2: One Key Enrolled as Many Cosigners

Severity High · Code multisig.py · Fix PR #725

When you enroll a multisig wallet, the device has to confirm the cosigners are distinct. It deduplicates them on the 32-bit master fingerprint, and never compares the extended public keys to each other:

# shared/multisig.py
self.xfp_paths = {}
for xfp, deriv, xpub in self.xpubs:
    self.xfp_paths[xfp] = str_to_keypath(xfp, deriv)
 
assert len(self.xfp_paths) == self.N, 'dup XFP'

The fingerprints are labels in the config file, and the attacker who hands you that file writes them. And the fingerprint is what people rely on to tell cosigners apart, the short ID every wallet shows, so the one check meant to catch a repeated key is defeated by a value the attacker controls. The same xpub under two invented fingerprints reads as two cosigners.

config says "2 of 3"
Key A · xfp 1111
Key A · xfp 2222
your key
dedup by fingerprint
passes: three distinct xfp
script holds Key A twice
one key signs both legs

Derive that "2 of 3" and the redeem script holds one attacker key twice. A single key produces both required signatures. We confirmed it end to end: rebuilt the script host-side, matched the address the device displayed, and had one key sign a real BIP-143 spend that the policy accepted, with a lone signature and a foreign key both failing as controls.

The ceiling is worse than a toy example. By default one attacker key can satisfy fourteen of fifteen legs, with a single leg forced to be yours. Turn on the wallet's "Skip Checks" and it's fifteen of fifteen with none of your key in the script at all, while the screen reads "All 15 co-signers must approve spends." The device shows a policy the script doesn't enforce.

Precondition: the victim imports a multisig config the attacker supplied, over USB, SD card, or a scanned QR. No prior compromise, no secrets.

Bug 3: Downloads Serve Any Address in Scratch Memory

Severity Medium · Code usb.py · Fix PR #724

The device keeps a 4 MB PSRAM scratch area. The USB dwld command computes the address it serves straight from the request, with no reference to what the current session actually staged:

# shared/usb.py  handle_download(offset, length, file_number)
pos = (MAX_TXN_LEN * file_number) + offset

There's no check that this session wrote there, and no assert self.encrypted_req. Other sensitive commands guard on self.encrypted_req; this one doesn't. So an unauthenticated session can walk the whole window and read whatever an earlier operation left behind. The lower half of that PSRAM is never wiped on a warm reset, so the residue is real: we read the full 4 MB back over 2,048 plaintext requests on a link that never negotiated encryption, and the data survived switching the device to an unrelated temporary seed.

What it exposes: whatever a previous operation staged there, read back across sessions on a link that never negotiated encryption. In ordinary signing, enrollment, and backup use that's transaction and wallet data (a staged PSBT, a multisig enrollment file, a signed result), not the seed: we installed a known seed, swept the window, and every key-material needle came back empty, since a normal backup stages 7z-encrypted. The exception is one opt-in flow. A cleartext USB restore (ckcc restore backup.txt) stages the plaintext at offset 0, and there we recovered the full mnemonic, xprv, and raw secret over an unencrypted link. It stays Medium because reaching the seed needs that cleartext restore, which the user opts into past an explicit warning, not ordinary use.

Precondition: USB access to the device.

Bug 4: The QR Reader Hands Back Uninitialized Memory

Severity Medium · Code bbqr.py · Fix PR #726

The Q reassembles data from animated QR codes, and on the Q that reassembly runs in PSRAM, which this path does not zero-fill. It sizes the decoded result from the geometry the QR declares, writes only the bytes that actually arrived, then hands back the whole declared span:

# shared/bbqr.py  BBQrPsramStorage (Q)
self.final_size = (blksize * (hdr.num_parts-1)) + self.runt_size  # from declared geometry
...
self.buf = True                            # PSRAM-backed, never zero-filled
...
def write_pkt(self, offset, data):         # writes only the bytes that arrived
    PSRAM.write_at(offset, ln4)[:] = data[0:ln4]
...
def get_buffer(self):
    return PSRAM.read_at(0, self.final_size)   # hands back final_size bytes, written or not

final_size comes from what the QR claims, write_pkt writes only what arrived, and the PSRAM behind it is never cleared. Declare a large result, send a small one, and the tail is whatever sat in that PSRAM before. In the chain we ran, 43 attacker-supplied bytes declared a 239-byte result, and the device returned 196 bytes of the victim's earlier scan verbatim, classified them as text, and offered to sign them. The device does show that text for approval before signing, but it reads as an ordinary message, so the review step is a weak guard rather than a real barrier. Your signed message can carry your own device's leaked memory, re-emitted as a QR the recipient holds up to a camera. It doesn't reach a stored seed in normal use, and we want to be exact about that: this is a memory-disclosure primitive, not key extraction.

Precondition: the victim scans an attacker-supplied QR, then signs, then displays the result.

How We Tested It

We didn't reason about these from the source and hope the device matched. Each one was executed against the real firmware in Coldcard's own simulator: the multisig degeneracy through the actual enrl enrollment command, the download leak over 2,048 real requests, the dice floor on the real key-handling path, and the QR leak through the full reassembly-and-re-emit chain.

The fair objection to simulator work is whether it matches the hardware, so we closed that gap. We pulled the compiled dice function out of the shipped 1.5.0Q image, and it's byte-for-byte identical frozen MicroPython bytecode to the source the simulator runs: 505 bytes, zero differing. Then we rebuilt the whole firmware from source and diffed it against what ships; the only bytes that differed sat inside the 128-byte signature header. The code we tested is the code that runs on the device.

Conclusion

Four bugs, all fixed, and the seed derivation we came to check turned out sound. The interesting part isn't that a wallet had them, it's that reading the firmware that actually ships surfaces real issues, and that the fixes came fast once they were reported the right way.

Two honest limits. We read one device and a narrow slice of it, so plenty went unread. And the approach leaned on the firmware being open and reproducible, which let us check the exact bytes on our own unit; on a closed image, half of what made this convincing isn't available, and where that line sits is still an open question for us.

We do firmware and embedded security review: reading what actually ships, and proving the finding. If your product needs that kind of look, get in touch.

References

Share:XLinkedIn

Written by Piotr Duszyński

Let's Talk About Your Project