Repository navigation
WYSIWYS break on the pixel route: the ERC-7730 adapter drops field values that collide with the navigation vocabulary #751
Description
Found by GPT-6 Astra in the 2026-09-24 ui-px merge review (FINDING 5), where it was flagged as not independently verified. It is now confirmed end-to-end with the shipped descriptor corpus.
Record: docs/security/adversarial-review/findings/ui-px-merge-2026-09-24.md.
The defect
secure/src/tx/display/erc7730_screens.rs classifies a page row as navigation by matching its text:
pub(crate) fn is_nav_row(row: &[u8]) -> bool {
row.starts_with(b"> ")
|| row.ends_with(b"> next")
|| row.ends_with(b"> sign")
|| matches!(row, b"L=Cancel" | b"R=Confirm" | b"Long-press:"
| b"Long-press to" | b"L cancel/R sign"
| b"Long-right" | b"to confirm")
}The field loop at :333 drops every match. The proven page keeps the row, and the signature commits to it. So a rendered value that collides with that vocabulary is a row the user signs but never sees.
The vocabulary is not reserved — nothing stops a decoded operand from landing in it.
Reachable today, with the shipped corpus
calldata-celo_accounts.json (Celo Accounts, chain 42220, 0x7d21685C17607338b313a7174bAb6620baD0aaB7) admits:
"setName(string name)": { "intent": "Set Account Name",
"fields": [ { "path": "name", "label": "Name", "format": "raw", "visible": "always" } ] }format: "raw" puts the attacker-chosen string straight onto a row. setName("> Alice") renders:
NAME
7 bytes
The name is gone.
setName("> Alice") and setName("> Carol") differ on the trusted display in exactly one place: the ERC-8213 32-byte calldata digest. Measured, by diffing the two transcripts:
-0xae284c3e3e2d0a4a1f5032b0ca416b78e50f09f2b5cc00bd08981918faef54a6
+0x88a23209505ecf8de0964aa0db846733e6afdc189a289cfa3ec8513bfc5707b3
Everything else is byte-identical. So the only thing separating the two on the device is a hash the user would have to recompute off-device — blind signing with extra steps, inside the flow whose whole purpose is that it is not.
Wider than the chevron
All four of these field values are signed but never displayed (measured):
"vote > next", "ok > sign", "R=Confirm", "to confirm"
Why nothing caught it
The render-faithfulness differential checks every hex run >= 8 and every decimal run >= 2 of the legacy pages against the screen text. "> Alice" is neither, so alphabetic content is unchecked.
Second consequence of the same textual test: is_confirm_page is "every row empty or nav", so a page whose only content row is "> Alice" classifies as a confirm page.
Tests (on merge/ui-px-evt, secure/src/display_under_test/structured_screens_render_pure_tests.rs)
Passing, and they stay passing:
control_an_ordinary_field_value_reaches_the_pixel_transcript— the hand-built page shape lifts, so the probes below isolate the value's textcontrol_celo_set_name_reaches_the_pixel_transcript—setName("Alice")renders end-to-end through the real descriptor, decoder and renderermeasured_two_chevron_names_differ_only_by_the_erc8213_digest— characterisation: asserts the broken behaviour, so the finding is pinned before the fix. Invert it when this is closed.
#[ignore]d, failing until fixed:
a_data_row_beginning_with_a_chevron_is_dropped_from_the_pixel_transcript(adapter)the_whole_nav_vocabulary_swallows_a_field_value(the full class)celo_set_name_with_a_chevron_value_reaches_the_transcript(end-to-end)two_different_celo_names_render_identically
Toward a fix
Every chevron-prefixed nav row in the tree is written to row index 3 (erc20_known.rs, erc20_unknown.rs, value_transfer.rs, erc8213.rs, forced_blind.rs — all pages.buf[p][3]), so position discriminates better than text. But position alone is not a complete fix: the confirm-page vocabulary spans several rows, and a body page's row 3 can hold data.
Proposed, in order of durability:
- Structural. The renderer knows which rows it emitted as navigation; carry that out of the page painter instead of re-deriving it by pattern match downstream.
- Fail closed. Until then, a body row that matches the nav vocabulary outside the positions the renderer uses should refuse, not silently drop — matching the codebase's "hard refuse rather than render less" discipline.
- Widen the differential from hex/decimal runs to all non-nav text, which catches this whole class rather than this instance.
Caveat
The harness renders the leaf with the ! DEV BUILD / Unattested descriptor banner, because synth_bundle synthesises the attestation. The field-rendering path is the same one an attested descriptor takes — the control test proves an ordinary value renders through it — but reachability on a production-attested corpus should be confirmed before severity is finalised.
Activity
docs(review): FINDING 5 confirmed end-to-end; wire both records to th…
Nicola-Ceornea commentedon Sep 28, 2026
Severity corrected: critical -> high. I over-rated this when filing it, on the day I confirmed it.
What is genuinely established: the adapter decides what to display by pattern-matching the text it is displaying, and a field value that collides with the navigation vocabulary is silently dropped. That is a real unsoundness in the trusted display and the fix stands.
What I let the framing imply and should not have: that this is an exploitable path to loss today. It is not. The one reachable instance is
setName(string)on Celo Accounts — the user still signs exactly the call the descriptor describes; they just cannot read one field. Setting your own Celo account name to an unreadable string is close to harmless on its own.The reason to fix it is the class, not this instance. The vocabulary is not reserved, so every descriptor admitted from here on is another chance for a value to land in it — and ERC-7730 coverage is expanding deliberately.
addStorageRoot(bytes url)andsetMetadataURL(string)on this same contract are alreadyformat: "raw". A silent drop is the worst possible failure mode for a trusted display, whatever the field happens to be.No evidence changed; only my rating of it.
test(ui-px): #751 — the pixel adapter drops field values it mistakes …
fix(ui-px): #751 — a field value is never mistaken for the renderer's…
merge: ui-px pixel trusted UI into feat/pq1-board-target
test(ui-px): re-bless three screen goldens that were never green
fix: three findings from the GPT-6 Astra review of today's work
Nicola-Ceornea commentedon Sep 28, 2026
Surviving WYSIWYS collision in the same class — whitespace, not vocabulary
GPT-6 Astra, reviewing the fix in
1f2a57e1, found the adapter still maps two different signed strings to the same displayed field. Verified in source.erc7730_screens.rs:73:Concrete case, on the same admitted Celo leaf my fix was tested against:
setName(" Alice")NAME/Alice/6 bytessetName("Alice ")NAME/Alice/6 bytesBoth are six bytes, the descriptor admits a raw string (
calldata-celo_accounts.json:303), and the renderer preserves the bytes and appends the length (formatters.rs:2618). The adapter then trims both ends at:355. The ERC-8213 calldata digest differs, but that is the opaque fallback — the clear-signed field is identical, which is the property this issue is about.My fix addressed the vocabulary collision and left the normalisation one. The residual I recorded ("a value rendering to exactly
> nextat row 3") is also narrower than the truth: whitespace-padded variants normalise to> nextbefore the row-3 exemption is applied.Astra's mitigating finding, which I am recording because it bounds the severity: it did not establish an attacker-controlled admitted string reaching row 3. The dynamic-string painter uses rows 1–2 and puts the length on row 3; enum labels can occupy all three rows but their text is descriptor-controlled (
formatters.rs:3082).It also notes that re-rendering twice cannot catch this: a deterministic omission reproduces identically both times, and the binding check at
px_lift.rs:204/:262establishes the same page buffer and range, not a lossless projection into pixels.Why this is not a quick fix
Same root cause as the original: the adapter reconstructs meaning from rendered text. Preserving leading whitespace naively would change every existing transcript, and the trim exists because the legacy painter pads to 16 columns. The sound fix is the structural one already named in this issue — the renderer marks which rows it emitted as navigation and which are values, so the adapter never re-derives either from the bytes it is displaying. Escaping meaningful whitespace visibly is the smaller interim step.
Not attempted here. Re-opening this as the remaining work rather than closing the issue on the vocabulary half.
fix(ui-px): #751 second class — whitespace normalisation merged two o…
Nicola-Ceornea commentedon Sep 28, 2026
Whitespace class fixed —
126d76aaThe two jobs
trimmed()was doing at once are now separated:and in the field loop:
So a space-padded
" > next"still classifies as chrome and refuses — the vocabulary fix is not weakened — while" Alice"now reaches the screen as" Alice".No golden moved. That is the useful signal: nothing in the shipped corpus carries leading whitespace in a value row, so this is a no-op for every existing flow and a change only for the colliding case.
New test
two_celo_names_differing_only_in_whitespace_render_differentlydrives Astra's exact pair through the real descriptor, decoder and renderer, and asserts both that the transcripts differ and that the leading space survives into the rendered line.Verified: host suite 2715 pass / 0 fail, stm32u585 image builds, ui-px frame goldens 7/7 unchanged.
Two limits, stated rather than implied
"Alice "vs"Alice"is still unrecoverable here. The legacy painter left-aligns into a 16-column row and pads with spaces, so those produce byte-identical rows. What separates them is the byte-length row the renderer already emits (6 bytesvs5 bytes). Astra's pair is fixed because both are six bytes and differ in leading position.What keeps this issue open
Both classes found so far — vocabulary and normalisation — are the same root cause: the adapter reconstructs meaning from rendered text. Each fix has been a patch on one way that inference fails, and Astra's review found the second within hours of the first. The structural fix is still the real answer: the renderer knows which rows it emitted as navigation and which are values, and should say so rather than leave the adapter to infer it from the bytes it is displaying.
fix(erc7730): an intent of exactly 16 chars glued the owner into the …
feat(erc7730): the renderer declares its chrome; the display stops gu…
Nicola-Ceornea commentedon Sep 28, 2026
Regression from the structural fix, found and fixed —
a60121f3The chrome mask landed in
4d61878aand broke batch ERC-7730 signing. Recording it here because the mechanism is a direct consequence of the fix's design, and because my stated failure-direction argument was wrong.What broke
wrap_pages_with_batch_bannercopies a batch member's pages one slot down, behind theBATCH SIGN | Tx i of Nanchor. It copiedbufrow by row and knew nothing about the newnavmask, so the wrapped copy arrived with an all-zero mask.erc7730_screens::is_confirm_page_atreads that mask, stopped recognising the body's confirm page,emitreturnedErr,px_liftreported"px body", and the handler refused the sign.Caught by
make e2e-px, scenario 5e-7730 (batch ERC-7730 trailer matches + signs):NscStatus0xFFFFFFFF instead of 0, screenid=NOTICE lab="SIGN REFUSED".The argument I got wrong
4d61878a's message claimed the failure direction was safe:That holds for a field row. It does not hold for the confirm page:
is_confirm_page_atrequires the bits to be set, so a lost mask is a refusal to sign, not a cosmetic artifact. The mask is transcript state, not metadata about it, and every copy of the page bytes has to carry it.Fix
Three changes in the copy path (
secure/src/tx/display/batch.rs):out.nav[..new_len]with the page bytes, so no stale bit from a previous member survives into this one;inner.nav[i]→out.nav[i + 1]alongside the rows;batch_banner_copy_proofto the mask, includingnav[0] == 0for the banner, so a skipped or partial mask copy fails the same FI proof as a dropped row.Why no host test caught it
Every existing lift test hands
Body::Erc7730an unwrapped transcript. New testthe_chrome_mask_survives_the_batch_banner_copywraps one and asserts the mask shifts with the bytes and the confirm page is still recognised. Negative control: deleting the mask-copy line alone turns it red (page 1 lost its chrome mask, 8 vs 0).Evidence
c7f56998make e2e-pxPlus: secure 2717 pass,
pqsigner-erc7730368,pqsigner-ui-px95 + 7 goldens, transcripts re-exported byte-identical,ui-pxstm32u585 image builds.Correction to an earlier claim on this issue's work
I reported a bench run on
002F0023as showing the display changes were "byte-identical on hardware". That run usedmake test-key-speed, which buildsui-semihostingwithoutui-px, andpx_route_confirmis#[cfg(feature = "ui-px")]. No pixel route executed, so the 553/553 frames were legacy 16×4 frames and said nothing about this adapter. The #759 progress-monotonicity result from that run stands (it is on the feature-independent crypto path).fix(erc7730): the renderer declares how far the intent runs — 95d7831 …
Nicola-Ceornea commentedon Sep 30, 2026
Fixed — all three collision classes, structurally
The root cause this issue identified was correct: the adapter decided what to display by pattern-matching the text it was displaying. That guess produced three distinct WYSIWYS defects, each fixed in turn.
"> Alice")1f2a57e1" Alice"/"Alice ")126d76aa95d7831a->48f6f970The fix is option 1 from this issue, not option 2
The renderer now declares which rows it emitted as chrome (
Pages::nav, a per-page bitmask), and the signing path reads that declaration.is_nav_rowno longer exists on any signing path — it is#[cfg(test)], retained only as the other half of a corpus differential (assert_nav_mask_matches_chrome) that proves the mask agrees with the old inference on every page the renderer actually produces.That matters for the failure mode. The interim fix (option 2, fail closed) turned a display bug into a signing denial — a legitimate Celo name starting with
"> "would have refused. A trusted display owes the user the bytes, not a refusal; the structural fix renders them verbatim.Tests — the characterisation test was inverted, as this issue asked
Now active and green (no
#[ignore]remains):(The feature set is deliberate: these goldens are authored under it, and running them with default features gives false reds — see the note on
ui-px-checkin the Makefile. The one ignored test is an unrelated seed-corpus layout diagnostic.)Two things the fix broke, recorded because they came from its design
4d61878arefused batch ERC-7730 signing.wrap_pages_with_batch_bannercopiedbufand notnav, so every chrome bit was lost on the batch route. My commit message argued the failure direction was "visible and harmless" — wrong:is_confirm_page_atrequires the bits SET, so a lost mask is a refusal to sign. Fixed ina60121f3, with the copy proof extended to covernav.95d7831adeleted the owner line from 16-character-intent pages — it traded a caption bug for anti-spoof information loss. Fixed in48f6f970by having the renderer declare the intent's extent in two bit-planes instead of inferring it from row fullness.Still open, deliberately
#762 — the chrome mask now decides what is displayed, and no transcript proof covers it. The mask is load-bearing for WYSIWYS; it should be bound the way the body pages are. That is the residual of this fix and is where the remaining risk in this area lives.
Severity note: I filed this as critical and corrected it to high on 2026-09-28. That correction stands — the reachable instance (
setNameon Celo Accounts) was never a path to loss. The reason to fix it was the class.fix(kani): the ui-px harnesses have not compiled since 4d61878; clear…