bugfix: signing TOCTOU on PSRAM-staged PSBT
What changed, and why it matters
This commit fixes a security bug where a malicious or compromised computer connected to a COLDCARD wallet could potentially swap the transaction data after the user reviewed it on the device screen but before the device signed it. The user might approve one transaction while the device signs a different one. The fix locks down the staged transaction data, re-checks it right before signing, and aborts with an error if anything changed.
Treat this as a security fix and include it in the next firmware release. Users should upgrade when available. The change is defensive and fail-closed, so no configuration change is needed.
Security signals we found
TOCTOU vulnerability in transaction signing
Post-review mutation of staged PSBT bytes
USB host could rewrite transaction data during approval
Fail-closed behavior on detected modification
PSRAM write-generation counter for fast-path integrity check
Regression tests for attack scenarios
Acknowledged external report with PoC
Evidence from the diff
The patch addresses a TOCTOU (time-of-check/time-of-use) vulnerability in the COLDCARD signing flow for PSBTs staged in PSRAM. Previously, the PSBT bytes were parsed and displayed to the user, but the same bytes were re-read from mutable PSRAM during signing. A USB host could upload new data into the transaction staging region while the approval screen was waiting, causing the signature to cover a different transaction than the one reviewed. The fix binds a SHA-256 digest of the staged bytes at parse time, tracks PSRAM writes to the transaction region with a generation counter, and re-verifies the digest immediately before signing. On mismatch it wipes the transaction and fails with “Transaction modified”. It also rejects USB uploads while a transaction or firmware-upgrade approval flow owns the staging area, and adds regression tests for mid-approval upload rewrite and direct PSRAM mutation.
Changed components
shared/auth.pyshared/psram.pyshared/usb.pytransaction signing approval flowPSRAM transaction staging areaUSB upload handlerInspect captured patch +152 / −4
### releases/Next-ChangeLog.md
@@ -27,6 +27,10 @@ This lists the new changes that have not yet been published in a normal release.
- Change: Backup System, Clone Coldcard, and Key Teleport’s Full COLDCARD Backup now capture the wallet secret currently in effect, including temporary seeds and BIP-39 passphrase wallets, and warn before export.
- Bugfix: View Seed Words and backup workflows incorrectly treated the master seed as the parent of every BIP-39 passphrase wallet. When a passphrase was applied to a temporary seed, they could not access that immediate parent seed.
- Enhancement: RNG self-test proving rng_get() enter the hardware read path. Brick device otherwise.
+- Bugfix: a compromised USB host could rewrite the staged PSBT after review but
+ before signing, so the signature covered a different transaction than shown.
+ Staged bytes are now re-verified before signing; any change aborts with
+ "Transaction modified". Thanks to FreeZ Agent for the report and PoC.
# Mk Specific Changes
### shared/auth.py
@@ -29,6 +29,26 @@
TXN_INPUT_OFFSET = 0
TXN_OUTPUT_OFFSET = MAX_TXN_LEN
+def psram_sha256(offset, length):
+ # SHA-256 over a region of PSRAM
+ # - read_at is zero-copy (a view), so no big RAM usage here
+ from glob import PSRAM
+
+ rv = sha256()
+ for pos in range(offset, offset+length, 4096):
+ rv.update(PSRAM.read_at(pos, min(4096, offset+length-pos)))
+
+ return rv.digest()
+
+def psram_wipe(offset, length):
+ # zero-out a region of PSRAM, in chunks
+ # - can zero up to 255 bytes past end: always still inside TXN staging area
+ from glob import PSRAM
+
+ z = bytes(256)
+ for pos in range(offset, offset+length, 256):
+ PSRAM.write(pos, z)
+
class UserAuthorizedAction:
active_request = None
@@ -367,6 +387,13 @@ async def interact(self):
return await self.failure(msg, exc)
+ # bind this request to the exact bytes we just parsed
+ # - they are re-read from live PSRAM during display, signing & finalization,
+ # and a USB host could rewrite them while we wait for approval
+ from glob import PSRAM
+ self.parsed_write_count = PSRAM.txn_write_count
+ self.parsed_sha = psram_sha256(self.offset, self.psbt_len)
+
dis.fullscreen("Validating...")
# Do some analysis/ validation
@@ -568,6 +595,19 @@ async def interact(self):
except:
return await self.failure("2FA Failed")
+ # the parsed bytes must be unchanged since parse/approval; covers all
+ # input methods and the HSM auto-approval path, as both end up here
+ # - fast path: nothing wrote to the TXN region since we hashed it,
+ # so there is no need to re-hash in that (common) case
+ from glob import PSRAM
+ if (PSRAM.txn_write_count != self.parsed_write_count) and \
+ (psram_sha256(self.offset, self.psbt_len) != self.parsed_sha):
+ # fail closed: wipe the txn, so no signature over modified data
+ psram_wipe(self.offset, self.psbt_len)
+ del self.psbt
+ gc.collect()
+ return await self.failure("Transaction modified")
+
# do the actual signing.
try:
dis.fullscreen('Wait...')
@@ -592,6 +632,12 @@ async def interact(self):
except BaseException as exc:
return await self.failure("Signing failed late", exc)
+ # tripwire: no writer could have run since the re-check above, as
+ # there is no await between it and signing (single-threaded asyncio),
+ # so this cannot trigger today - it fails loudly if a future change
+ # adds an await or a new TXN-region writer on this path
+ assert PSRAM.txn_write_count == self.parsed_write_count
+
try:
await done_signing(self.psbt, self, self.input_method,
self.filename, self.output_encoder,
### shared/psram.py
@@ -10,6 +10,10 @@ class PSRAMWrapper:
base = 0x9000_0000 # OCTOSPI1
length = 0x40_0000 # 4 meg (lower half)
+ # bumped on every write into the TXN input region (below MAX_TXN_LEN);
+ # used to detect post-review mutation of the transaction being signed
+ txn_write_count = 0
+
def __init__(self):
self._wr = uctypes.bytearray_at(self.base, self.length)
@@ -22,7 +26,10 @@ def write_at(self, offset, ln):
assert offset % 4 == 0, offset
assert ln % 4 == 0, ln
assert offset + ln <= self.length, (offset+ln)
-
+
+ if offset < version.MAX_TXN_LEN:
+ self.txn_write_count += 1
+
return memoryview(self._wr)[offset:offset+ln]
# Be compatible with SPIFlash class...
### shared/usb.py
@@ -807,11 +807,20 @@ async def handle_upload(self, offset, total_size, data):
from glob import dis, hsm_active
from utils import check_firmware_hdr
from sigheader import FW_HEADER_OFFSET, FW_HEADER_SIZE, FW_HEADER_MAGIC
+ from auth import UserAuthorizedAction, FirmwareUpgradeRequest, ApproveTransaction
+
+ # PSRAM transaction region must be read-only while an approval flow is using it
+ # - blocks TOCTOU attack on in-progress transaction signing
+ # - firmware upgrade approval starts only after its upload is done
+ # - a new upload may supersede a pending transaction approval (host can
+ # abandon and replace it via stxn); that case is caught by the
+ # staged-bytes digest re-check in ApproveTransaction before signing
+ UserAuthorizedAction.check_busy((FirmwareUpgradeRequest, ApproveTransaction))
+
import glob
- # any upload block repurposes the staging area, so it invalidates any
- # previous download lease - uploads always complete before a new
- # result is produced and leased
+ # any accepted upload block repurposes the staging area, so it
+ # invalidates any previous download lease
glob.ALLOWED_DOWNLOAD = None
# maintain a running SHA256 over what's received
### testing/test_sign.py
@@ -4146,4 +4146,86 @@ def hack(psbt):
assert "Change back:" not in story
end_sign(accept=True)
+
+def test_upload_during_approval(dev, fake_txn, start_sign, end_sign, cap_story,
+ need_keypress, press_cancel):
+ # the PSBT in PSRAM must be immutable from review to signing;
+ # a USB host rewriting it mid-approval must not get a signature over
+ # transaction details that were never shown (TOCTOU)
+ in_psbt = fake_txn(3, 3)
+ assert len(in_psbt) > 600
+ start_sign(in_psbt, finalize=True)
+
+ # wait for the approval screen
+ for _ in range(100):
+ title, story = cap_story()
+ if title == 'OK TO SEND?':
+ break
+ time.sleep(.1)
+ else:
+ raise pytest.fail('no approval screen')
+
+ # attacker host rewrites an aligned block mid-approval: allowed at USB
+ # layer (a new upload may supersede a pending request), but must be
+ # caught before any signature is produced
+ rv = dev.send_recv(CCProtocolPacker.upload(256, len(in_psbt), bytes(256)))
+ assert rv == 256
+
+ # user approves what was originally displayed; must not sign
+ need_keypress('y')
+ with pytest.raises(CCProtoError) as ee:
+ while True:
+ time.sleep(.1)
+ done = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
+ if done is not None:
+ break
+
+ assert 'Transaction modified' in str(ee)
+
+ # dismiss failure screen
+ title, story = cap_story()
+ assert 'Transaction modified' in story
+ press_cancel()
+
+ # normal flow still works afterwards: fresh upload + sign
+ in_psbt = fake_txn(2, 2, segwit_in=True)
+ start_sign(in_psbt, finalize=True)
+ end_sign(accept=True, finalize=True)
+
+
+def test_psbt_mutation_before_signing(dev, fake_txn, start_sign, cap_story,
+ need_keypress, sim_exec, press_cancel):
+ # second layer: even if the PSBT bytes in PSRAM are rewritten by
+ # any means after review, signing must abort before producing a signature
+ in_psbt = fake_txn(3, 3)
+ start_sign(in_psbt, finalize=True)
+
+ # wait for the approval screen
+ for _ in range(100):
+ title, story = cap_story()
+ if title == 'OK TO SEND?':
+ break
+ time.sleep(.1)
+ else:
+ raise pytest.fail('no approval screen')
+
+ # rewrite part of the PSBT in PSRAM, bypassing the USB layer entirely
+ sim_exec("from glob import PSRAM; PSRAM.write(256, bytes(100))")
+
+ # user approves what was originally displayed; must not sign
+ need_keypress('y')
+ with pytest.raises(CCProtoError) as ee:
+ while True:
+ time.sleep(.1)
+ done = dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
+ if done is not None:
+ break
+
+ assert 'Transaction modified' in str(ee)
+
+ # dismiss failure screen
+ title, story = cap_story()
+ assert 'Transaction modified' in story
+ press_cancel()
+
# EOFWhy this scored 82/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.