bugfix: restrict PSRAM download (dwld) to leased results
What changed, and why it matters
This update fixes a bug in the COLDCARD hardware wallet's USB download command. Previously, a connected computer could ask the COLDCARD to read back almost anything it had temporarily stored in its extra memory chip (PSRAM), including uploaded transaction files or multisig setup files, even across different USB sessions and without encryption. Now the device only allows downloading the single most recent result it explicitly produced for the user (such as a signed transaction or backup), only over an encrypted session, and only until a new upload or session resets the permission.
Treat this as a security fix and include it in the next firmware release. Users should upgrade when available. Developers should review whether any other PSRAM consumers or download paths exist that do not set/reset ALLOWED_DOWNLOAD, and consider whether the lease should also be cleared on other state-reset events.
Security signals we found
Arbitrary PSRAM read primitive over USB
Cross-session data exposure
Plaintext-link data exposure
Information disclosure of staged PSBT/multisig files
Bugfix explicitly described as security-relevant in changelog
Independent researcher credited
Evidence from the diff
The USB dwld handler previously permitted arbitrary readback of PSRAM file_number 0/1 regions without tying access to a freshly produced, intentionally downloadable result. The patch introduces a global ALLOWED_DOWNLOAD lease tuple (file_number, offset, length) set only when the device produces a signed transaction, visualization, or backup. handle_download now requires an encrypted session, a non-None lease, matching file_number, and in-bounds offset/length. Leases are invalidated on new encrypted session setup, any upload, and staging of a new transaction. Tests confirm uploaded PSBTs and out-of-bounds reads are rejected.
Changed components
shared/usb.py: USBProtocol.handle_download / handle_upload / handle_crypto_setupshared/auth.py: transaction signing, visualization, backup result leasingshared/glob.py: ALLOWED_DOWNLOAD global lease stateInspect captured patch +140 / −4
### releases/Next-ChangeLog.md
@@ -17,6 +17,12 @@ This lists the new changes that have not yet been published in a normal release.
- Bugfix: Prevent valid message signatures when using a Delta Mode PIN.
- Bugfix: Harden callgate buffer validation against integer overflow and out-of-range access,
following a finding in the [Karma-X security review](https://karma-x.io/blog/post/75/).
+- Bugfix: USB `dwld` allowed readback of arbitrary staged PSRAM content (uploaded
+ PSBT, multisig enroll file), also across sessions and over plaintext links.
+ Downloads are now limited to the single most recent result produced for
+ download (signed txn, visualization, backup), require an encrypted session,
+ and are invalidated by any upload, newly staged transaction, or new session.
+ Thanks to [@drk1wi](https://github.com/drk1wi).
# Mk Specific Changes
### shared/auth.py
@@ -3,7 +3,7 @@
# Operations that require user authorization, like our core features: signing messages
# and signing bitcoin transactions.
#
-import stash, ure, chains, sys, gc, uio, version, ngu, ujson
+import stash, ure, chains, sys, gc, uio, version, ngu, ujson, glob
from ubinascii import b2a_base64, a2b_base64
from ubinascii import hexlify as b2a_hex
from ubinascii import unhexlify as a2b_hex
@@ -276,6 +276,10 @@ def __init__(self, psbt_len, flags=None, psbt_sha=None, input_method=None,
self.offset = offset
self.psbt_len = psbt_len
+ # a new transaction was just staged into the input region of PSRAM,
+ # so any previous download lease is now dangling
+ glob.ALLOWED_DOWNLOAD = None
+
# do finalize is None if not USB, None = decide based on is_complete
if flags is None:
self.do_finalize = self.do_visualize = None
@@ -626,7 +630,12 @@ async def save_visualization(self, msg, sign_text=False):
fd.write(b2a_base64(sig).decode('ascii').strip())
fd.write('\n')
- return fd.tell(), fd.checksum.digest()
+ rv = fd.tell(), fd.checksum.digest()
+
+ # lease the result region for download (dwld)
+ glob.ALLOWED_DOWNLOAD = (1, 0, rv[0])
+
+ return rv
def output_summary_text(self, msg):
# Produce text report of where their cash is going. This is what
@@ -791,6 +800,8 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
if input_method == "usb":
# return result over USB before going to all options
tx_req.result = data_len, data_sha2
+ # lease the result region for download (dwld)
+ glob.ALLOWED_DOWNLOAD = (1, 0, data_len)
if hsm_active:
# it is enough to just return back via USB, other options
# are pointless
@@ -1110,6 +1121,8 @@ async def interact(self):
if r:
# expect (length, sha)
self.result = r
+ # backup image sits at start of PSRAM; lease it for download (dwld)
+ glob.ALLOWED_DOWNLOAD = (0, 0, r[0])
else:
self.refused = True
### shared/glob.py
@@ -29,4 +29,9 @@
# QR scanner (Q1 only)
SCAN = None
+# the only region of PSRAM the USB host may download (dwld) right now:
+# tuple (file_number, offset, length), set by producers of downloadable
+# results only; None blocks all downloads
+ALLOWED_DOWNLOAD = None
+
# EOF
### shared/usb.py
@@ -711,6 +711,10 @@ def handle_crypto_setup(self, version, his_pubkey):
if version == 0x2:
self.bound = True
+ # new session: any download lease from a previous session is void
+ import glob
+ glob.ALLOWED_DOWNLOAD = None
+
# pick a random key pair, just for this session
pair = ngu.secp256k1.keypair()
my_pubkey = pair.pubkey().to_bytes(True) # un-compressed
@@ -757,6 +761,10 @@ async def handle_mitm_check(self):
async def handle_download(self, offset, length, file_number):
# let them read from where we store the signed txn
# - filenumber can be 0 or 1: uploaded txn, or result
+ # - but only the single most recent result explicitly produced for
+ # download (a lease); arbitrary readback of whatever was last
+ # staged in PSRAM (eg. uploaded PSBT, multisig enroll file)
+ # is not allowed
# limiting memory use here, should be MAX_BLK_LEN really
length = min(length, MAX_BLK_LEN)
@@ -766,6 +774,17 @@ async def handle_download(self, offset, length, file_number):
assert offset + length <= MAX_TXN_LEN, "bad offset"
assert 1 <= length, 'len'
+ # a plaintext link may not read PSRAM at all
+ assert self.encrypted_req, 'must encrypt'
+
+ import glob
+ ad = glob.ALLOWED_DOWNLOAD
+ assert ad, 'not allowed'
+ fn, start, size = ad
+ assert file_number == fn, 'not allowed: file no'
+ assert start <= offset and (offset + length) <= (start + size), \
+ 'not allowed: out of bounds'
+
# maintain a running SHA256 over what's sent
if offset == 0:
self.file_checksum = sha256()
@@ -788,6 +807,12 @@ 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
+ 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
+ glob.ALLOWED_DOWNLOAD = None
# maintain a running SHA256 over what's received
if offset == 0:
### testing/test_hsm.py
@@ -1780,4 +1780,27 @@ def test_rmur_ul_exceeds_payload(dev):
dev.send_recv(msg, encrypt=False)
assert 'badlen' in str(e.value)
+def test_hsm_sign_download_lease(dev, quick_start_hsm, fake_txn, load_hsm_users,
+ auth_user, start_sign):
+ # HSM-mode signed result must be downloadable via the dwld lease (file 1),
+ # while the uploaded input must never be downloadable (file 0)
+ policy = DICT(warnings_ok=True, rules=[dict(users=['pw'])])
+ load_hsm_users()
+ quick_start_hsm(policy)
+
+ psbt = fake_txn(1, 2, dev.master_xpub, change_outputs=[0], segwit_in=True)
+ auth_user.psbt_hash = sha256(psbt).digest()
+ auth_user("pw")
+ start_sign(psbt)
+ resp_len, chk = wait_til_signed(dev)
+
+ # lease covers the signed result
+ out = dev.download_file(resp_len, chk)
+ assert len(out) == resp_len
+
+ # uploaded input must not be downloadable
+ with pytest.raises(CCProtoError) as e:
+ dev.send_recv(CCProtocolPacker.download(0, 256, 0))
+ assert 'not allowed' in str(e.value)
+
# EOF
### testing/test_usb.py
@@ -219,8 +219,72 @@ def test_remote_up_download(f_len, dev, mk_num):
ll, sha = dev.upload_file(data, verify=True)
assert ll == len(data) == f_len
- rb = dev.download_file(ll, sha, file_number=0)
- assert rb == data
+ # arbitrary readback of uploaded content is not allowed;
+ # only results explicitly produced for download can be fetched
+ with pytest.raises(CCProtoError) as e:
+ dev.download_file(ll, sha, file_number=0)
+ assert 'not allowed' in str(e.value)
+
+
+def test_download_lease(dev, fake_txn, start_sign, end_sign):
+ # a malicious USB host must not be able to re-download content that was
+ # previously staged in PSRAM (uploaded PSBT, multisig enroll file, ...);
+ # only the single most recent result produced for download is readable
+ data = os.urandom(1024)
+ dev.upload_file(data)
+
+ # nothing produced for download yet: all reads blocked
+ for file_no in (0, 1):
+ with pytest.raises(CCProtoError) as e:
+ dev.send_recv(CCProtocolPacker.download(0, 256, file_no))
+ assert 'not allowed' in str(e.value)
+
+ # sign: signed result (file 1) becomes downloadable (end_sign fetches it)
+ in_psbt = fake_txn(1, 2, segwit_in=True)
+ start_sign(in_psbt, finalize=False)
+ signed = end_sign(accept=True, finalize=False)
+
+ # uploaded input (file 0) must not be downloadable
+ with pytest.raises(CCProtoError) as e:
+ dev.send_recv(CCProtocolPacker.download(0, 256, 0))
+ assert 'not allowed' in str(e.value)
+
+ # reads past the end of the produced result are blocked
+ with pytest.raises(CCProtoError) as e:
+ dev.send_recv(CCProtocolPacker.download(len(signed), 1, 1))
+ assert 'not allowed' in str(e.value)
+
+ # in-bounds partial re-read of the result still works
+ part = dev.send_recv(CCProtocolPacker.download(0, 256, 1))
+ assert part == signed[:256]
+
+ # a plaintext (unencrypted) link may not read PSRAM, even with a lease
+ msg = struct.pack('<4sIII', b'dwld', 0, 256, 1)
+ with pytest.raises(CCProtoError) as e:
+ dev.send_recv(msg, encrypt=False)
+ assert 'must encrypt' in str(e.value)
+
+ # a new encrypted session voids the previous session's lease
+ dev.start_encryption()
+ with pytest.raises(CCProtoError) as e:
+ dev.send_recv(CCProtocolPacker.download(0, 256, 1))
+ assert 'not allowed' in str(e.value)
+
+ # a new upload clears the lease: result no longer downloadable
+ dev.upload_file(b'next')
+ with pytest.raises(CCProtoError) as e:
+ dev.send_recv(CCProtocolPacker.download(0, 256, 1))
+ assert 'not allowed' in str(e.value)
+
+ # same for a partial re-upload at a nonzero offset (no offset-zero block)
+ in_psbt = fake_txn(1, 2, segwit_in=True)
+ start_sign(in_psbt, finalize=False)
+ end_sign(accept=True, finalize=False)
+ rv = dev.send_recv(CCProtocolPacker.upload(256, 1024, bytes(256)))
+ assert rv == 256
+ with pytest.raises(CCProtoError) as e:
+ dev.send_recv(CCProtocolPacker.download(0, 256, 1))
+ assert 'not allowed' in str(e.value)
def test_dwld_offset_at_max(dev, mk_num):Why this scored 76/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.