Coldcard/firmware@ ae88593552

zkao.io · 2026-08-03 · by Long Nguyen ·generic
Security scan with 18 confirmed findings.

Coldcard/firmware@ ae88593552

Table of Contents

1. #1CriticalMalicious firmware can reuse protected bootloader code to bypass the callgate and expose firewall secrets 2. #2HighUnauthenticated Coldcard cloning lets an attacker controlling MicroSD contents recover the source seed or replace the target wallet 3. #3HighForged PSBT ownership metadata lets HSM self-transfer rules authorize full thefts 4. #4HighAttacker-controlled multisig outputs bypass review and HSM policy as false change 5. #5HighA duplicated cosigner key can reduce the effective multisig threshold and enable theft 6. #6HighPredictable firmware randomness can expose wallet seeds and ephemeral private keys 7. #7HighAn active USB proxy can impersonate a Coldcard and substitute its wallet xpub despite the anti-MITM check 8. #8HighA failed initial PIN write leaves the wallet operating without authentication 9. #9HighFailed CCC authorization lets an attacker obtain a signature that the single-signer spending policy denied 10. #10HighPersisting an HSM policy can turn zero-value spending limits into unrestricted signing 11. #11HighA physical attacker can replace an approved firmware image with attacker-signed code and disclose wallet secrets 12. #12HighCaptured first HOTP code can repeatedly authorize HSM transaction signing 13. #13HighSignatures can authorize immediate PSBTv2 spends despite required locktimes 14. #14HighPower interruption during backup restore leaves the restored wallet able to sign without its spending policy 15. #15HighA physical interposer can replay one-time HSM credentials after interrupting power before counter persistence 16. #16HighPhysical side-channel measurements can recover AES-256 keys 17. #17HighPower loss can remove the spending policy and restore unrestricted signing 18. #18HighInterrupted first-boot provisioning can expose the persisted secure-element pairing secret

Findings

18 totalCopy all as Markdown

Confirmed findings and inconclusive results still under review are shown here. Findings the project team marked as false positives or duplicates are not listed.

| # | Severity | Finding | Location | | -- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | 1 | Critical | Malicious firmware can reuse protected bootloader code to bypass the callgate and expose firewall secrets | https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/stm32/mk4-bootloader | | 2 | High | Unauthenticated Coldcard cloning lets an attacker controlling MicroSD contents recover the source seed or replace the target wallet | shared/backups.py#L750-L879 | | 3 | High | Forged PSBT ownership metadata lets HSM self-transfer rules authorize full thefts | https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/shared | | 4 | High | Attacker-controlled multisig outputs bypass review and HSM policy as false change | https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/shared | | 5 | High | A duplicated cosigner key can reduce the effective multisig threshold and enable theft | https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/shared | | 6 | High | Predictable firmware randomness can expose wallet seeds and ephemeral private keys | https://github.com/Coldcard/firmware/tree/master | | 7 | High | An active USB proxy can impersonate a Coldcard and substitute its wallet xpub despite the anti-MITM check | https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af | | 8 | High | A failed initial PIN write leaves the wallet operating without authentication | https://github.com/Coldcard/firmware/tree/ae88593 | | 9 | High | Failed CCC authorization lets an attacker obtain a signature that the single-signer spending policy denied | shared/auth.py#L410-L416 | | 10 | High | Persisting an HSM policy can turn zero-value spending limits into unrestricted signing | shared/hsm.py#L238-L251 | | 11 | High | A physical attacker can replace an approved firmware image with attacker-signed code and disclose wallet secrets | https://github.com/Coldcard/firmware | | 12 | High | Captured first HOTP code can repeatedly authorize HSM transaction signing | shared/users.py#L219-L248 | | 13 | High | Signatures can authorize immediate PSBTv2 spends despite required locktimes | shared/psbt.py#L1119-L1121 | | 14 | High | Power interruption during backup restore leaves the restored wallet able to sign without its spending policy | shared/backups.py#L162-L269 | | 15 | High | A physical interposer can replay one-time HSM credentials after interrupting power before counter persistence | shared/users.py#L94-L252 | | 16 | High | Physical side-channel measurements can recover AES-256 keys | external/c-modules/aes256ctr/aes\_256\_ctr.s#L9-L424 | | 17 | High | Power loss can remove the spending policy and restore unrestricted signing | shared/nvstore.py#L490-L504 | | 18 | High | Interrupted first-boot provisioning can expose the persisted secure-element pairing secret | stm32/mk4-bootloader/storage.c#L463-L503 |

Malicious firmware can reuse protected bootloader code to bypass the callgate and expose firewall secrets

CriticalZK-17rykkr6

#1<https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/stm32/mk4-bootloader>

Copy markdown

The callgate preserves and later trusts the firmware-controlled incoming stack pointer and link register. At entry, `callgate_entry0` saves the caller's `sp` and `lr`, switches to the bootloader stack, and calls the dispatcher:

// stm32/mk4-bootloader/startup.S:140-150
mov         r10, sp
mov         sp, r9
push        {r10, lr}
bl          firewall_dispatch
pop         {r10, lr}
mov         sp, r10

The dispatcher sets the firewall pre-arm bit before returning (`stm32/mk4-bootloader/dispatch.c:700-703`). The assembly then wipes bootloader SRAM and performs an unchecked indirect branch through the saved caller LR:

// stm32/mk4-bootloader/startup.S:152-163
wipe_loop2:
    str         r0, [r9], +4
    cmp         r9, r10
    bne         wipe_loop2

bx lr

Hostile firmware is not required to enter with a linking branch. It can set `lr` to any odd address inside the protected code segment, set `sp` to attacker-controlled firmware SRAM, and use `bx` to enter the always-open callgate at `callgate_entry0`. Because the chosen return remains inside the protected segment, pre-arm does not close the firewall. ST's RM0432 Rev. 9, Firewall control register (`FW_CR`), `FPA` bit specifies that `FPA=1` closes the firewall when code executes outside the protected segment; it does not close it for an intra-segment branch. The bundled HAL documents the same rule (`stm32/mk4-bootloader/stm32l4xx_hal_firewall.c:228-243`). The linker places `dispatch.o`, `main.o`, selected HAL objects, all remaining `.text*`, and all rodata after the firewall boundary (`stm32/mk4-bootloader/link-script.ld:37-49`), providing a large protected ROP/code-reuse surface.

After `mov sp, r10`, the attacker-selected protected target executes with an attacker-controlled stack and attacker-controlled callee-saved registers (`r4-r8` and `r11` survive the dispatcher ABI). A targeted Capstone disassembly of the repository's Mk4 `releases/3.3.0/bootloader.bin` confirmed that the callgate pointer is `0x08000305`, the unchecked return is at `0x08000342`, and the protected image contains numerous stack-loading return gadgets, including `pop {r0, r3, r5, r7, pc}` at `0x080024d8` and `pop {r2, r3, r4, r6, r7, pc}` at `0x080023e2`, as well as the byte-copy routine at `0x0800d688`. These confirmed primitives provide a protected ROP/code-reuse path capable of loading copy arguments and moving protected NVROM bytes to firmware-readable SRAM. The evidence demonstrates the control-flow bypass and required gadgets; it does not include a complete hardware extraction chain. The bypass avoids the intended callgate method switch and its pointer, PIN-state, and output-range validation.

#### Impact

Replaceable malicious firmware can set its stack to a crafted ROP chain, set `LR` to a protected Thumb gadget/function, and enter the callgate using `bx` rather than `blx`. When the legitimate dispatch operation completes, `callgate_entry0` restores the malicious stack and branches to the protected address without closing the firewall. The chain can invoke protected routines directly and copy non-volatile protected data, including the pairing secret or MCU key material, into firmware-readable SRAM before finally branching outside the segment. This defeats the firewall's core isolation property and can expose secrets used to authenticate secure-element operations and protect wallet state.

#### Recommendation

Do not return through firmware-supplied `LR` or restore firmware-supplied `SP` while protected execution is still open. Validate that both values describe the exact expected caller context and force the final transition through a fixed, minimal exit trampoline that cannot branch within the protected segment. The exit path should close the firewall atomically before any attacker-controlled control-flow state is consumed.

Unauthenticated Coldcard cloning lets an attacker controlling MicroSD contents recover the source seed or replace the target wallet

HighZK-anq29y5t

#2shared/backups.py#L750-L879

Copy markdown

The Coldcard-to-Coldcard clone protocol performs ephemeral ECDH using public keys carried entirely by an untrusted MicroSD card, but neither device authenticates those keys or asks the user to compare a transcript code on the trusted displays.

The target writes its unauthenticated ephemeral public key to a fixed file:

# shared/backups.py:759-768
pair = ngu.secp256k1.keypair()
my_pubkey = pair.pubkey().to_bytes(False)
...
with card.open(fname, 'wb') as fd:
    fd.write(ujson.dumps(dict(pubkey=b2a_hex(my_pubkey))))

The source accepts whatever compressed secp256k1 public key is currently in that file. The only checks are its encoded length and prefix. It then derives the archive password from that unauthenticated key and exports the main wallet backup:

# shared/backups.py:845-877
with open(path + '/ccbk-start.json', 'rb') as fd:
    d = ujson.load(fd)
    his_pubkey = a2b_hex(d.get('pubkey'))
    assert len(his_pubkey) == 33
    assert 2 <= his_pubkey[0] <= 3
...
pair = ngu.secp256k1.keypair()
my_pubkey = pair.pubkey().to_bytes(False)
session_key = pair.ecdh_multiply(his_pubkey)
fname = b2a_hex(my_pubkey).decode() + '-ccbk.7z'
await write_complete_backup(b2a_hex(session_key).decode(), fname,
                            allow_copies=False, bypass_tmp=True)

`bypass_tmp=True` causes `render_backup_contents()` to export the protected main wallet. The plaintext contains the mnemonic or BIP32 master secret, XPRV, raw encoded secret, and long secret:

# shared/backups.py:49-65
with stash.SensitiveValues(bypass_tmp=bypass_tmp, enforce_delta=True) as sv:
    if sv.mode == 'words':
        ADD('mnemonic', bip39.b2a_words(sv.raw))
    elif sv.mode == 'master':
        ADD('bip32_master_key', b2a_hex(sv.raw))
    ADD('xprv', chain.serialize_private(sv.node))
    ADD('raw_secret', b2a_hex(sv.secret).rstrip(b'0'))
    if version.has_608:
        ADD('long_secret', b2a_hex(pa.ls_fetch()))

An attacker who replaces `ccbk-start.json` with their own public key knows the corresponding private key. The source public key is published in the output filename, so the attacker computes the identical ECDH result and decrypts the complete backup. A targeted experiment with the repository's `ngu.secp256k1` binding confirmed that `source.ecdh_multiply(attacker_pubkey) == attacker.ecdh_multiply(source_pubkey)`.

The reverse direction is also unauthenticated. The target trusts the source public key parsed from the output filename, derives the archive password, and disables the normal seed-fingerprint confirmation:

# shared/backups.py:794-834
if fn.endswith('-ccbk.7z'):
    incoming = path + '/' + fn
    his_pubkey = a2b_hex(fn[0:66])
...
session_key = pair.ecdh_multiply(his_pubkey)
words = [b2a_hex(session_key).decode()]
prob = await restore_complete_doit(incoming, words, file_cleanup=delme,
                                   ux_confirm=False)

Anyone who knows the target public key from `ccbk-start.json` can generate their own ephemeral keypair, create a valid encrypted clone archive containing an attacker-controlled seed, name it with their ephemeral public key, and replace the legitimate response. The target imports that seed without showing its master fingerprint for approval.

The two substitutions can also be combined into a transparent relay. The attacker substitutes key A before the card reaches the source, derives the source archive password from A and the source public key published in the filename, and decrypts the complete seed-bearing backup. The attacker then re-encrypts the unchanged plaintext under a second key B and the genuine target public key captured from the original `ccbk-start.json`, naming the replacement archive with B. The target derives the same replacement password and completes migration normally, so theft of the source seed need not cause a visible clone failure.

This is the public-key-substitution failure that authenticated key establishment is intended to prevent. NIST SP 800-56A Rev. 3, section 5.6.3, requires public keys to be protected from unauthorized modification/substitution and correctly associated with the intended entity: <https://doi.org/10.6028/NIST.SP.800-56Ar3>

#### Impact

A physical attacker or party that can modify and later recover or exfiltrate the MicroSD contents between clone steps can compromise either wallet without breaking ECDH or guessing a PIN.

To recover the source wallet, the attacker replaces `ccbk-start.json` with an attacker public key before the card reaches the source. The owner approves the normal "Clone Coldcard" workflow. The source encrypts a full main-seed backup to the attacker-controlled ECDH key and writes its ephemeral public key in the filename. The attacker copies the archive, derives the same password, and recovers the mnemonic/XPRV/raw secret, gaining complete control of the wallet's funds. The attacker may then transparently decrypt and re-encrypt the same backup for the genuine target using a second substituted source key, allowing the migration to succeed while retaining the stolen seed.

To replace the target wallet, the attacker reads the target public key from `ccbk-start.json`, constructs a clone archive containing a seed they control, encrypts it using a fresh attacker ephemeral key, and substitutes it for the source response. Because the target calls restore with `ux_confirm=False`, it installs the attacker seed without the normal fingerprint approval. If the owner subsequently receives funds to the apparently cloned device, the attacker can spend them.

The attack requires the owner to intentionally start cloning, but removable media is untrusted under the audit threat model and the trusted displays provide no key or transcript comparison. The result is direct protected-seed disclosure or attacker-controlled wallet substitution.

#### Recommendation

Authenticate both ephemeral public keys before exporting or restoring any secret. Since two Coldcards do not have a pre-established identity relationship, use the trusted displays as an authenticated out-of-band channel.

The target should display a high-entropy, domain-separated code derived from its ephemeral public key. Before exporting, the source should display the same target-key code and require the owner to confirm that it matches the target. After generating the source key, the source should display a second code derived from the complete ordered transcript `(target_public_key, source_public_key)`. Before restoring, the target should derive and display that transcript code and require confirmation that it matches the source display. Use at least 64 bits represented as words or grouped hexadecimal, and retain the normal restored-seed fingerprint confirmation as defense in depth.

Do not rely on filenames, CRCs, 7z password verification, or successful ECDH as peer authentication; an active attacker can generate all of them consistently.

Forged PSBT ownership metadata lets HSM self-transfer rules authorize full thefts

HighZK-nsnl9tdk

#3<https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/shared>

Copy markdown

HSM self-transfer authorization treats unverified PSBT output derivation metadata as proof that an output belongs to the device. A malicious client can therefore label an attacker-controlled script output as an "own" output, satisfy a 100% self-transfer rule, and obtain a valid signature spending the device's singlesig funds to the attacker.

`parse_subpaths()` increments `num_our_keys` whenever an untrusted output derivation merely starts with the device fingerprint. It does not derive the claimed public key or prove that the key occurs in the destination script:

# shared/psbt.py:275-324
def parse_subpaths(self, my_xfp, parent):
    ...
    for pk in self.subpaths:
        ...
        here = list(unpack_from('<%dI' % (vl//4), v))
        ...
        self.subpaths[pk] = here

if here[0] == my_xfp: num_ours += 1 elif pk in parent.wif_store: num_ours += 1 ... self.num_our_keys = num_ours

Output validation has an early-return path that preserves this attacker-inflated count. When a P2SH/P2WSH output has multiple derivation entries but no global metadata selected an enrolled multisig wallet, validation explicitly leaves it as a normal, non-change output and returns before checking the derivations against private keys, checking that the alleged device key occurs in the redeem/witness script, or even validating that script through an enrolled wallet:

# shared/psbt.py:470-500
if af in [AF_P2SH, AF_P2WSH]:
    redeem_script = self.get(self.redeem_script) if self.redeem_script else None
    witness_script = self.get(self.witness_script) if self.witness_script else None
    ...
    else:
        if not redeem_script and not witness_script:
            raise FatalPSBTIssue(...)

if not active_multisig: self.is_change = False return af

A second early-return construction affects P2TR. After `parse_subpaths()` has accepted a forged legacy compressed-key derivation carrying the device fingerprint, `validate()` returns for `AF_P2TR` without proving any relationship between that key and the Taproot `scriptPubKey` (`shared/psbt.py:417-432`). The output remains non-change while retaining `num_our_keys > 0`. BIP 371's Taproot-specific x-only derivation and internal-key/tree fields are not validated on this path, so ordinary legacy output metadata cannot establish ownership of the attacker-controlled P2TR destination.

The missing checks are normally performed only for a selected enrolled multisig wallet by `active_multisig.validate_script(...)`, which reconstructs and validates the script from the wallet and supplied derivations. The HSM path nevertheless consumes the unverified `num_our_keys` value as authoritative ownership evidence:

# shared/hsm.py:375-394
if self.min_pct_self_transfer:
    own_in_value = sum([i.amount for i in psbt.inputs if i.num_our_keys])
    own_out_value = 0
    for idx, txo in psbt.output_iter():
        o = psbt.outputs[idx]
        if o.num_our_keys:
            own_out_value += txo.nValue
    percentage = (float(own_out_value) / own_in_value) * 100.0
    assert percentage >= self.min_pct_self_transfer, ...

if "EQ_NUM_OWN_INS_OUTS" in self.patterns: own_ins = sum([1 for i in psbt.inputs if i.num_our_keys]) own_outs = sum([1 for o in psbt.outputs if o.num_our_keys]) assert own_ins == own_outs, ...

This is reachable through the normal authorization order: `shared/auth.py:368-386` validates and classifies outputs, then `shared/auth.py:524-526` passes the PSBT to `hsm_active.approve_transaction()`. The final private derivation check in `shared/psbt.py:2137-2168` cannot catch this forgery because it checks only outputs whose `is_change` flag is true, while the vulnerable branch explicitly sets `is_change = False`.

This issue is distinct from the cross-wallet multisig-change finding. That issue uses valid metadata for an enrolled wallet to corrupt `active_multisig` and `is_change`; this issue requires no enrolled destination wallet or global XPUBs, corrupts `num_our_keys`, and can count an attacker script that does not contain any device key as device-owned.

#### Impact

A malicious HSM client can bypass a rule intended to permit only transactions that retain a configured percentage of value under device ownership. One construction uses the inactive-multisig P2SH/P2WSH branch:

1. The attacker submits a PSBT spending a valid singlesig UTXO controlled by the device. 2. The transaction sends the funds to an attacker-controlled P2WSH or P2SH script. 3. The output map contains at least two BIP32 derivation records so it follows the multisig-output branch. One arbitrary public key is labeled with the device fingerprint and an attacker-chosen path, even though that public key need not derive from the device or occur in the destination script. 4. No global XPUBs select an enrolled multisig wallet. Output validation leaves the output non-change but retains `num_our_keys > 0` without validating ownership. 5. An HSM rule requiring up to 100% self-transfer counts the entire attacker output as returning to the device. `EQ_NUM_OWN_INS_OUTS` can likewise count one forged own output for one own input. 6. HSM approves the rule without local transaction confirmation. The normal signing path validates and signs the genuine singlesig input, but skips private-key verification for the forged output because it is not marked change. 7. After broadcast, the attacker spends the destination using the actual attacker-controlled script.

Alternatively, the attacker can use an unrelated P2TR destination and attach a syntactically valid compressed-key output derivation falsely labeled with the device fingerprint. The P2TR early return preserves the forged ownership count without script validation, allowing the same self-transfer percentage and own-input/output-count bypass without an enrolled destination wallet.

The attacker can obtain an unattended, valid signature transferring the full input value outside device control despite a policy specifically configured to require ownership retention. This is a high-severity HSM authorization bypass under the audit threat model.

#### Recommendation

Never use raw PSBT fingerprints or derivation-record counts as proof of output ownership. HSM self-transfer and own-input/output-count rules must consume a separate, fail-closed ownership result established only after private derivation and destination-script membership have both been verified.

For script outputs, require the destination to match a fully validated enrolled wallet and prove that at least one public key derived from the device is actually committed by the redeem/witness script. P2TR ownership must likewise be established from validated Taproot-specific metadata and a reconstructed matching script. Every unsupported or non-change early return must clear or leave unset the validated-ownership result. Outputs without complete validation must count as foreign for all HSM policy purposes, regardless of their BIP32 derivation metadata.

Attacker-controlled multisig outputs bypass review and HSM policy as false change

HighZK-2a94ickv

#4<https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/shared>

Copy markdown

A PSBT can spend the victim's exclusively controlled singlesig coins into a separately registered multisig wallet that malicious cosigners can spend, while COLDCARD treats the destination as change. Interactive signing displays the transaction as a consolidation "within wallet," and HSM signing excludes the destination from wallet, amount, period, whitelist, and attestation enforcement.

The wallet identity used for output change validation is selected from attacker-controlled global XPUB records before input ownership is considered. `handle_xpubs()` assigns `self.active_multisig` solely by matching the supplied global XPUB fingerprint/path set to an enrolled wallet:

# shared/psbt.py:1340-1363
async def handle_xpubs(self):
    # Lookup correct wallet based on xpubs in globals
    ...
    candidates = MultisigWallet.find_candidates(xfp_paths)

if len(candidates) == 1: self.active_multisig = candidates[0]

Validation invokes this global-metadata selection before it analyzes the actual UTXOs and signing scripts. The authorization flow then analyzes inputs and outputs in this order:

# shared/auth.py:368-386
await self.psbt.validate()
...
self.psbt.consider_inputs(cosign_xfp=ccc_c_xfp)
...
self.psbt.consider_outputs()
self.psbt.consider_dangerous_sighash()

A singlesig input is validated only against its own UTXO script and BIP32 key path. It does not clear or bind `active_multisig` to the input's source wallet. In contrast, every multisig-looking output is checked against the globally selected wallet and marked as change when its script and derivations match that registered wallet:

# shared/psbt.py:495-526, 535-539
if not active_multisig:
    self.is_change = False
    return af
...
active_multisig.validate_script(witness_script or redeem_script,
                                subpaths=self.subpaths)
...
self.is_change = True
return af

The final private-key check only proves that the device owns one output pubkey; it does not prove that the destination preserves the input wallet's control policy:

# shared/psbt.py:2142-2168
for pubkey, subpath in oup.subpaths.items():
    if subpath[0] == my_xfp:
        res = self.check_pubkey_at_path(sv, subpath, pubkey)
        if res:
            good += 1
...
if not good:
    raise FraudulentChangeOutput(...)

HSM authorization consumes the same unproven wallet identity and change flag. A wallet-scoped rule matches `active_multisig.name`, while amount and destination restrictions exclude outputs marked as change:

# shared/hsm.py:310-334,932-949
if self.wallet:
    if psbt.active_multisig:
        assert self.wallet == psbt.active_multisig.name, 'wrong wallet'
    else:
        assert self.wallet == '1', 'not multisig'
...
if o.is_change or (txo.nValue == 0 and allow_zeroval):
    continue
...
total_out = 0
for idx, txo in psbt.output_iter():
    outp = psbt.outputs[idx]
    if not outp.is_change:
        total_out += txo.nValue

Therefore valid metadata for an unrelated enrolled multisig wallet acts as an untrusted provenance assertion across both interactive review and HSM policy. The authorization and change-classification path is shared by PSBT v0 and v2. No duplicate, malformed, or non-canonical PSBT key is required.

#### Impact

A malicious coordinator and malicious multisig cosigners can steal singlesig funds through a misleading approval flow:

1. The victim has a registered 1-of-2 multisig wallet containing the COLDCARD key and an attacker key. The same attack works for any MMM\-of-NNN wallet where the attackers control MMM keys. 2. The attacker constructs a PSBT v0 or v2 spending a victim singlesig UTXO that COLDCARD can sign. 3. The only transaction output pays the registered multisig script, with correct output derivations, witness/redeem script, and global XPUB records for that multisig wallet. 4. COLDCARD validates the singlesig input, independently selects the multisig wallet from global metadata, marks the destination as change, and displays `Consolidating ... within wallet` instead of presenting the amount as a payment. 5. After approval, COLDCARD signs the singlesig input. Once broadcast, the attacker's threshold of multisig keys can spend the received coins without the device.

In HSM mode, the same transaction can be approved without local review. The attacker can configure the PSBT so a rule intended to allow only the named multisig wallet matches, while a one-satoshi maximum, period limit, or unrelated destination whitelist sees zero foreign value and no destination to check. HSM signs the singlesig input, and the attacker later spends the 1-of-2 output using only the attacker key.

This crosses a wallet-control boundary while suppressing the payment amount from interactive review or excluding it from automated policy accounting. It defeats trusted-display, change-provenance, multisig-identity, and HSM authorization guarantees and yields an attacker-accepted spend of victim funds, meeting the overview's high-severity criteria.

#### Recommendation

Derive change-wallet identity from the validated signable inputs, never from global XPUB metadata alone. Treat global XPUBs only as a proposed wallet identity until an input's actual UTXO script, redeem/witness script, and derivation set prove that the input belongs to that wallet.

A multisig output should be eligible for hidden/change classification only when every device-signable input being funded under the transaction is validated as belonging to the same enrolled multisig wallet and control policy. If any device-signable input is singlesig, from a different multisig wallet, or has unresolved provenance, render the multisig output as an external payment, include its value in the displayed send amount, and apply every HSM amount, period, whitelist, and attestation check.

A duplicated cosigner key can reduce the effective multisig threshold and enable theft

HighZK-y5nit9mq

#5<https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/shared>

Copy markdown

Structural property: Every signature slot in an enrolled `M`\-of-`N` wallet must correspond to a distinct derived public-key stream; user-supplied fingerprints and BIP32 metadata are labels, not proof that two streams are distinct.

Untrusted descriptors are parsed into independent `(xfp, derivation, xpub)` tuples without comparing their cryptographic key streams:

# /repo/shared/descriptor.py:336-353
M, keys = int(splitted[0]), splitted[1:]
N = int(len(keys))
if M > N:
    raise ValueError("M must be <= N: got M=%d and N=%d" % (M, N))

res_keys = [] for key in keys: koi, key = cls.parse_key_orig_info(key) ... xpub = cls.parse_key_derivation_info(key) xfp = str2xfp(koi[:8]) origin_deriv = "m" + koi[8:] res_keys.append((xfp, origin_deriv, xpub))

`MultisigWallet.from_descriptor()` then calls `check_xpub()` for each tuple. `check_xpub()` validates each xpub in isolation and appends its normalized serialization, but it never compares the public key and chain code against previously accepted entries (`/repo/shared/multisig.py:709-721,795-869`). The wallet constructor only rejects duplicate claimed fingerprints:

# /repo/shared/multisig.py:140-149
self.xpubs = xpubs
...
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'

Therefore, the attacker-constructible accepted class is not limited to one incidental descriptor string. Any entries with distinct claimed XFPs that deserialize to the same BIP32 derivation state (the same public key and chain code) are duplicate signature slots. An exact xpub can be repeated under different origin XFPs when its depth is zero or greater than one because `check_xpub()` does not authenticate those XFPs. At depth one, an attacker can instead change the unauthenticated parent-fingerprint metadata and Base58Check checksum while retaining the public key and chain code, then use the corresponding claimed XFP. The declared derivation only has to have the xpub's depth. The existing checks additionally require `N` distinct claimed XFPs and exactly one tuple labeled with and deriving to the device's own key (`/repo/shared/multisig.py:775-791,822-863`); neither requirement establishes cross-entry key-stream uniqueness.

For example, a hostile coordinator can supply a `2-of-3` descriptor containing the victim device's xpub once and the attacker's same xpub twice under two distinct claimed XFPs. After user approval, `confirm_import()` describes this as "2 signatures, from 3 possible co-signers" and commits it (`/repo/shared/multisig.py:1070-1141`), even though there are only two distinct key owners.

Address generation consumes all stored entries independently. `yield_addresses()` deserializes every xpub and `make_redeem_script()` derives and pushes every resulting public key without a uniqueness check (`/repo/shared/multisig.py:488-515,88-109`). The resulting script is `2 <A_i> <A_i> <V_i> 3 CHECKMULTISIG` (possibly BIP67-sorted). Bitcoin `CHECKMULTISIG` counts signature positions rather than distinct signature byte strings, so two copies of the same valid attacker signature satisfy the two duplicate `A_i` positions. More generally, any colluding set of key owners whose accepted multiplicities total at least `M` can spend without the remaining nominal cosigners.

#### Impact

A malicious coordinator or prospective cosigner can import an apparently `M`\-of-`N` wallet whose `N` displayed cosigner labels contain fewer than `N` distinct key streams. The victim can approve and persist that wallet, derive or verify its receive addresses on the trusted device, and fund those addresses believing that `M` distinct parties must authorize a spend.

If attacker-controlled streams occupy at least `M` slots, the attacker signs once per distinct controlled key and duplicates each signature for that key's repeated slots in the witness or scriptSig. The transaction is valid without the victim's signature, allowing theft of the full balance. Even when attacker multiplicity is below `M`, the descriptor reduces the number of distinct approvals below the policy shown to the user.

Normal Coldcard PSBT signing does not remove this impact. PSBT BIP32 derivations are maps keyed by public key (`/repo/shared/psbt.py:274-313,969-979`), and `validate_script()` also enforces strict BIP67 ordering (`/repo/shared/multisig.py:619-621`), so a later signing PSBT containing duplicate derived pubkeys is generally rejected. The attacker does not need the Coldcard to sign: after funds are deposited to the device-derived script, the attacker can construct and broadcast the valid spend externally.

#### Recommendation

Before enrollment approval or persistence, reject any pair of cosigner entries that represents the same BIP32 public derivation stream. Do not use claimed XFPs, origin paths, depth, parent fingerprints, child numbers, or SLIP-132 versions as participant identity because these values are attacker-supplied or non-cryptographic metadata.

The same invariant should be enforced for descriptor imports, legacy multisig files, PSBT-created wallets, deserialization of persisted wallets, and any future enrollment path. The enrollment UX should only describe `N` possible cosigners after this invariant has passed.

Predictable firmware randomness can expose wallet seeds and ephemeral private keys

HighZK-jkrpi60p

#6<https://github.com/Coldcard/firmware/tree/master>

Copy markdown

On every checked-in STM32 board configuration, `ngu.random` calls MicroPython's fallback software PRNG instead of the board's fail-closed hardware RNG. `external/libngu/ngu/random.c:22-30` binds its nominal chip entropy source to the global `rng_get()` and incorrectly tests only whether `MICROPY_HW_ENABLE_RNG` is defined:

#ifdef MICROPY_PY_STM
// ports/stm32/rng.c
extern uint32_t rng_get(void);

define CHIP_TRNG_SETUP()

define CHIP_TRNG_32() rng_get()

ifndef MICROPY_HW_ENABLE_RNG

error "get a HW TRNG plz"

endif

#endif

`stm32/COLDCARD/mpconfigboard.h:74-77`, `stm32/COLDCARD_MK4/mpconfigboard.h:75-78`, and `stm32/COLDCARD_Q1/mpconfigboard.h:77-80` all define `MICROPY_HW_ENABLE_RNG` as zero. As a result, the referenced symbol is the deterministic fallback at `external/micropython/ports/stm32/rng.c:64-98`:

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; } // deterministic state updates }

uint32_t rng_get(void) { return pyb_rng_yasmarang(); }

The board implementations, such as `stm32/COLDCARD_MK4/rng.c:40-79,86-148`, instead expose `random_buffer()` and `random32()` around a private fail-closed `rng_get_or_fault()`. They do not replace the global `rng_get()` that libngu calls. Thus `my_random_bytes()` at `external/libngu/ngu/random.c:68-87` XORs two deterministic Yasmarang streams rather than incorporating a fresh hardware word for each output.

Mk4/Mk5/Q1 startup adds one unknown but very small state value. `shared/mk4.py:39-49` reads both secure elements, hashes their concatenation, truncates the digest to four bytes, and reseeds libngu:

a = callgate.read_rng(1)
b = callgate.read_rng(2)
n = ngu.hash.sha256d(a+b)
n, = ustruct.unpack('I', n[0:4])
ngu.random.reseed(n)

The inputs are not 64 fresh bytes. The bootloader returns 32 fresh bytes from SE1 (`stm32/mk4-bootloader/dispatch.c:583-588` and `ae.c:694-714`) and 8 static device bytes from SE2 (`dispatch.c:590-594` and `se2.c:1328-1344`). Although the 32 fresh bytes are not known to the attacker, the firmware compresses all 40 input bytes to a single unknown 32-bit word. `random_reseed()` at `external/libngu/ngu/random.c:162-168` writes only that word to `yasmarang_pad`; it does not create a full-width CSPRNG state:

STATIC mp_obj_t random_reseed(mp_obj_t arg) {
    yasmarang_pad = mp_obj_get_int_truncated(arg);
    return mp_const_none;
}

Consequently, under the stated observability assumptions, an attacker searches the unknown reseed contribution over at most 2322^{32}232 values, together with any unresolved `UID`/`RTC`/`SysTick` candidates. Initialization failure makes this worse: `shared/main.py:53-64` catches and ignores exceptions from `mk4.init0()`/`q1.init0()`, so if either secure-element RNG read fails, libngu can retain its compiled `yasmarang_pad` value `0x0a8ce26f`. This removes the fresh 32-bit reseed contribution, although any pre-failure evolution of `n`, `d`, and `dat` and the subsequent call history must still be reconstructed.

This does not mean recovery is unconditional. The attacker must also reproduce the generator's stream position and internal state. Calls before the reseed can evolve libngu's `n`, `d`, and `dat`, which reseeding does not reset; calls after it advance both streams. `bytes()` and `uint32()` consume words, `uniform()` can consume a data-dependent number of libngu words (`external/libngu/ngu/random.c:90-157`), and first creation of the secp256k1 context consumes 32 bytes before a zero-argument keypair consumes another 32 bytes (`external/libngu/ngu/k1.c:72-104,420-449`). Recovery is practical only where the attacker can reconstruct or sufficiently bound this prior consumption history.

The weak source reaches wallet generation at `shared/seed.py:601-609`:

seed = ngu.random.bytes(32)
return ngu.hash.sha256d(seed)

It also reaches zero-argument secp256k1 key generation at `external/libngu/ngu/k1.c:420-449`, including USB (`shared/usb.py:705-728`), Web2FA (`shared/web2fa.py:20-34`), Teleport (`shared/teleport.py:70-85,196-211`), clone backup (`shared/backups.py:742-755,854-868`), and paper-wallet (`shared/paper.py:86-99`) flows. These public keys or wallet-derived addresses can provide the predicate used to test candidate states.

The following PoC is deliberately a model, not a full device exploit. It demonstrates that, for fixed boot state and fixed prior-consumption offset, the checked-in algorithms reduce the remaining secure-element-derived uncertainty to a 32-bit enumerated seed. It uses a reduced search solely so it finishes quickly; it does not demonstrate the cost of a complete 2322^{32}232 search, infer real `RTC`/`SysTick` values, account for an unknown call history, or recover a key from a physical device.

#!/usr/bin/env python3
import struct

MASK = 0xffffffff def rol32(x, n): return ((x << n) | (x >> (32 - n))) & MASK

class Yasmarang: def _init_(self, pad, n, d, dat=0): self.pad, self.n, self.d, self.dat = pad, n, d, dat def word(self): self.pad = (self.pad + self.dat + self.d * self.n) & MASK self.pad = rol32(self.pad, 3) self.n = self.pad | 2 self.d ^= ((self.pad << 31) + (self.pad >> 1)) & MASK self.d &= MASK low = self.pad & 0xff if low >= 128: low -= 256 self.dat ^= (low ^ (self.d >> 8) ^ 1) & 0xff return (self.pad ^ ((self.d << 5) & MASK) ^ (self.pad >> 18) ^ (self.dat << 1)) & MASK

UID_WORD, SYSTICK = 0x12345678, 0x00054321 RTC_TR, RTC_SSR = 0x00123456, 0x00000077

def output(reseed_word, words_skipped=0, length=32): chip = Yasmarang(UID_WORD ^ SYSTICK, RTC_TR, RTC_SSR) # This assumes no pre-reseed evolution of n/d/dat. mix = Yasmarang(reseed_word, 69, 233) for _ in range(words_skipped): chip.word(); mix.word() out = bytearray() while len(out) < length: out += struct.pack('<I', chip.word() ^ mix.word()) return bytes(out[:length])

DEMO_BITS = 20 secret = 0x000abcde & ((1 << DEMO_BITS) - 1) target = output(secret, words_skipped=8, length=8) recovered = next(x for x in range(1 << DEMO_BITS) if output(x, words_skipped=8, length=8) == target) assert recovered == secret print(f'recovered reduced {DEMO_BITS}-bit demo seed: {recovered:#x}') print('full firmware reseed uncertainty is 32 bits, plus unresolved boot state/offset')

#### Impact

For Mk4/Mk5/Q1, a supply-chain, targeted physical, or similarly capable attacker who records the device identity, sufficiently narrows the `RTC`/`SysTick` state sampled on the fallback's first use, and reconstructs the relevant RNG call history can enumerate the unknown 32-bit reseed word offline. The attacker tests candidates against an observed wallet address or ephemeral public key. The attack is conditional on those observability and stream-position assumptions; the defect alone does not guarantee recovery from an arbitrary device.

If the targeted output is a newly generated wallet seed, a matching candidate reveals the wallet entropy and all derived private keys, permitting theft of the wallet's funds. If the targeted output is a zero-argument ECDH keypair, a matching public key reveals the session private key and can remove confidentiality or authentication provided by that key in USB, Web2FA, Teleport, or backup flows. This is high severity under the report bar because the production RNG failure can enable wallet-seed or ephemeral-private-key recovery: the concrete Mk4/Mk5/Q1 startup path contributes only a 32-bit unknown secure-element-derived reseed word instead of fresh hardware entropy, while the stated assumptions explicitly bound the fallback's UID, `SysTick`, RTC, and stream-position state sufficiently for offline enumeration.

If secure-element initialization faults are induced or occur naturally and startup continues, the fixed-pad fallback can reduce the candidate space further, potentially to deterministic output for a reconstructable boot state and consumption history. This is an aggravating fail-open condition, not a claim that the compiled pad alone determines arbitrary device outputs.

The same erroneous fallback wiring is present for the legacy `COLDCARD`/Mk3 configuration. However, this report's 32-fresh-plus-8-static-byte reseed analysis and PoC model are specific to the Mk4/Mk5/Q1 initialization path; they do not claim the identical end-to-end recovery procedure for Mk3.

#### Recommendation

Route every STM32 `ngu.random` request directly to the board's fail-closed hardware RNG implementation, make production builds fail if that source is unavailable, and treat any early secure-element entropy initialization failure as fatal before seed or key generation. Do not use MicroPython's deterministic fallback for cryptographic outputs. Retain secure-element data only as full-width additional input to a reviewed CSPRNG/DRBG; do not truncate it to a 32-bit PRNG seed.

Cover `COLDCARD`/Mk3, `COLDCARD_MK4`/Mk4/Mk5, and `COLDCARD_Q1` in build/link tests that verify the symbol used by `CHIP_TRNG_32()` reads the STM32 RNG peripheral and propagates timeout and health-test failures. Also add device tests showing that `ngu.random.bytes()`, `ngu.random.uint32()`, and zero-argument secp256k1 key generation fail closed when the peripheral is faulted.

An active USB proxy can impersonate a Coldcard and substitute its wallet xpub despite the anti-MITM check

HighZK-tekijczz

#7<https://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af>

Copy markdown

The encrypted USB handshake returns the device ephemeral public key and the wallet master xpub in the same unauthenticated plaintext response. `ColdcardDevice.start_encryption()` stores that xpub as `self.master_xpub`:

# external/ckcc-protocol/ckcc/client.py:239-255
pubkey = self.ec_setup()
msg = CCProtocolPacker.encrypt_start(pubkey, version=version)
his_pubkey, fingerprint, xpub = self.send_recv(msg, encrypt=False)
self.session_key = self.ec_mult(his_pubkey)
self.master_xpub = str(xpub, 'ascii')
self.master_fingerprint = fingerprint
self.aes_setup(self.session_key)

The response is parsed without authentication in `CCProtocolUnpacker.mypb()`:

# external/ckcc-protocol/ckcc/protocol.py:347-355
dev_pubkey, fingerprint, xpub_len = unpack_from('64sII', msg, 4)
xpub = msg[-xpub_len:] if xpub_len else b''
return dev_pubkey, fingerprint, xpub

The device signs the resulting session key with its wallet master private key when asked for a MITM proof:

# shared/usb.py:739-754
with stash.SensitiveValues() as sv:
    pk = sv.node.privkey()
    sv.register(pk)
    signature = ngu.secp256k1.sign(pk, self.session_key, 0).to_bytes()
return b'biny' + signature

However, the host-side verification defaults to the xpub received through the channel being verified:

# external/ckcc-protocol/ckcc/client.py:269-291
def check_mitm(self, expected_xpub=None, sig=None):
    xp = expected_xpub or self.master_xpub
    ...
    if not sig:
        sig = self.send_recv(CCProtocolPacker.check_mitm(), timeout=5000)
    ...
    ok = self.mitm_verify(sig, xp)
    if ok != True:
        raise RuntimeError("Possible active MiTM attack in progress! Incorrect signature.")

This is verification-anchor confusion: `self.master_xpub` is attacker-controlled precisely because the plaintext `ncry`/`mypb` exchange is under active attack. A USB proxy replaces the host and device ephemeral shares to create two ECDH sessions, substitutes its own valid master xpub in `mypb`, and signs the host-facing session key with the private key matching that substituted xpub. Calling `check_mitm()` with its documented default then succeeds, even though the host has no channel to the genuine device.

USB encryption version 2 does not prevent the attack. `shared/usb.py:709-712` binds subsequent messages to encryption only after processing the unauthenticated handshake. The proxy knows both session keys and can decrypt and re-encrypt all later messages. An independently obtained genuine xpub passed explicitly as `expected_xpub` does block the PoC.

The unsafe no-argument check is used by sensitive CLI workflows, including transaction signing, HSM policy activation and status, local authorization-code retrieval, user/HSM authentication, backup initiation, and BIP39 passphrase submission (`external/ckcc-protocol/ckcc/cli.py`, including the signing path around lines 625-628 and another sensitive call around lines 1267-1269). Consequently, the flaw affects more than wallet discovery: callers can falsely treat relayed or modified PSBTs, HSM policy material, passphrases, and currently usable HSM authorization credentials as protected by an authenticated USB channel.

#### Impact

An attacker able to interpose on USB can make desktop software accept an attacker-controlled wallet xpub as the connected Coldcard's authenticated identity. After the default anti-MITM check reports success, the attacker can return its own xpubs and addresses through the authenticated-looking channel. A host wallet that uses the verified master xpub to create receive addresses will therefore direct deposits to keys controlled by the attacker, causing loss of funds without compromising the genuine Coldcard seed.

The same proxy can transparently translate encrypted commands between the host and genuine device, so successful encrypted communication does not expose the impersonation. It can read or alter sensitive encrypted-channel inputs such as BIP39 passphrases, HSM usernames and usable OTP/password-derived authorization material, HSM policy uploads, PSBTs, and responses. Downloaded backup contents remain protected by their separate backup encryption and are not disclosed merely by this channel flaw. This finding does not claim that USB transport alone bypasses on-device transaction confirmation or HSM spending policy; those remain separate authorization layers. The high-severity consequence is wallet-identity/authentication substitution that can redirect incoming funds after the protocol's explicit anti-MITM API has accepted the attacker.

#### Recommendation

Never use an identity key delivered inside the current unauthenticated ECDH handshake as the anchor for verifying that handshake. Require `check_mitm` callers to provide an independently pinned expected wallet/device identity, or perform a first-pairing ceremony in which the user verifies a fingerprint or short authentication string on the trusted Coldcard display before the host stores the identity.

Authenticate a canonical transcript containing the protocol/version, initiator and responder roles, both ephemeral public keys, and the pinned device/wallet identity. Derive directional encryption and authentication keys from ECDH plus that transcript using a domain-separated KDF, and use authenticated encryption or explicit key confirmation rather than bare AES-CTR.

A failed initial PIN write leaves the wallet operating without authentication

HighZK-qtgbuybe

#8<https://github.com/Coldcard/firmware/tree/ae88593>

Copy markdown

The first-time PIN setup flow catches every exception from the PIN write and verification sequence, logs it only to the debug console, and then unconditionally enables USB and enters the `EmptyWallet` menu:

# shared/actions.py:331-356
try:
    dis.busy_bar(True)
    assert pa.is_blank()

pa.change(new_pin=pin) pa.setup(pin) ok = pa.login() assert ok settings.set_key() settings.load() except Exception as e: print("Exception: %s" % e) finally: dis.busy_bar(False)

Allow USB protocol, now that we are auth'ed

from usb import enable_usb enable_usb()

from flow import EmptyWallet return MenuSystem(EmptyWallet)

This is fail-open because a blank secure-element PIN is itself represented as a signed successful PIN state. Both current Mk4/Q1 bootloader code and the Mk3 bootloader set `PA_SUCCESSFUL | PA_IS_BLANK` when the main PIN remains blank (`stm32/mk4-bootloader/pins.c:576-589` and `stm32/bootloader/pins.c:575-588`). That signed blank state is accepted by `pin_change` as authorization for subsequent PIN or secret writes (`stm32/mk4-bootloader/pins.c:885-918`; `stm32/bootloader/pins.c:831-858`).

Therefore, if `pa.change(new_pin=pin)` fails before the main-PIN slot is updated, such as after repeated secure-element communication failures or an induced interruption/fault, execution does not remain in the virgin/PIN-setup flow. It proceeds to `EmptyWallet`, whose first entries create or import seed material (`shared/flow.py:467-477`). Seed installation then calls `pa.change(new_secret=nv)` (`shared/seed.py:725-744`), which succeeds under the still-valid signed blank-PIN state and stores the wallet while the main PIN is still empty.

On every later boot, `pin_setup_attempt` detects the still-blank main PIN and again grants `PA_SUCCESSFUL | PA_IS_BLANK`; the normal startup path consequently does not require a PIN. The displayed setup workflow has therefore appeared to complete while the resulting wallet has no PIN authentication.

#### Impact

An attacker able to cause two consecutive secure-element failures or interrupt the secure-element operation while a user performs first-time PIN setup can make the PIN write fail without presenting an error to the user. The device then shows the normal empty-wallet workflow, allows the user to generate or import a seed, and stores that seed under a blank main PIN. After the attacker later obtains the device, bootloader setup automatically treats the blank PIN as successful, so the attacker can access the wallet and authorize private-key operations without knowing the PIN the user believed was installed. This is a complete authentication bypass for all subsequently created/imported wallet material on the affected setup attempt.

#### Recommendation

Treat every initial PIN setup error as fatal to the setup transaction. Do not enable USB, enter `EmptyWallet`, or permit seed creation/import unless the code has independently confirmed that the secure element is no longer blank and that a fresh setup/login with the selected PIN succeeds. Show a persistent user-facing error and return to the PIN setup/virgin flow (or reboot) on any exception.

Failed CCC authorization lets an attacker obtain a signature that the single-signer spending policy denied

HighZK-p1nsftxu

#9shared/auth.py#L410-L416

Copy markdown

Structural violation. `ApproveTransaction.interact()` treats eligibility for a CCC signature as sufficient to override a single-signer spending-policy (SSSP) denial, even though CCC's required Web 2FA has not yet succeeded. If that authorization subsequently fails, the code clears `could_ccc_sign` but never restores the saved SSSP denial. The device therefore creates its primary key-A signature without either SSSP authorization or the CCC key-C signature that justified the override.

The premature override is established in `/repo/shared/auth.py:410-416`:

could_ccc_sign, ccc_needs_2fa = CCCFeature.could_cosign(self.psbt)

test for allowing any signature when in single-signer mode

- but CCC will override it.

should_block, ss_needs_2fa = SSSPFeature.can_allow(self.psbt) if should_block and not could_ccc_sign: return await self.failure('Spending Policy violation.')

`could_ccc_sign` only means that the PSBT is an enrolled multisig containing key C and that the non-2FA parts of the CCC policy pass. `/repo/shared/ccc.py:313-342` returns `(True, needs_2fa)` before the deferred Web 2FA challenge is performed. This capability result suppresses `should_block=True` at `/repo/shared/auth.py:415`.

The two results can coexist through a concrete production path. When CCC Web 2FA is configured, `SpendingPolicy.meets_policy()` appends the `CCC: Web 2FA required` warning and returns `True` at `/repo/shared/ccc.py:133-138`. SSSP is evaluated afterward and rejects any existing PSBT warning at `/repo/shared/ccc.py:85-87,196-216`. Consequently, with CCC and SSSP both enabled, a CCC-eligible PSBT requiring CCC Web 2FA reaches `/repo/shared/auth.py:410-415` with `(could_ccc_sign, ccc_needs_2fa) == (True, True)` and `(should_block, ss_needs_2fa) == (True, False)`. The original report's incidental claim that `ss_needs_2fa` is also true is unnecessary and incorrect for this path; the stronger attacker input relies on the actual `should_block=True` result.

After the user approves the displayed transaction, a failed or aborted CCC challenge takes `/repo/shared/auth.py:550-558`:

try:
    await CCCFeature.web2fa_challenge()
except:
    could_ccc_sign = False
    ch2 = await ux_show_story("Will not add CCC signature. Proceed anyway?")
    if ch2 != 'y':
        return await self.failure("2FA Failed")

Answering `y` continues without rechecking `should_block`. `/repo/shared/auth.py:571` invokes `self.psbt.sign_it()` with the primary wallet secret, while `/repo/shared/auth.py:573-577` skips key C because `could_ccc_sign` is now false. The device even updates SSSP's last-signed state at `/repo/shared/auth.py:579-581`, despite having retained and bypassed its denial.

A non-incidental attacker input is a valid enrolled 2-of-N CCC multisig PSBT that already contains a partial signature from external key B and spends to an attacker-controlled output. CCC's own setup describes key A as the device master key, key C as the policy-controlled device key, and key B as another device (`/repo/shared/ccc.py:473-480`). Existing partial signatures are accepted into `part_sigs`, and the primary signing pass ignores already-signed keys while selecting key A at `/repo/shared/psbt.py:850-865,2128-2264`. Once key A is added, `is_complete()` counts the attacker-supplied B signature plus the new A signature toward the multisig threshold at `/repo/shared/psbt.py:2453-2474`. No key-C signature or successful CCC Web 2FA is then needed to finalize the spend.

#### Impact

A malicious host, coordinator, or external multisig cosigner can turn a transaction that SSSP explicitly denied into a complete spend under the following end-to-end conditions: CCC and SSSP are both active; the PSBT belongs to an enrolled CCC multisig and passes CCC's magnitude, velocity, and whitelist rules; CCC Web 2FA is configured but fails or is aborted; the local user approves the displayed transaction and then accepts the prompt to proceed without CCC; and the attacker controls enough other multisig signing power to complete the threshold.

In the standard 2-of-N construction described at `/repo/shared/ccc.py:473-480`, an attacker controlling key B can submit a B-presigned PSBT over USB. `/repo/shared/usb.py:786-815` writes the untrusted upload to PSRAM, and the checksum-bound `stxn` command invokes `sign_transaction()` at `/repo/shared/usb.py:531-542`, which creates `ApproveTransaction` at `/repo/shared/auth.py:721-730`. After parsing and normal PSBT validation, the faulty authorization sequence at `/repo/shared/auth.py:410-581` adds key A while omitting key C. The existing B signature and new A signature satisfy the 2-of-N threshold under `/repo/shared/psbt.py:2453-2501`.

`/repo/shared/auth.py:754-800` then serializes or finalizes the signed result into output PSRAM. For USB, `/repo/shared/usb.py:544-580` returns its length and checksum, and `/repo/shared/usb.py:757-784` exposes output file 1 to the host. The host protocol's `download_file()` consumes that output in `/repo/external/ckcc-protocol/ckcc/client.py:309-329`. The attacker can therefore obtain and broadcast a fully authorized A+B transaction even though the device's active SSSP returned `should_block=True` and CCC Web 2FA never authorized key C.

This defeats the owner's locked single-signer spending policy and converts a constrained key-A signing authority into an attacker-completable signature. The extra local prompt only says that CCC will not sign; it does not disclose that SSSP already denied the primary key-A signature or require the configured SSSP bypass PIN.

#### Recommendation

Bind the SSSP override to successful CCC authorization and actual key-C signing, not to preliminary CCC eligibility. If CCC Web 2FA fails or CCC signing cannot be guaranteed, immediately enforce the saved SSSP result before calling the primary `psbt.sign_it()`. A transaction with `should_block=True` must require the normal SSSP bypass mechanism rather than a generic proceed prompt.

Compute a final authorization plan after all deferred second-factor checks and assert immediately before primary signing that either SSSP authorized key A or an authorized key-C signature will be added atomically. Do not update SSSP velocity state for a transaction whose SSSP decision was bypassed by a failed CCC authorization.

Persisting an HSM policy can turn zero-value spending limits into unrestricted signing

HighZK-gvynfkgr

#10shared/hsm.py#L238-L251

Copy markdown

Structural property: policy parsing distinguishes an explicit monetary limit of `0` from omission, but canonical persistence and policy hashing serialize those fields by truthiness, so a save/reload transition erases the restriction. This violates the authorization invariant that `load -> save -> load` must preserve policy behavior and that distinct authorization semantics must have distinct policy hashes.

`ApprovalRule._init_()` accepts both `per_period` and `max_amount` in the inclusive range \[0,MAX\_SATS\]\[0, \\texttt{MAX\\\_SATS}\]\[0,MAX\_SATS\] (`shared/hsm.py:105-113,199-200`). The live authorization object then checks them by presence: `max_amount: 0` rejects every transaction with positive foreign output at `shared/hsm.py:320-321`, while `per_period: 0` rejects positive period spend at `shared/hsm.py:371-373`. A velocity rule additionally requires a valid nonzero top-level `period` (`shared/hsm.py:498-517`), so `{"period":1,"rules":[{"per_period":0}]}` is valid.

However, `ApprovalRule.to_json()` emits a field only when `bool(val)` is true (`shared/hsm.py:238-251`). `HSMPolicy.save()` uses that serializer for every durable rule (`shared/hsm.py:529-539`), and `HSMPolicy.hash()` hashes the same lossy representation (`shared/hsm.py:550-557`). Therefore both zero limits are omitted, and a zero-limit policy has the same policy identity as the resulting omitted-limit policy despite different authorization behavior.

This is reached through the production configuration and lifecycle path. The host uploads policy JSON and sends `hsms` (`external/ckcc-protocol/ckcc/protocol.py:242-249`, `external/ckcc-protocol/ckcc/cli.py:1009-1018`). The device checks command size and upload checksum (`shared/usb.py:624-637`), parses JSON and constructs `HSMPolicy` (`shared/hsm_ux.py:88-118`), displays the live zero-limit policy and requires two confirmations for a new file (`shared/hsm_ux.py:24-76`), then writes `self.save()` to `/flash/hsm-policy.json` (`shared/hsm.py:670-675`). The active in-memory object remains restrictive until reconstruction.

At boot, a nonblank wallet with a saved policy reaches `start_hsm_approval()` (`shared/actions.py:918-930`), which rereads and reparses the lossy file (`shared/hsm_ux.py:88-118`). If `boot_to_hsm` is configured, the degraded policy activates automatically (`shared/hsm_ux.py:140-147`); otherwise the operator receives another confirmation screen, which now describes the empty rule as `Any amount` (`shared/hsm.py:256-269`, `shared/hsm_ux.py:24-55,150-151`). Backup capture and restore preserve the already-lossy file verbatim (`shared/hsm.py:60-72`, `shared/backups.py:115-118,267-269`).

After reload, omitted limits become `None`, so all amount and velocity checks are skipped. An otherwise empty rule also skips wallet, whitelist, local confirmation, remote-user, self-transfer, and pattern checks before returning true (`shared/hsm.py:309-402`). `approve_transaction()` totals non-change outputs, selects that rule, and returns approval (`shared/hsm.py:869-968`). Independent PSBT parsing, ownership/change, sighash, warning/logging, CCC/SSSP, and optional 2FA checks remain in force (`shared/auth.py:348-416,524-571`); for a valid attacker-chosen PSBT that passes them, HSM approval reaches `psbt.sign_it()` and the signed or finalized result is returned over USB (`shared/auth.py:524-571,779-797`).

#### Impact

An operator can intentionally configure `max_amount: 0` or `per_period: 0` to prohibit outgoing value while retaining an HSM transaction rule. The device initially displays that restriction and the live policy rejects positive foreign output, but normal persistence followed by reboot, backup/restore, or explicit reload reconstructs the rule without the limit. With `boot_to_hsm`, this authorization weakening occurs automatically at startup; without it, the reload screen exposes the degraded rule as `Any amount` and requires confirmation.

After the degraded policy is active, an untrusted host can submit an otherwise valid PSBT paying an attacker-controlled address. Provided the PSBT passes the independent transaction-validity and any separately enabled CCC/SSSP or 2FA checks, the empty HSM rule approves it without amount, velocity, user, whitelist, wallet, or local-confirmation constraints. The device then produces a wallet signature that can move funds contrary to the installed HSM spending policy. The lossy policy hash does not distinguish the intended zero-limit policy from the unrestricted persisted representation.

#### Recommendation

Make canonical serialization presence-aware for every authorization-relevant field. In particular, emit `max_amount` and `per_period` whenever they are not `None`, so explicit zero remains distinct from omission. Add round-trip authorization tests and require the policy hash to distinguish zero from an absent limit. As defense in depth, consider rejecting transaction rules that become semantically empty unless an explicit unrestricted-rule marker is present.

A physical attacker can replace an approved firmware image with attacker-signed code and disclose wallet secrets

HighZK-31io5sfy

#11<https://github.com/Coldcard/firmware>

Copy markdown

The firmware approval is bound only to a cached 128-byte header, not to the firmware bytes later authenticated and installed from mutable external PSRAM.

The MicroSD path reads a header from the selected file, copies the binary to PSRAM, and constructs an approval request from the cached header:

# shared/actions.py:236-263,269-272
hdr = bytearray(FW_HEADER_SIZE)
fp.seek(offset + FW_HEADER_OFFSET)
rv = fp.readinto(hdr)
...
while pos < size:
    ...
    PSRAM.write(pos, buf)
    pos += here
...
m = FirmwareUpgradeRequest(hdr, size, psram_offset=0)

The approval screen decodes and displays that cached header. After approval it passes only the PSRAM offset and length to the bootloader; it does not re-read the header or pass a digest of the approved bytes:

# shared/auth.py:1477-1483,1501-1514,1516-1527
self.hdr = hdr
self.length = length
self.psram_offset = psram_offset
...
date, version, _ = decode_firmware_header(self.hdr)
...
ch = await ux_show_story(msg)
if ch == 'y':
    ...
    pa.firmware_upgrade(self.psram_offset, self.length)

The bootloader then independently authenticates whatever bytes occupy that external PSRAM range at that later time and immediately records their world checksum and installs them:

// stm32/mk4-bootloader/pins.c:1292-1308,1327-1338
uint32_t start = about[0];
uint32_t len = about[1];
const uint8_t data = (const uint8_t )PSRAM_BASE+start;
uint8_t world_check[32];
bool ok = verify_firmware_in_ram(data, len, world_check);
if(!ok) return EPIN_AUTH_FAIL;
...
rv = ae_encrypted_write(KEYNUM_firmware, KEYNUM_main_pin, digest, world_check, 32);
...
psram_do_upgrade(data, len);

This later signature check does not preserve the user's decision because the accepted key set includes public development key zero. `verify_firmware_in_ram()` accepts any recognized key after checking the current PSRAM bytes (`stm32/mk4-bootloader/verify.c:247-288`), and the repository explicitly states that key zero is shared publicly so anyone can build firmware (`stm32/keys/README.md:1-18`). Firmware signed with key zero is allowed to run; boot merely shows a warning (`stm32/mk4-bootloader/verify.c:349-352`).

Consequently, an implant or other attacker able to modify/replace the external PSRAM contents while the genuine cached version/build is displayed can substitute same-length malicious firmware signed with public key zero. The bootloader validates and blesses the substituted bytes rather than the bytes the user reviewed.

#### Impact

An attacker with physical access can install an interposer or replace/control the external PSRAM, wait for the owner to initiate and approve a legitimate firmware upgrade, and swap the staged image after its header has been cached for display but before `pin_firmware_upgrade()` reads PSRAM. The replacement can be custom firmware signed with the repository's public development key zero and given a timestamp above the anti-downgrade floor.

The bootloader accepts the replacement, stores its world checksum in the secure element, and flashes it. Although a custom-firmware warning is displayed during boot, the attacker-controlled firmware is still executed afterward and can read wallet secrets through the normal authenticated firmware interfaces, alter transaction review/signing policy, or exfiltrate seeds on a later SD/USB interaction. This converts invasive access to an external mutable memory bus into persistent arbitrary firmware execution despite the owner approving different exact content.

#### Recommendation

Cryptographically bind local approval to the exact firmware bytes. Compute a digest over the complete staged image before displaying approval, include a user-verifiable digest/version in the approval, and pass the expected digest through the authenticated PIN command. In the bootloader, verify the current PSRAM image and compare its authenticated digest and security-relevant header fields against the approved values before updating the secure-element world checksum or erasing flash.

Also require a separate explicit development-firmware authorization path before accepting public key zero. A normal factory-firmware approval must not be reusable to authorize key-zero firmware merely because PSRAM changed after review.

Captured first HOTP code can repeatedly authorize HSM transaction signing

HighZK-qutsp9ig

#12shared/users.py#L219-L248

Copy markdown

The HOTP replay state uses `0` for both "no code accepted yet" and "counter zero was accepted." As a result, the structurally replayed input `(stored last_counter = 0, token = HOTP(secret, 0))` is accepted repeatedly. This replay shape is distinct from its concrete provenance: an attacker does not need to choose the HOTP secret or create the user; the strongest attacker-reachable input is a legitimate user's first six-digit HOTP code captured before or during its first use and then resubmitted through the HSM USB `user` command.

New HOTP users are persisted with counter zero:

# shared/users.py:142-146
u = cls.get()
assert len(u) < MAX_NUMBER_USERS, 'too many'
u[username] = [auth_mode, b32encode(secret), 0]
settings.put(KEY, u)

When that state is loaded, `auth_okay` checks counters one through nine and additionally counter zero. A counter-zero match writes zero back, so the security state does not advance:

# shared/users.py:219-248
if auth_mode == USER_AUTH_HOTP:
    candidates = [last_counter+i for i in range(1, 10)]

if not last_counter: candidates.append(0)

...

if expect == token: cls.update_counter(username, c) return ''

The attacker-controlled credential is reachable from `shared/usb.py:672-682`, where the HSM-whitelisted `user` command accepts a username and token and queues them with `hsm_active.usb_auth_user`. During transaction approval, `shared/hsm.py:877-925` consumes that queued value, calls `Users.auth_okay`, and adds the username to the authorized-user set on success. `ApprovalRule.matches_transaction` counts that set toward `min_users` at `shared/hsm.py:364-369`. Thus, while the stored counter remains zero, resubmitting the captured counter-zero token satisfies the same HSM user factor again instead of returning a replay error.

#### Impact

A malicious USB host that captures a legitimate HSM user's first HOTP code can submit the same six-digit token before each signing request. The host sends the replay through `user` (`shared/usb.py:672-682`), uploads and requests signing of a PSBT through `stxn` (`shared/usb.py:531-541`), and the HSM accepts the replayed username toward the rule's `min_users` requirement (`shared/hsm.py:921-968`). Approval then reaches the signing consumer in `shared/auth.py:526-571`, which calls `self.psbt.sign_it()`.

This does not bypass unrelated HSM restrictions such as additional required users, local confirmation, wallet, whitelist, amount, warning, or velocity checks. However, whenever this HOTP user is the only missing factor for an otherwise matching rule, the replay converts one captured one-time code into repeated authorization for attacker-supplied, policy-compliant transactions. The replay remains usable until a higher HOTP counter is successfully accepted, the user is deleted, or its credential is replaced.

#### Recommendation

Represent an unused HOTP credential with a value that cannot be a valid HOTP counter, such as `-1`, and derive candidates uniformly as counters strictly greater than the last accepted counter. Remove the special case that appends counter zero when the stored value is zero. Treat existing records with stored counter zero as having already consumed counter zero during migration; this prevents continued replay even though previously unused authenticators may need to advance to counter one.

Signatures can authorize immediate PSBTv2 spends despite required locktimes

HighZK-lcte67vt

#13shared/psbt.py#L1119-L1121

Copy markdown

BIP370 requires a PSBTv2 signer to reconstruct the transaction `nLockTime` from the per-input `PSBT_IN_REQUIRED_HEIGHT_LOCKTIME` and `PSBT_IN_REQUIRED_TIME_LOCKTIME` fields. When inputs require a locktime, the signer must select the type supported by every locktime-constraining input, use the maximum value of that type, and prefer height when both types are possible. `PSBT_GLOBAL_FALLBACK_LOCKTIME` applies only when no input specifies a requirement.

The firmware parses both required-locktime fields from hostile PSBT input maps:

# /repo/shared/psbt.py:992-995
elif kt == PSBT_IN_REQUIRED_TIME_LOCKTIME:
    self.req_time_locktime = unpack("<I", self.get(val))[0]
elif kt == PSBT_IN_REQUIRED_HEIGHT_LOCKTIME:
    self.req_height_locktime = unpack("<I", self.get(val))[0]

However, the transaction-wide property ignores them and uses only the v0 unsigned transaction locktime or the v2 fallback:

# /repo/shared/psbt.py:1119-1121
@property
def lock_time(self):
    return (self._lock_time or self.fallback_locktime) or 0

For PSBTv2, `_lock_time` is unset because there is no global unsigned transaction. A valid PSBTv2 containing a required height of `499999999`, no fallback locktime, and at least one non-final input sequence therefore produces `self.lock_time == 0`. Validation confirms only the numeric domains at `/repo/shared/psbt.py:1555-1564`; it neither derives the required maximum nor rejects a set of inputs for which no locktime type is supported by all locktime-constraining inputs. Because the derived value is zero, the locktime UX block at `/repo/shared/psbt.py:1585-1609` is also skipped.

After authorization, both signature algorithms commit to this incorrect value at `/repo/shared/psbt.py:2361-2362` and `/repo/shared/psbt.py:2445-2446`. Finalization writes the same zero at `/repo/shared/psbt.py:2632-2633`. The resulting signature is valid for an immediately eligible transaction rather than for the transaction reconstructed according to the required-locktime fields in the approved PSBTv2.

#### Impact

This is conditionally high impact when the PSBTv2 required-locktime fields are part of the authorization contract for a staged spend and the Coldcard signature alone, or a threshold of similarly behaving signers, is sufficient to spend. An attacker-controlled coordinator can submit an otherwise valid PSBTv2 with the intended future required locktime, obtain approval through the normal interactive or HSM path, and receive a signature that commits to `nLockTime = 0`. If the inputs have non-final sequences, the correctly reconstructed transaction would remain unavailable until the required height or time, while the transaction accepted and signed by the device can be broadcast immediately.

The practical consequence is early execution of a delayed withdrawal, inheritance transfer, vault recovery step, or other presigned workflow before its intended reaction period. The condition matters: if the owner independently verifies that the trusted device display contains the expected `TX LOCKTIMES` entry and refuses when it is absent, or if another required signer correctly reconstructs and signs the future locktime, this path does not yield a spendable early transaction. A malicious coordinator that never established a required locktime gains nothing from merely adding ignored metadata.

#### Recommendation

Implement BIP370 transaction reconstruction before display, policy evaluation, signing, or finalization. Among inputs that specify at least one required-locktime field, select a locktime type present in every such input, use the maximum requirement of the selected type, and prefer height when both types are available. Reject PSBTv2 input combinations for which neither type is supported by every locktime-constraining input. Use the fallback locktime only when no input specifies either requirement, and use the resulting single value consistently in the UX, HSM authorization story, every sighash implementation, and final transaction serialization.

Power interruption during backup restore leaves the restored wallet able to sign without its spending policy

HighZK-0tregwj7

#14shared/backups.py#L162-L269

Copy markdown

`restore_from_dict_ll()` commits the restored seed before it restores the seed-bound security policy, and there is no persistent restore-in-progress marker that blocks normal boot or signing after an interruption.

The restore sequence first replaces the protected secret and switches the settings namespace:

# shared/backups.py:176-182

clear (in-memory) settings and change also nvram key

- also captures xfp, xpub at this point

pa.change(new_secret=raw) dis.progress_bar_show(.25)

force the right chain

pa.new_main_secret(raw, chain) # updates xfp/xpub

`pa.change(new_secret=raw)` reaches the protected PIN callgate and permanently writes the new seed. `pa.new_main_secret()` then derives the new seed's settings key and loads that namespace. When this is a newly restored seed, that namespace can contain only defaults.

Only afterward does the function iterate over backup settings, including `setting.sssp`, and save them:

# shared/backups.py:198-264
for key in vals:
    # ...
    settings.set(k, vals[key])

write out

settings.save()

The backup writer does include `sssp`, because it serializes every current setting except an explicit denylist that does not contain `sssp`:

# shared/backups.py:100-113
for k,v in settings.current.items():
    # ... excluded keys ...
    ADD('setting.' + k, v)

The policy is fail-open when the setting is absent. `SSSPFeature.can_allow()` returns permission to continue signing whenever `SSSPFeature.is_enabled()` is false; with no restored `sssp` value, `sssp_spending_policy('en')` is false:

# shared/ccc.py:191-205
def can_allow(cls, psbt):
    if not cls.is_enabled():
        exists = bool(settings.master_get('sssp', False))
        if exists:
            psbt.warnings.append(('SP', "Spending Policy defined but disabled."))
        return False, False

Therefore, cutting power after the seed write at `backups.py:178` but before the settings save at `backups.py:264` creates a durable hybrid state: the valuable restored seed is installed, while its spending limit, address allowlist, velocity limit, and optional Web2FA requirement are absent. On reboot there is no transaction marker to force restore completion or wipe/lock the partially restored seed.

#### Impact

A physical attacker who knows the device's main PIN but is supposed to remain constrained by Single-Signer Spending Policy can bypass that policy during a legitimate backup restore. After the owner approves the displayed backup fingerprint, the attacker interrupts power while the progress screen is between the protected seed write and policy persistence. The device then reboots with the restored seed active but without `sssp` enabled. The attacker later unlocks with the known main PIN and submits an attacker-chosen transaction; `SSSPFeature.can_allow()` observes no enabled policy and permits the ordinary signing flow, bypassing amount/velocity/address restrictions and Web2FA and enabling theft of wallet funds.

The same non-atomic ordering also precedes restoration of other settings and the HSM policy file, but the concrete high-impact path above requires only the explicitly backed-up seed-bound `sssp` policy and the threat model's attacker who knows the main PIN.

#### Recommendation

Make backup restoration a fail-closed persistent transaction. Before changing the protected seed, durably record that a restore is in progress in a namespace that is readable before selecting the new seed's settings. On every boot/login, refuse all wallet use and signing while that marker is present; permit only completion of the same authenticated restore or an explicit wipe/restart. Stage and validate the complete seed-bound settings and policy first, then commit the seed and policy as one recoverable state transition, and clear the marker only after every required policy component is durably installed and verified.

A physical interposer can replay one-time HSM credentials after interrupting power before counter persistence

HighZK-3d1d05bo

#15shared/users.py#L94-L252

Copy markdown

Successful HOTP and TOTP authentication updates the replay counter only in RAM and schedules an asynchronous settings write. The wallet proceeds to approve and produce the requested signature before that counter is durably committed.

`Users.update_counter()` mutates the in-memory settings dictionary and calls `settings.changed()` rather than synchronously saving it:

# shared/users.py:94-99
@classmethod
def update_counter(cls, username, cnt):
    t = cls.get()
    assert username in t
    t[username][2] = cnt
    settings.changed()

Both OTP modes return success immediately after this non-durable update:

# shared/users.py:222-252
if auth_mode == USER_AUTH_HOTP:
    candidates = [last_counter+i for i in range(1, 10)]
    if not last_counter:
        candidates.append(0)
else:
    if totp_time <= last_counter:
        return 'replay'
    candidates = [(totp_time-i) for i in range(0, 3)
                  if (totp_time-i) > last_counter]
...
if expect == token:
    cls.update_counter(username, c)
    return ''

`SettingsObject.changed()` does not write immediately. It schedules `write_out()` for 250 milliseconds later on the cooperative event loop:

# shared/nvstore.py:402-405,466-475
def changed(self):
    self.is_dirty += 1
    if self.is_dirty < 2:
        call_later_ms(250, self.write_out)
...
async def write_out(self):
    if not self.is_dirty:
        return
    try:
        self.save()

In the HSM signing path, the OTP is checked at `shared/hsm.py:924-931`, policy approval returns, and the transaction is signed synchronously at `shared/auth.py:524-571`. The signed result is then serialized into PSRAM and marked available to USB without an intervening durable counter write or event-loop yield:

# shared/auth.py:524-571
ch = await hsm_active.approve_transaction(...)
...
self.psbt.sign_it()
# shared/auth.py:779-797
with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram:
    if is_complete:
        txid = psbt.finalize(psram)
    else:
        psbt.serialize(psram)
    data_len = psram.tell()
    data_sha2 = psram.checksum.digest()

if input_method == "usb": tx_req.result = data_len, data_sha2 if hsm_active: return

Because these operations are synchronous, the delayed `write_out()` task cannot run while approval, signing, serialization, and `tx_req.result` publication execute. A power interruption during this window reboots with the old persisted `last_counter`, even though the OTP has already caused a wallet signature. Under the stated threat model, the attacker may interrupt power and probe/interpose peripheral buses; the signed PSBT is placed in external PSRAM before the replay state becomes durable.

This violates the defining one-time property. RFC 4226 section 7.2 requires the verifier to advance its counter after successful HOTP validation, and RFC 6238 section 5.2 states that a verifier must not accept a second use of a successfully validated TOTP: <https://www.rfc-editor.org/rfc/rfc4226.html> and <https://www.rfc-editor.org/rfc/rfc6238.html>

#### Impact

A physical attacker who can interpose the external PSRAM bus and control device power can turn one valid HOTP/TOTP approval into multiple authorized signatures.

The attacker first observes or submits a legitimate one-time credential for a transaction allowed by a rule requiring that HSM user. The device accepts the OTP, signs, and writes the signed PSBT to external PSRAM, but the OTP counter still exists only in RAM. The attacker captures that signed output from the probed PSRAM bus and interrupts power before the delayed settings task commits. After reboot and HSM reactivation, the persisted counter is unchanged, so the same HOTP/TOTP value is accepted again.

HOTP and TOTP credentials are not bound to the PSBT hash in this implementation; only `USER_AUTH_HMAC` is transaction-bound. The replayed OTP can therefore authorize a different attacker-chosen transaction that satisfies the same HSM rule. Repeating the interruption can bypass the intended per-transaction remote-user approval and produce additional valid wallet signatures, enabling unauthorized spends within the policy's other limits.

This attack is specifically relevant to the audit threat model because hosts are untrusted, HSM credentials protect signing authority, and physical attackers may interrupt power and probe peripheral buses.

#### Recommendation

Make successful HOTP/TOTP consumption durable before returning authentication success or performing any signing operation. A security-critical replay counter must use a write-ahead/commit-before-effect ordering: validate the token, atomically persist the new counter, verify that persistence succeeded, and only then expose success to the policy and signer.

Do not use the normal delayed settings batching path for OTP counters. At minimum, synchronously call the atomic settings-slot save operation after updating the counter and fail authentication if it cannot be committed. For stronger fault resistance and flash endurance, store replay state in a dedicated journal or secure-element monotonic counter with an atomic committed record. Add power-cut tests at every boundary between OTP validation, counter persistence, signature generation, PSRAM serialization, and USB result publication.

Physical side-channel measurements can recover AES-256 keys

HighZK-3flzj3u8

#16external/c-modules/aes256ctr/aes\_256\_ctr.s#L9-L424

Copy markdown

The AES-256-CTR assembly is the unprotected T-table implementation from Ko Stoffelen's `aes-armcortexm` project, not that project's constant-time bitsliced/masked implementation. `AES_Te0` is a 1 KiB table and both the key schedule and encryption use secret-dependent indexed loads such as:

adr r0, AES_Te0
uxtb r10, r9, ror #8
ldrb r10, [r0, r10, lsl #2]

The upstream project and paper separately identify only the bitsliced AES-128 variant as protected against timing attacks and only the masked bitsliced AES-128 variant as protected against first-order physical side channels. The imported AES-256 code is the fast, unprotected variant. This distinction matters on the deployed STM32L496/L4S5 Cortex-M4 targets: their Flash interface includes data caching/acceleration, so the 1 KiB table's data-dependent Flash accesses are not generally software-timing invariant. More importantly under the stated physical-attacker model, indexed S-box/T-table operations and unmasked intermediates provide standard power/EM leakage. Published work has practically recovered both an AES-CTR key and nonce from 256 power traces on a Cortex-M4.

The alternative Cifra source enables cache protection by default and scans all 256 S-box entries through a `volatile const` pointer (`external/libngu/libs/cifra/src/aes.c:95-96`, `external/libngu/libs/cifra/src/bitops.h:164-219`, `external/libngu/libs/cifra/src/cf_config.h:23-56`). This is materially better for cache leakage, but source C alone does not establish that every production compiler/version/flag preserves branchless constant-time machine code. It also does not mask power/EM leakage: the equality mask and selected intermediate remain secret-dependent values.

Evidence:

#### Impact

A physical attacker who can retain and repeatedly operate a device can trigger AES-CTR operations while measuring supply current or near-field electromagnetic emissions. By correlating traces with hypotheses for the first-round S-box/T-table intermediates, the attacker can recover the AES key; AES-CTR-specific literature demonstrates recovery of the key and nonce from only 256 traces on a Cortex-M4. Depending on which long-lived application key is measured, recovery can expose encrypted settings, saved wallet/passphrase material, USB session traffic, teleport payloads, or other secrets protected by this module, potentially leading to wallet-secret recovery and loss of funds. Cache/timing leakage is additionally plausible because the target Flash interface has a data cache much smaller than `AES_Te0`, although an end-to-end remote timing exploit is less established than local power/EM key recovery.

#### Recommendation

Do not use the imported T-table AES implementation for secrets when a physical attacker is in scope. Replace it with an implementation designed and evaluated for the actual threat model: at minimum a table-free constant-time bitsliced/fixsliced AES-256 implementation for software-visible timing/cache resistance, and a properly masked implementation with fresh randomness plus device-level leakage testing for power/EM resistance. Treat Cifra's table scan as cache-hardening only, not physical side-channel protection, and verify the exact production binary whenever relying on C constant-time idioms.

Power loss can remove the spending policy and restore unrestricted signing

HighZK-4fbtc0bd

#17shared/nvstore.py#L490-L504

Copy markdown

Structural property. `SettingsObject.save()` writes and closes a successor slot before unlinking its predecessor (`shared/nvstore.py:490-504`). LittleFS close synchronizes the file (`external/micropython/extmod/vfs_lfsx_file.c:186-196`, `external/micropython/lib/littlefs/lfs2.c:2620-2635,2805-2853`), so the Python ordering does not itself create a normal no-valid-copy interval. However, production Mk4/Q1 expose 512-byte logical LittleFS blocks (`external/micropython/ports/stm32/storage.h:31`, `external/micropython/ports/stm32/storage.c:416-434`) over STM32L4S5 flash whose physical erase page is 8 KiB (`external/micropython/ports/stm32/flash.c:100-106`). Synchronizing a dirty logical block copies an 8 KiB page, erases the complete page, and rewrites it in a later handler step (`external/micropython/ports/stm32/flashbdev.c:166-198,250-265`). A power cut in that erase-before-rewrite interval can therefore destroy 16 logical blocks together, defeating LittleFS's assumption that its redundant logical metadata blocks fail independently.

Exact condition. Exploitation requires a save whose physical 8 KiB rewrite page contains both members of active LittleFS metadata needed to discover/authenticate all surviving nvstore slots, or contains equivalent shared metadata/data whose loss invalidates every slot. Allocation and wear history determine this co-location; not every save or power cut is exploitable. The root metadata pair is initially logical blocks 0 and 1 in the same physical page (`external/micropython/lib/littlefs/lfs2.c:3621-3624`), and LittleFS allocates redundancy in logical-block pairs (`external/micropython/lib/littlefs/lfs2.c:1443-1450`) despite the production device erasing groups of 16.

Attacker reachability. A physical attacker who knows the main PIN, or acts while the hobbled device is unlocked, can trigger a same-structure snapshot without modifying the restrictive `sssp`: Address Explorer remains available in hobbled mode (`shared/flow.py:560-574`), selecting an address writes `axi` (`shared/address_explorer.py:256-264`), and `put()` schedules the whole settings map for save after 250 ms (`shared/nvstore.py:399-418`). The attacker then interrupts power during the vulnerable physical-page rewrite. This replaces the finding's incidental hand-constructed slot provenance with a production-constructible snapshot containing the existing active policy.

At reboot, `load()` rejects torn or undiscoverable slots, and if none validates it installs defaults without `sssp` (`shared/nvstore.py:346-394,517-524`). Login derives `pa.hobbled_mode` solely from the recovered `sssp` setting (`shared/actions.py:862-895`). With the setting absent, `SSSPFeature.can_allow()` permits otherwise-valid PSBT signing (`shared/ccc.py:195-216`), and the authorization path proceeds past the spending-policy gate (`shared/auth.py:408-416`).

#### Impact

Under the stated allocation-dependent condition, a physical attacker can turn one attacker-triggered settings save and a precisely timed power interruption into complete loss of the active Single-Signer Spending Policy. After reboot, the device loads defaults, does not enter hobbled mode, and treats the policy as absent. An attacker who knows the main PIN can then submit and approve an otherwise-valid PSBT that violates the former magnitude, velocity, address-whitelist, or Web2FA restriction; `shared/auth.py:408-416` reaches `SSSPFeature.can_allow()`, and `shared/ccc.py:195-216` returns without blocking when `sssp` is absent. This defeats the threat-model purpose of Spending Policy against an attacker who possesses the device and knows the main PIN.

The result is conditional rather than universal: the interrupted 8 KiB erase/rewrite must cover shared LittleFS metadata or data required by every valid nvstore snapshot. If at least one independently recoverable slot and its directory metadata remain outside the affected physical page, recovery retains the active policy and the bypass fails.

#### Recommendation

Make the filesystem's advertised erase geometry and failure domain match the STM32 flash implementation, or add a power-loss-safe lower-layer journal so an interrupted 8 KiB page rewrite cannot erase all logical copies. Place security-policy recovery records in independently erasable physical pages and fail closed when an enabled policy record is unexpectedly unavailable. Validate the fix with fault injection at every physical erase/program boundary, not only by corrupting individual simulator files.

Interrupted first-boot provisioning can expose the persisted secure-element pairing secret

HighZK-bmdybc55

#18stm32/mk4-bootloader/storage.c#L463-L503

Copy markdown

This issue is conditional on the MCU remaining in the bootloader's incomplete-provisioning state; it is not reachable merely by replacing SE1 in a normally provisioned device. At reset, `system_startup()` unconditionally enters `flash_setup()` (`stm32/mk4-bootloader/main.c:132-153`), but `flash_setup()` calls `ae_setup_config()` only when `pairing_secret_xor` or the saved SE1 identity is still blank (`stm32/mk4-bootloader/storage.c:463-488`). After successful setup, `confirm_pairing_secret()` programs the complement marker (`storage.c:500-503`), so replacing SE1 later does not reopen the path.

A physical attacker can nevertheless construct the required state during first-boot provisioning. `pick_pairing_secret()` persists the pairing secret before SE1 setup (`storage.c:475-488`). If power is interrupted after that write, and in particular after the original public serial has been observed/saved but before `confirm_pairing_secret()`, the next reset retains the secret while `blank_xor` remains true and re-enters `ae_setup_config()`. This follows the explicit threat-model capabilities to interpose the SE bus and interrupt power, but requires access during incomplete provisioning rather than access only to a completed device.

Once reached, SE1 responses are authenticated only by public framing CRC. `ae_read_n()` accepts attacker-generated bodies after checking length and CRC (`stm32/mk4-bootloader/ae.c:577-606`). The attacker can report an unlocked data zone at `ae.c:1817-1828`, return configuration blocks containing the already observed persisted serial at `ae.c:1844-1857`, report the config zone locked to skip configuration writes, and set the slot-lock bitmap so only `KEYNUM_pairing` is treated as writable at `ae.c:1868-1924`. This is the strongest same-structure construction: the essential property is the bootloader authorizing a plaintext secret write from unauthenticated bus state, not the incidental choice of exactly two modified response frames.

At the pairing slot, `ae_setup_config()` passes `rom_secrets->pairing_secret` to `ae_write_data_slot()` (`ae.c:1920-1923`), which serializes the 32-byte value in an `OP_Write` request before receiving status (`ae.c:1281-1294`). Thus an interposer learns the secret even if the replacement/emulator rejects the write. Firmware cannot independently trigger this path: `shared/callgate.py` exposes ordinary SE reads and factory RDP controls, while `firewall_dispatch()` reserves startup method `-1` to the reset stub (`stm32/mk4-bootloader/dispatch.c:662-669`) and has no callgate method for `ae_setup_config()`.

#### Impact

Under the exact condition that first-boot provisioning has persisted `pairing_secret` but has not yet programmed `pairing_secret_xor`, a physical attacker can interrupt power, interpose or emulate SE1 on reboot, and cause the bootloader to transmit the persisted 32-byte pairing secret in plaintext. The attacker can then allow provisioning to complete and retain a credential intended to be shared only by the protected MCU and SE1. Recovery of this pairing key weakens the MCU-SE1 trust boundary and enables later SE1 impersonation or manipulation by an attacker who again controls the bus.

A normally provisioned device is not affected by replacement alone: its nonblank complement marker prevents `ae_setup_config()` from running. The impact therefore applies only to devices whose initial provisioning can be interrupted and resumed under attacker-controlled SE-bus conditions; it does not support the original broader claim that replacing SE1 in any deployed device discloses the secret.

#### Recommendation

Never release a persisted pairing secret based on an unauthenticated serial/configuration read. Before any mutation or plaintext secret write, authenticate the active secure element with a challenge-response operation that proves knowledge of the already-provisioned pairing key and binds the proof to the expected device identity. If the active element cannot prove possession, abort provisioning without transmitting the persisted secret. A replacement blank element must require a separate recovery or factory enrollment flow that creates a new pairing secret rather than reusing the old one.

zkao.io · 2 images · 26 links

Comments (0)

No comments yet