Skip to content

bbqr: reject part sizes that would return unwritten bytes - #718

Open
drk1wi wants to merge 1 commit into
Coldcard:masterfrom
drk1wi:fix/bbqr-uninit-read
Open

bbqr: reject part sizes that would return unwritten bytes#718
drk1wi wants to merge 1 commit into
Coldcard:masterfrom
drk1wi:fix/bbqr-uninit-read

Conversation

@drk1wi

@drk1wi drk1wi commented Aug 12, 2026

Copy link
Copy Markdown

BBQrStorage.save_packet() (shared/bbqr.py:271-282) sizes the decoded result from
the geometry the QR series declares:

        if which == hdr.num_parts-1:
            # size of runt determines final complete size
            self.runt_size = len(data)
            self.final_size = (blksize * (hdr.num_parts-1)) + self.runt_size

but write_pkt() only ever writes len(data) bytes. Nothing requires a non-final
part to be blksize long, nor runt_size to be <= blksize, and blksize is
whatever the first part to arrive happens to carry (:232-233). Any shortfall is
counted into final_size and never written.

BBQrPsramStorage.get_buffer() (:437-440) then returns
PSRAM.read_at(0, self.final_size), and psram.py:16-18 has no initialisation
check. PSRAM[0..2MB) is the PSBT staging area and is not wiped at boot, so the
uncounted bytes are whatever the previous scan or export left there. A 16-part series
carrying 43 attacker bytes came back as 239, and decode_qr_result classified the
result as text and routed it onward.

Fix: enforce in save_packet() what collect() (:228) already documents as a
required assumption, that every part is blksize except the final one which may be
short, and refuse in _finalize() to complete a series with a part missing.

Two things in the same file are deliberately left alone. BBQrStorage.get_buffer()
returns the whole buffer rather than buf[:final_size], which nothing in tree reaches
because scanner.py:251 uses the PSRAM subclass and that overrides get_buffer()
with a bounded read. BBQrPsramStorage does not override reset(), so a frags entry
from an abandoned series survives a header change and gets flushed into the next
series by _finalize(). That one is reachable and is a separate defect.

The new checks raise AssertionError out of collect(). ux_q1.py:666,
actions.py:1451 and tapsigner.py:54 do not catch it, where the neighbouring idiom
for this kind of rejection is raise QRDecodeExplained (bbqr.py:198).


Reported privately to security@coinkite.com and opened here as agreed.

save_packet() sized the result from the declared geometry but write_pkt() only
wrote len(data) bytes per part, so a series of short parts made final_size
count bytes that were never written. For BBQrPsramStorage those come back out
of PSRAM, which still holds whatever the previous scan left there. Enforce what
collect() already documents: every part is blksize, except the final one.
@doc-hex

doc-hex commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Looking good, add to Next-ChangeLog.md

@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.

Thanks for the report and the original patch — the diagnosis and test scenarios are yours. #726 is a hardened successor; strictly better in three ways:

  • No new DoS: the bare asserts escape into flows that only catch QRDecodeExplained (seed entry, XPRV import, TAPSIGNER) and land in die_with_debug — one crafted QR series = fatal screen + logout. #726 raises a QRDecodeExplained subclass and recovers in collect() instead.
  • No conflict with repeated-frame handling: completeness is checked in finalize(), not _finalize() — the frag flush stays callable mid-scan on incomplete series.
  • Also fixes the stale-frags bug your body disclosed and deferred (abandoned-series fragments flushed into the next decode), with a regression test.

Same geometry enforcement, same credit — you're thanked in the ChangeLog. Curious what you think.

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.

3 participants