This report was generated by the ZKAO Max scan on zkao v2.0.0, completed Aug 2, 2026.

Coldcard/firmware@ ae88593552

ZKAO Maxzkao v2.0.0Completed Aug 2, 202618 confirmed findings
1Critical17High

Findings

18 total

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

#SeverityFindingLocation
1CriticalMalicious firmware can reuse protected bootloader code to bypass the callgate and expose firewall secretshttps://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/stm32/mk4-bootloader
2HighUnauthenticated Coldcard cloning lets an attacker controlling MicroSD contents recover the source seed or replace the target walletshared/backups.py#L750-L879
3HighForged PSBT ownership metadata lets HSM self-transfer rules authorize full theftshttps://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/shared
4HighAttacker-controlled multisig outputs bypass review and HSM policy as false changehttps://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/shared
5HighA duplicated cosigner key can reduce the effective multisig threshold and enable thefthttps://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af/shared
6HighPredictable firmware randomness can expose wallet seeds and ephemeral private keyshttps://github.com/Coldcard/firmware/tree/master
7HighAn active USB proxy can impersonate a Coldcard and substitute its wallet xpub despite the anti-MITM checkhttps://github.com/Coldcard/firmware/tree/ae88593552403303a1973f1a927095b62cc059af
8HighA failed initial PIN write leaves the wallet operating without authenticationhttps://github.com/Coldcard/firmware/tree/ae88593
9HighFailed CCC authorization lets an attacker obtain a signature that the single-signer spending policy deniedshared/auth.py#L410-L416
10HighPersisting an HSM policy can turn zero-value spending limits into unrestricted signingshared/hsm.py#L238-L251
11HighA physical attacker can replace an approved firmware image with attacker-signed code and disclose wallet secretshttps://github.com/Coldcard/firmware
12HighCaptured first HOTP code can repeatedly authorize HSM transaction signingshared/users.py#L219-L248
13HighSignatures can authorize immediate PSBTv2 spends despite required locktimesshared/psbt.py#L1119-L1121
14HighPower interruption during backup restore leaves the restored wallet able to sign without its spending policyshared/backups.py#L162-L269
15HighA physical interposer can replay one-time HSM credentials after interrupting power before counter persistenceshared/users.py#L94-L252
16HighPhysical side-channel measurements can recover AES-256 keysexternal/c-modules/aes256ctr/aes_256_ctr.s#L9-L424
17HighPower loss can remove the spending policy and restore unrestricted signingshared/nvstore.py#L490-L504
18HighInterrupted first-boot provisioning can expose the persisted secure-element pairing secretstm32/mk4-bootloader/storage.c#L463-L503

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

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

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

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

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 MM-of-NN wallet where the attackers control MM 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

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

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} 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} 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

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

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

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

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}] (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

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

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

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

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

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

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

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

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.

zkaoAutomated security analysis for cryptography code
v2.0.0