bugfix: bind firmware upgrade approval to staged image
What changed, and why it matters
This commit fixes a security bug in the COLDCARD hardware wallet's firmware update process. Previously, while the user was looking at the approval screen for one firmware file, a second file could be silently uploaded and replace the first one. If the user approved, the second (unreviewed) firmware would be installed instead of the one they saw on screen. The fix records a fingerprint of the staged firmware at approval time and checks it again right before flashing, aborting if anything changed.
Apply this patch promptly; it closes a real firmware-integrity TOCTOU. Users should ensure their COLDCARD firmware includes this fix before performing future upgrades. No additional action is indicated by the commit materials.
Security signals we found
Time-of-check/time-of-use (TOCTOU) between consent screen and flash operation
Missing integrity re-verification before destructive firmware write
Authorization bypass via concurrent/reordered request handling
Regression test demonstrates staged-image replacement attack
Fix mirrors existing staged-bytes guard in ApproveTransaction
Evidence from the diff
FirmwareUpgradeRequest captured the firmware header at upload time for display, but passed a live PSRAM window to the flash operation. Because check_busy allow-listed FirmwareUpgradeRequest, a second raw upload (without the legacy trailer that triggers authorize_upgrade) could overwrite the staged bytes while the consent screen was displayed. The patch adds a staged_sha digest at approval time and an assert re-verifying the digest immediately before pa.firmware_upgrade(), failing closed on mismatch. A regression test uploads image A, displays approval, uploads raw image B, approves, and confirms the upgrade aborts.
Changed components
shared/auth.py: FirmwareUpgradeRequest.interact()testing/test_upgrades.pyInspect captured patch +37 / −0
### shared/auth.py
@@ -1585,6 +1585,10 @@ async def interact(self):
self.pop_menu()
return
+ # bind this request to the exact bytes staged in PSRAM right now;
+ # a second upload may land while the consent screen is up
+ self.staged_sha = psram_sha256(self.psram_offset, self.length)
+
# Get informed consent to upgrade.
date, version, _ = decode_firmware_header(self.hdr)
@@ -1601,6 +1605,9 @@ async def interact(self):
ch = await ux_show_story(msg)
if ch == 'y':
+ # re-verify the staged bytes are unchanged since approval
+ assert psram_sha256(self.psram_offset, self.length) == self.staged_sha
+
# Accepted:
# - write final file header, so bootloader will see it
# - reboot to start process
### testing/test_upgrades.py
@@ -136,4 +136,34 @@ def test_hacky_upgrade(mode, cap_story, transport, dev, sim_exec, make_firmware,
# assert a == data[pos:pos+128], repr(pos)
+def test_upgrade_staged_image_tamper(dev, make_firmware, upload_file, cap_story,
+ need_keypress, sim_exec, is_q1, is_mark5):
+ # a second upload may land while the upgrade approval is on screen
+ # (check_busy allow-lists FirmwareUpgradeRequest); the staged bytes
+ # must be re-verified before flashing, not just the header snapshot
+ hw = "q1" if is_q1 else (5 if is_mark5 else 4)
+ data_a = make_firmware(hw)
+ hdr_a = data_a[FW_HEADER_OFFSET:FW_HEADER_OFFSET+FW_HEADER_SIZE]
+
+ # upload image A with trailer -> fires authorize_upgrade
+ upload_file(data_a + hdr_a)
+ _, story = cap_story()
+ assert "Install this new firmware?" in story
+
+ # upload image B as a raw image (no trailer) -> no re-auth, but
+ # overwrites the staging area via PSRAM.write
+ data_b = make_firmware(hw, outname='tmp-firmware-b.bin')
+ assert len(data_b) == len(data_a)
+ upload_file(data_b)
+
+ # approve what was displayed (image A); the pre-flash assert fires,
+ # caught by interact()'s except -> self.failed, cleanup, pop_menu
+ need_keypress('y')
+ time.sleep(1)
+ # must not have upgraded: request done and cleaned up, no reboot
+ rv = sim_exec("from auth import UserAuthorizedAction; "
+ "print(UserAuthorizedAction.active_request is None)")
+ assert 'True' in rv
+
+
# EOFWhy 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.