Skip to content

bugfix: integer underflow in PSRAM virtual-disk file import (OOB write from hostile FAT metadata) - #763

Open
Amiga500 wants to merge 5 commits into
Coldcard:masterfrom
Amiga500:amiga500-fix-psramdisk-fat-underflow
Open

bugfix: integer underflow in PSRAM virtual-disk file import (OOB write from hostile FAT metadata)#763
Amiga500 wants to merge 5 commits into
Coldcard:masterfrom
Amiga500:amiga500-fix-psramdisk-fat-underflow

Conversation

@Amiga500

@Amiga500 Amiga500 commented Aug 20, 2026

Copy link
Copy Markdown

Summary

psram_copy_file() and psram_mmap_file() (stm32/COLDCARD_MK4/psramdisk.c, shared by Q1 via symlink) trust FAT metadata of the PSRAM virtual disk, which is exposed over USB Mass Storage and therefore fully writable by a compromised USB host.

The final fragment length is computed as:

len = actual_len - so_far;   // uint32_t arithmetic

where actual_len = fp.obj.objsize (directory entry size field) and so_far is the sum of the bytes covered by previous cluster-chain fragments. FatFs' CREATE_LINKMAP follows the cluster chain to EOC without validating it against objsize, so a crafted FAT (chain covering more bytes than objsize, e.g. on a .dfu/.psbt file dropped by the host) makes so_far > actual_len and len underflows to ~4 GB. The subsequent memcpy(dest, spot, len) then writes attacker-influenced data out of bounds:

  • the pre-write check dest + actual_len + 3 >= PSRAM_TOP_BASE is insufficient and can itself wrap around for objsize near 4 GB;
  • the dest >= PSRAM_TOP_BASE check happened after the memcpy — too late.

Realistic impact: corruption of everything staged in PSRAM (PSBT/firmware staging areas) and device crash via BusFault. SRAM and flash sit below 0x90000000, so the forward-only copy does not reach them; no credible path to code execution was identified (hence moderate severity), but it is a deterministic host-triggered memory corruption reachable without user confirmation when Virtual Disk is in "Enable & Auto" mode.

Fix

  • Reject cluster chains whose coverage exceeds the file size (so_far > actual_len, and per-fragment len > remaining) before the subtraction — no more underflow.
  • Verify destination bounds with 64-bit arithmetic before each memcpy, eliminating both the post-hoc check and the wrap-around.
  • Same guard applied to psram_mmap_file(), which hands attacker-influenced (address, length) tuples to MicroPython.

Verification

Logic validated with a standalone host harness replicating the fixed loop: legit single/multi-fragment files pass; hostile chains (oversized coverage, objsize near 4 GB, degenerate zero-size) are all rejected. A full firmware build was not possible in the authoring environment (no ARM toolchain / uninitialized submodules) — a run through repro-build.sh before merge would be appreciated.

@Amiga500 Amiga500 changed the title bugfix: integer underflow in PSRAM virtual-disk file import (OOB write from hostile FAT metadata) chore: opened in error (closing) Aug 20, 2026
@Amiga500 Amiga500 closed this Aug 20, 2026
@Amiga500
Amiga500 deleted the amiga500-fix-psramdisk-fat-underflow branch August 20, 2026 06:05

@scgbckbone scgbckbone left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you planning to re-open ?

@Amiga500

Copy link
Copy Markdown
Author

I have sent an email 6 hours ago to security@coinkite.com with all info

@Amiga500

Copy link
Copy Markdown
Author

but i can reopen it right away without any problems if you give me the ok

@doc-hex

doc-hex commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Ok, to reopen this PR... it's very not hidden at this point.

This fix can be in the next release as it didn't make today's cutoff.

@Amiga500
Amiga500 restored the amiga500-fix-psramdisk-fat-underflow branch August 20, 2026 15:08
@Amiga500 Amiga500 changed the title chore: opened in error (closing) bugfix: integer underflow in PSRAM virtual-disk file import (OOB write from hostile FAT metadata) Aug 20, 2026
@Amiga500 Amiga500 reopened this Aug 20, 2026
psram_copy_file() and psram_mmap_file() trusted FAT metadata exposed over
USB Mass Storage: a cluster chain covering more bytes than the directory
entry's file size made 'len = actual_len - so_far' underflow to ~4GB,
causing an out-of-bounds memcpy across PSRAM from a compromised USB host.

- validate cluster-chain coverage against file size before each fragment
- check destination bounds with 64-bit arithmetic *before* writing
  (the old post-memcpy check was too late, and dest+len+3 could wrap)

Q1 shares this file via symlink; both platforms are fixed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Amiga500
Amiga500 force-pushed the amiga500-fix-psramdisk-fat-underflow branch from 9e7150b to 9261095 Compare August 20, 2026 15:42

@scgbckbone scgbckbone left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two hostile FAT/link-map shapes can still bypass these guards and produce out-of-bounds PSRAM reads or mappings. The fragment count must be validated before it is narrowed by block_to_ptr(), and the declared final remainder must be checked against the final fragment's physical capacity.

Comment thread stm32/COLDCARD_MK4/psramdisk.c
Comment thread stm32/COLDCARD_MK4/psramdisk.c
Two residual hostile-FAT shapes bypassed the guards:

- num_clusters (DWORD from the link map) was narrowed to uint16_t by
  block_to_ptr(); 65536 wrapped to 0 and passed the range check, mapping
  fragments outside the PSRAM window. Now validated as u32 (1..BLOCK_COUNT)
  before the call in both psram_copy_file() and psram_mmap_file().

- The declared final remainder was not checked against the final fragment's
  physical capacity: a 1024-byte directory entry backed by one 512-byte
  cluster made the final branch copy/map past the fragment. Now the fragment
  capacity is preserved and the final remainder must fit it (checked before
  the copy path's align-4).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@scgbckbone

Copy link
Copy Markdown
Collaborator

ACK cf3fe22

nit: the changelog only mentions oversized chains and OOB writes while the patch also prevents undersized final fragments and OOB reads/mappings - please update that wording and resolve the changelog conflict with master

otherwise LGTM

Review nit from scgbckbone.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@doc-hex

doc-hex commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Two findings from reviewing the current patch:

  1. Fragment capacity assumes one sector per cluster. CREATE_LINKMAP reports run lengths in clusters, but num_clusters * BLOCK_SIZE and block_to_ptr(..., num_clusters) treat the count as sectors. Because the USB host can rewrite the BPB, FatFs can mount a valid filesystem with csize > 1; such files can then be rejected or, for fragmented/overallocated layouts, copied or mapped with skipped data. Please either compute a checked sector count as num_clusters * vfs.fatfs.csize and use it for both block_to_ptr() and capacity, or explicitly reject csize != 1 after mounting.

  2. One trailing fragment is still accepted when preceding fragments exactly cover the declared size. If so_far == objsize, the final branch produces len == 0 and accepts the oversized chain; mmap() consequently returns a spurious zero-length tuple. Please reject this case when another fragment remains (including a zero-size file with an allocated chain).

Non-blocking follow-ups: the PR currently conflicts with master in Next-ChangeLog.md, and the hostile-FAT cases are only covered by an external harness rather than repository regression tests.

Copilot AI added a commit to Amiga500/firmware that referenced this pull request Aug 24, 2026
…head dec0158) into copilot branch

Brings in the reviewed patch: integer-underflow fix in psram_copy_file/
psram_mmap_file plus review fixes (fragment-count validation as u32,
final-remainder capacity check, 64-bit pre-copy bounds check) and the
updated changelog wording.

Conflicts:
- releases/Next-ChangeLog.md (upstream reshuffled sections after 5.6.1)

# Conflicts:
#	releases/Next-ChangeLog.md

Co-authored-by: Amiga500 <16525337+Amiga500@users.noreply.github.com>
Copilot AI added a commit to Amiga500/firmware that referenced this pull request Aug 24, 2026
…ing fragments

Finding 1: CREATE_LINKMAP reports run lengths in clusters, but the code
treats them as 512-byte sectors; a hostile host can rewrite the BPB so
FatFs mounts a filesystem with csize > 1, breaking block_to_ptr() and
capacity math. Reject vfs.fatfs.csize != 1 right after f_mount() in both
psram_mmap_file() and psram_copy_file() (the explicit-reject option).

Finding 2: when preceding fragments exactly cover objsize, the final
branch computed len == 0 and accepted the oversized chain, making
mmap() return a spurious zero-length tuple. Now reject a final fragment
whose remainder is < 1 (covers zero-size files with an allocated chain).

Also merged PR Coldcard#763 head (dec0158) into this branch (changelog conflict
resolved, keeping both entries) and updated the changelog wording.

Validated with a python logic harness and a gcc-compiled C harness
replicating both fixed loops: legit shapes pass, all hostile FAT shapes
(csize>1, u16 wrap, oversized chains, trailing fragments, edge clusters)
are rejected.

Co-authored-by: Amiga500 <16525337+Amiga500@users.noreply.github.com>
@Amiga500

Amiga500 commented Aug 24, 2026

Copy link
Copy Markdown
Author

Thanks for the careful re-review. Both findings addressed in 457acd8:

  1. Sectors vs. clusters (csize > 1) — I went with the explicit-reject option: right after f_mount(), both psram_mmap_file() and psram_copy_file() now refuse any mounted filesystem with vfs.fatfs.csize != 1 (ValueError("bad clus size")). I preferred this over computing a checked sector count (num_clusters * csize) because the whole downstream math — clst2sect()'s scaling, block_to_ptr()'s sector range check, and the capacity bookkeeping — is built around 512-byte sectors; pinning csize == 1 keeps one invariant instead of threading a second unit through every calculation. A host-rewritten BPB now just makes the file unreadable instead of mis-mapped.

  2. Trailing fragment after exact coverage — the final branch now rejects len < 1 as well as len > capacity:

len = fp.obj.objsize - so_far;
if((len < 1) || (len > capacity)) {
mp_raise_ValueError(MP_ERROR_TEXT("bad file size"));
}

So when preceding fragments already cover objsize exactly (so_far == objsize), a remaining fragment is rejected as oversized-chain metadata instead of producing a spurious zero-length tuple from mmap(). This also covers the zero-size-file-with-allocated-chain case you mentioned (objsize == 0 with any link map).

Follow-ups:

The Next-ChangeLog.md conflict with master is resolved in the current head (both entries kept), and the entry now also mentions spurious trailing fragments and multi-sector clusters.
On the regression tests: agreed that repo-side coverage would be better than my external harness. The catch is these paths run on bare-metal PSRAM/FAT code with no simulator hook today, so a pytest-style regression would need either a host-compiled harness of psramdisk.c or new simulator plumbing. Happy to add the host-harness variant under testing/ if you think that's worth the build plumbing — let me know your preference.

Re-validated with the standalone harness extended for the new shapes: legit single/multi-fragment files (including normal over-allocation of the final cluster) pass; hostile shapes (csize 2/4, num_clusters u16 wrap, oversized chains, exact-coverage trailing fragment, zero-size file with chain, clusters past end of disk) are all rejected. CodeQL is clean on the diff.

@doc-hex

doc-hex commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Move commit 457acd8 onto the branch backing the PR. The fixes currently exist only on copilot/amiga500-fix-psramdisk-fat-underflow, while PR 763 tracks amiga500-fix-psramdisk-fat-underflow.

Copilot AI added a commit to Amiga500/firmware that referenced this pull request Aug 25, 2026
…ing fragments

Finding 1: CREATE_LINKMAP reports run lengths in clusters, but the code
treats them as 512-byte sectors; a hostile host can rewrite the BPB so
FatFs mounts a filesystem with csize > 1, breaking block_to_ptr() and
capacity math. Reject vfs.fatfs.csize != 1 right after f_mount() in both
psram_mmap_file() and psram_copy_file() (the explicit-reject option).

Finding 2: when preceding fragments exactly cover objsize, the final
branch computed len == 0 and accepted the oversized chain, making
mmap() return a spurious zero-length tuple. Now reject a final fragment
whose remainder is < 1 (covers zero-size files with an allocated chain).

Also merged PR Coldcard#763 head (dec0158) into this branch (changelog conflict
resolved, keeping both entries) and updated the changelog wording.

Validated with a python logic harness and a gcc-compiled C harness
replicating both fixed loops: legit shapes pass, all hostile FAT shapes
(csize>1, u16 wrap, oversized chains, trailing fragments, edge clusters)
are rejected.

Co-authored-by: Amiga500 <16525337+Amiga500@users.noreply.github.com>
…ing fragments

Finding 1: CREATE_LINKMAP reports run lengths in clusters, but the code
treats them as 512-byte sectors; a hostile host can rewrite the BPB so
FatFs mounts a filesystem with csize > 1, breaking block_to_ptr() and
capacity math. Reject vfs.fatfs.csize != 1 right after f_mount() in both
psram_mmap_file() and psram_copy_file() (the explicit-reject option).

Finding 2: when preceding fragments exactly cover objsize, the final
branch computed len == 0 and accepted the oversized chain, making
mmap() return a spurious zero-length tuple. Now reject a final fragment
whose remainder is < 1 (covers zero-size files with an allocated chain).

Also merged PR Coldcard#763 head (dec0158) into this branch (changelog conflict
resolved, keeping both entries) and updated the changelog wording.

Validated with a python logic harness and a gcc-compiled C harness
replicating both fixed loops: legit shapes pass, all hostile FAT shapes
(csize>1, u16 wrap, oversized chains, trailing fragments, edge clusters)
are rejected.

Co-authored-by: Amiga500 <16525337+Amiga500@users.noreply.github.com>
@Amiga500

Copy link
Copy Markdown
Author

@doc-hex Done — the fixes from 457acd8 are now on the PR branch as be54bf8 (cherry-picked onto the PR head; tree verified identical). Ready for re-review.

Comment thread releases/Next-ChangeLog.md Outdated
# Shared Improvements - Both Mk and Q

- tbd
- Bugfix: Simulator crashed on Bless Firmware, due to a desynced LED pipe. Thanks to

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove

Amiga500 added a commit to Amiga500/firmware that referenced this pull request Aug 26, 2026
Latest review comment by scgbckbone on Coldcard#763: "remove"
(line 7 of releases/Next-ChangeLog.md, the View TRNG Words entry).

Note: PR 763's branch (amiga500-fix-psramdisk-fat-underflow) does not
contain this entry; direct push to it is blocked by a repository rule,
so this records the removal on the session branch.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Removed a bugfix note about simulator crash on Bless Firmware.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants