Skip to content

http: bound the REU body sink to its region; cold-bank asserts and stash checks (adv-l5 on #266) - #273

Merged
JC-000 merged 10 commits into
masterfrom
fix/reu-body-sink-bound
Oct 6, 2026
Merged

JC-000 merged 10 commits into
masterfrom
fix/reu-body-sink-bound

Conversation

@JC-000

@JC-000 JC-000 commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Merge after #279-#282. This is based on 249f65d; if it conflicts after those merge, it gets one rebase.

Follow-up to #266, fixing adv-l5's HIGH finding and two LOWs.

HIGH: the REU body sink wrote outside its region

http_sink_blit wrote each 512 B bounce to http_reu_body_base + cursor with no length bound, and REU addresses wrap.

  • 512 KB and 1 MB REU: the default base $10:0000 aliases bank 0.
  • 16 MB REU: the carry out of the bank byte is dropped.

Consequence. A server's body reaches the multiply rows (banks 0-1), comb's Lim-Lee table and the cold-code bank (bank 2).

  • adv-l5's PoC forges the cold image (marker and 8-bit XOR) and runs server code at the next typed-target prompt. I reproduced it on master PRG 541a0613 at 512 KB and 16 MB.
  • The XOR is not authentication; bounding the writes is the fix.

Older exposure on the Lim-Lee table (before #266). Body offsets that land on bank 2 $0000-$3FFF:

REU size body offset
≤1 MB 131,072-147,455
2 MB 1,179,648
4 MB 3,276,800
8 MB 7,471,104
16 MB 15,859,712

No attacker is needed. On master, the 751 KB Wikipedia demo body streamed on a 512 KB or 1 MB REU overwrites all 16,384 B of the Lim-Lee table and the cold image. Measured with master's own code in the interpreter.

Shipped images. No release (v0.2.0-v0.4.3) packaged HTTPS_BODY_TO_REU (supervisor checked tools/package/_common.sh at each tag). Only demo and REU-body builds are exposed.

Fix (0cfda62)

  • reu_probe_size runs at boot on UCI, before anything else writes the REU.
    • It writes a byte to offset 0 of banks 1, 2, 4 … 128, reads it back, and checks bank 0 for aliasing.
    • No REU, or an expired DMA confirm, leaves the size unknown, so the sink fails closed.
  • sink_room_init sets each body's region to [base, end).
    • end is the probed size, or bank 6 when the base is below the P-384 banks.
    • A base in banks 0-2 or 6-7, past the end, or with no size established gets an empty region.
  • sink_fits: a blit that would cross end, or wrap 24 bits, writes nothing and latches http_sink_full. Every later blit is refused.
  • Clear stop, not a silent truncation:
    • http_recv_body stops at once and returns C=1;
    • the timeout verdict returns C=1;
    • the menu prints BODY TOO BIG FOR THE REU instead of entering the viewer.
  • Assemble-time asserts: HTTP_REU_BODY_BASE lies outside banks 0-2 and 6-7, and the reserved overlay slots and the cold bank lie inside them.
  • Placement. The guard code is a new HTTP_SINK_GUARD segment in CRYPTO_HOT (279 B). LOADER and the overlay have no room for it.
  • Rig oracle. The banner rig's body oracle reads http_sink_full. It is an optional symbol, so a refused body cannot read as complete.

LOW 2: the REU-home asserts could never fail (883bd28)

The asserts now import the library's code-read exports and SINK_FLOOR_BANK:

  • LIB_NISTCURVES_REU_BANK_COMB;
  • _OFFSET_COMB_P256 / _OFFSET_COMB_P384;
  • _BANK_MUL.

They check that the cold images, both Lim-Lee tables and the multiply rows are disjoint and below the sink's banks.

Red-green. I rebuilt the comb archive with LIB_NISTCURVES_REU_OFFSET_COMB_P384=0xA000, which puts the table on the cold home.

  • Old asserts: it links silently.
  • New asserts: ld65: Error: cold bank: images overlap the linked library's P-384 Lim-Lee table.

LOW 3: stash checks (cbd804e)

  • (a) cold_call refuses while cold_stashed = 0. Before the fix, a planted REU image with the marker and XOR 0 ran with no stash ($0334=$42).
  • (b) cold_bank_init checks every group's marker at its load address before stashing. Before the fix, a TRUST group whose tail never arrived was stashed and summed.
  • The re-run case now forges every marker, so the once-per-LOAD rule stays separately tested.

Rig fix (5d90e64)

My first hardware run of 0cfda62 crashed the banner rig AFTER 'G'.

  • Cause: folding http_sink_full (in CRYPTO_HOT) into the rig's one-DMA span widened it to 20 KB and tripped the span assert.
  • Effect: the DeviceLock was released with a possibly live socket. This was reported at once. A later locked read-only check found another lane had already reloaded the device and got dhcp ok, so no lease poisoning was seen.
  • Fix: the span read is master's again. The optional symbol is resolved before any socket opens and read separately.

Red-green

  • tools/test_reu_body_sink.py (new): 70/70. It runs on the 6502 interpreter with a modelled REU at 512 KB, 1 MB and 16 MB, and with no REU:
    • the probe;
    • region rules;
    • an exact-fit bounce passes and a bounce one byte over is refused, latched, with bank 6 untouched;
    • a 24-bit wrap is refused;
    • adv-l5's attack leaves bank 2 byte-identical and the real prompt runs;
    • the verdict returns C=1;
    • a benign 751,073 B stream on 512 KB / 1 MB stops at the region with no REU byte outside it changed.
  • Mutants: 7, all killed. No bound; no floor; no alias check; no verdict check; latch not sticky; no P-384 gap; no 24-bit carry.
  • test_cold_bank.py: 116/116. The two new LOW-3 cases are red before the fix; mutants L3a and L3b are killed; the original 8 mutants are still killed.
  • test_http (VICE, uci): 72/72. New vector: the probe sees 8 banks, and a 1,500 B body against a 1 KB region writes the two bounces that fit, refuses the rest, and leaves bank 6 untouched.
  • Other suites:
    • test_aead_fail_closed (uci, sink-abort case): pass;
    • test_viewer 7/7;
    • test_trust_store_6502 (comb) 128/128;
    • test_prg_load 11/11;
    • mutate_http_body_checks 90/90.
  • _prg_load.py wording. I reworded its new comment so test_device_prep's rig discovery no longer selects the helper. comb: cold-code bank — no-connection code runs from the REU via cert_buf (#155 phase 2, L5) #266 had made that suite red.
  • Root pytest: 2 red, neither from this PR.

uci-m3 (folded in on the rebase onto 249f65d)

  • HTTP_SINK_GUARD had no home in cfg/c64-https-uci-m3.cfg, so the uci-m3 link failed. It now has one.
  • The M3 http_recv_body stops as soon as the sink refuses a write. At completion it returns C=1 if a refusal happened.
  • check_m3_client.py test_http_sink_refusal_stops (new). A 2,000 B body refused at its first blit must stop mid-stream. A 100 B body refused at the final flush must not read as complete.
    • Red on each half removed: read on to the framing's end (2000 B) and a body the sink refused was reported complete.
    • Green: 49/49.
  • mutate_m3_client.py: 2 new mutants. 60/60 killed.
  • The rebase conflict was http_recv_timeout_verdict: master now carries C through php/plp. The sink check sits after the plp and is an lda/bne that leaves C alone.

Suites at 074e422

suite result
test_reu_body_sink 70/70
test_cold_bank 116/116
test_trust_store_6502 (default and comb) 128/128 each
test_viewer 7/7
test_prg_load 11/11
test_http_body_checks_unit 20/20
mutate_http_body_checks 90/90
test_http (VICE, uci) 72/72
test_aead_fail_closed (uci) 43/43
check_m3_client 49/49
mutate_m3_client 60/60
root pytest 414 passed, 1 failed

The one root-pytest failure is test_ip65_hw_checks_unit, which needs an ip65 build in build/. That is build state only.

Hashes and margins (tools/measure_margins.py --build, side by side with master 249f65d)

  • ip65: all four profiles are byte-identical to master: 5f261d3c, 69eeea39, 93353af1, c9aa6a4e.
  • UCI images change, as intended:
profile PRG sha256
uci 34065085…
uci-onchip 90096a4e…
uci-comb b68679cb…
comb demo + TS 393360be…
uci-m3 0a826f7e… (master b124ea9f…)
uci-m3 + HTTPS_BODY_TO_REU 9e6ab506…
  • Every profile links. That includes uci-m3, and also a trial merge on top of the four PRs queued ahead of this one.
  • For free bytes, run the tool rather than trusting a table here.

adv-273 round 2 (f5df8b8, a281ac0, d076a9a, 56e29fd)

  • F1, missing red (f5df8b8, tests only). A refusal can first happen in a verdict's own final flush: every full bounce fits, and the last partial one crosses the region end.
    • New case test_reu_body_sink case_verdict_flush, covering both verdicts.
    • Red: on the mutant that checks the latch before the flush (fd62f9f0), C=0 ×2. Green: 76/76 on 393360be.
    • check_m3_client gets the unframed + 01 variant. The mutate_m3_client mutant sink-check-before-flush is killed.
  • F2 (a281ac0). uci-m3 with HTTPS_BODY_TO_REU entered the viewer even when the sink had refused the body.
    • It now prints BODY TOO BIG FOR THE REU and closes, like boot.s.
    • New VIEWER_TESTS case in check_m3_client: the menu path with a refused 100 B body.
    • Red without the fix (b75fd5fb): do_https_get never returns, because the viewer waits for keys. Green: 51/51 (9e6ab506).
    • Not in mutate_m3_client: the hook is assembled only on a BODY_TO_REU image. adv-273's manual mutant is the evidence.
  • F3 (d076a9a). reu_probe_size ran on every uci-m3 boot; with the REU disabled that is about 3 s at 1 MHz.
    • It now runs only on 6510-TLS UCI builds and on uci-m3 + HTTPS_BODY_TO_REU.

    • New test_boot_reu_dma_only_for_the_sink runs start to the menu in the model and counts REU register writes:

      image REU writes at boot
      master b124ea9f 0
      3bf57344 27 (red)
      0a826f7e 0
      BODY_TO_REU image > 0
    • Mutant boot-probe-ungated is killed.

  • Nits (56e29fd). check_m3_client now tells a BODY_TO_REU image by -D HTTPS_BODY_TO_REU in its flags.stamp, not by the presence of viewer_enter.
    • A KEEP_VIEWER=1 image (c5c70a02) was 49/51 and is now 50/50.
    • mutate_m3_client refuses a BODY_TO_REU image, where boot-probe-ungated is equivalent.
  • Results at the tip:
    • All eight 6510-TLS PRGs are byte-identical to 074e422, so the suites and the hardware run below still apply.
    • check_m3_client: plain 50/50, BODY_TO_REU 51/51, KEEP_VIEWER 50/50.
    • mutate_m3_client: 62/62.

Hardware

  • Device: U64E, firmware 3.15, git 3a1ff9ff, FPGA 126, core 1.50. Read through the harness get_info under DeviceLock.
  • Setup: 48 MHz, prepare_device then preflight_reu.
run PRG result
comb Wikipedia demo + TRUST_STORE=1, rig_https_banner, RETURN ×2 393360be044dbe92… PASS: HTTP 200, 751,764 B == Content-Length; exited with 'Q'

🤖 Generated with Claude Code

JC-000 and others added 10 commits October 5, 2026 18:37
http_sink_blit STASHed each bounce to http_reu_body_base + cursor with no
bound, and REU addresses wrap. On a 512 KB / 1 MB REU the default base
$10:0000 is bank 0; on 16 MB the carry out of the bank byte was dropped.
A server's body then reached the multiply rows (banks 0-1), comb's Lim-Lee
table and the cold-code bank (bank 2): adv-l5's PoC runs server code at
the next typed-target prompt, with the cold bank's marker and 8-bit XOR
forged. The XOR is not authentication; bounding the writes is the fix.

- reu_probe_size (boot, UCI, before anything else writes the REU) finds
  the REU's size by write/read-back/alias at bank offset 0; no REU or an
  expired DMA confirm leaves it unknown.
- sink_room_init (per body): the region is [base, end), end = the probed
  size, or bank 6 below the P-384 banks; a base in banks 0-2 or 6-7, past
  the end, or with no size gets an empty region.
- sink_fits: a blit that would cross the end (or wrap 24 bits) writes
  nothing and latches http_sink_full; every later blit is refused.
  http_recv_body stops at once and returns C=1; the timeout verdict says
  C=1; the menu prints BODY TOO BIG FOR THE REU instead of the viewer.
- Assemble-time asserts: HTTP_REU_BODY_BASE outside banks 0-2 and 6-7;
  the cold bank and the reserved overlay slots inside them.
- tools/test_reu_body_sink.py (6502 interpreter, 512 KB/1 MB/16 MB/no
  REU, adv-l5's attack); test_http gains a VICE over-region vector; the
  banner rig's body oracle reads http_sink_full.
- uci-m3: HTTP_SINK_GUARD gets a home in its cfg, and the M3 receive
  loop stops on a refusal and is C=1 at completion when one happened.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…small REU

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Folding it into the one-DMA span widened the span to 20 KB on comb
($6113-$AF50) and tripped the span assert AFTER 'G', with a live socket
(measured on the U64E, PRG 75be0262). The span is master's again; the
optional symbol is resolved before any socket opens and read separately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dv-l5 LOW 2)

The old asserts compared COLD_REU with a literal floor, so they could not
fail. They now import the code-read symbols reu_config.o exports
(LIB_NISTCURVES_REU_BANK_COMB, _OFFSET_COMB_P256/_P384, _BANK_MUL) and the
body sink's SINK_FLOOR_BANK, and check the images, both Lim-Lee tables and
the multiply rows are disjoint and below the sink's banks.

Red-green: rebuilding the comb archive with
LIB_NISTCURVES_REU_OFFSET_COMB_P384=0xA000 (the P-384 table onto the cold
home) links silently with the old asserts and fails this link with
'cold bank: images overlap the linked library's P-384 Lim-Lee table'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(a) cold_call never read cold_stashed. With no stash this LOAD,
cold_g_sum is 0 and stale or planted REU bytes carrying the marker and an
XOR of 0 ran: case_no_stash_refuses ran a planted trust_store_load before
this commit ($0334=$42) and is refused (cold_err FETCH) after it.
(b) cold_bank_init checked only the UI group's marker, so a LOAD that
stopped short of the TRUST group was stashed and summed:
case_truncated_group_is_not_stashed was red; every group's marker is now
checked at its load address first. The re-run case now forges every
marker, so the once-per-LOAD rule stays separately tested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two sizes: a 2,000 B body refused at its first blit must stop the loop
mid-stream; a 100 B body refused at the final flush must not read as
complete. Two mutants (stop removed; completion back to CLC), both killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… (adv-273 F1)

A refusal can first happen in http_recv_{close,timeout}_verdict's own
final flush: every full bounce fit, the last partial one crosses the
region's end. Moving the http_sink_full check above php / jsr
http_body_finish stayed green everywhere. Now pinned twice:

- test_reu_body_sink case_verdict_flush (comb TS demo image): both
  verdicts, unframed close and a satisfied Content-Length. Red on the
  mutant (fd62f9f0: C=0 twice), green on 393360be.
- check_m3_client: the unframed + 01 variant; mutate_m3_client gains
  sink-check-before-flush, killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With HTTPS_BODY_TO_REU, m3_https_get.inc entered the viewer whatever
http_sink_full said, so a refused body showed REU bytes it never wrote.
It now prints BODY TOO BIG FOR THE REU and closes, as boot.s's TLS arm.

check_m3_client gains VIEWER_TESTS, run on a BODY_TO_REU image (and
named as not run otherwise): the menu path with a refused 100 B body.
Red without the fix (b75fd5fb: do_https_get never returns, the viewer
waits for keys); green with it (9e6ab506). The model attaches a response
to the next Open (M3Device.on_write) so a menu fetch can carry a body.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…3 F3)

reu_probe_size ran on every uci-m3 boot; with the REU disabled it spins
~3 s at 1 MHz, and only the REU body sink uses its result. A plain
uci-m3 boot does no REU DMA again, as on master; every 6510-TLS UCI
image and uci-m3 + HTTPS_BODY_TO_REU still probe.

check_m3_client test_boot_reu_dma_only_for_the_sink runs `start` to the
menu and counts REU register writes: 0 on plain uci-m3 (master b124ea9f
0, 3bf57344 27, now 0a826f7e 0), > 0 on a BODY_TO_REU image. Mutant
boot-probe-ungated killed. (It also caught a .not/.or precedence slip in
the first draft of the gate, which dropped the probe from BODY_TO_REU.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ewer (adv-273 nits)

viewer_enter is also linked by KEEP_VIEWER=1 and VIEWER_TEST_HELPERS=1,
which do not stream the body to the REU: on a KEEP_VIEWER image the boot
test demanded a probe and the viewer test ran, 49/51 (adv-273). The image's
kind now comes off -D HTTPS_BODY_TO_REU in the flags.stamp beside its
labels (KEEP_VIEWER: 50/50, the viewer test named as not run).
mutate_m3_client refuses a BODY_TO_REU image (boot-probe-ungated is
equivalent there) and copies the stamp into its scratch build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@JC-000
JC-000 force-pushed the fix/reu-body-sink-bound branch from a861278 to 56e29fd Compare October 6, 2026 02:34
JC-000 added a commit that referenced this pull request Oct 6, 2026
…K_CODE)

1. MEDIUM-HIGH. The hook stored tp_bwarn from A after a failed
   tp_cmp_got, and A is then the bundle pin's byte where the hashes first
   differ (from byte 31 down). A $00 there left tp_bwarn clear: BUNDLE
   MISMATCH printed, RECORD KEY? was never asked, and trust_post recorded
   the server's key as TOFU -- free when the pin's byte 31 is $00, ~2^8 /
   2^16 grinds for byte 30 / 29. Now `lda #1`. Regression: pins with byte
   31 = $00, and byte 30 = $00 with 31 equal (red on 04af8b9: recorded
   without A); mutant. Audit: tp_bwarn was the only flag stored from an
   incidental A; the other stores are explicit, or clear (the safe way).
2. LOW. TRUST_POLICY_BSS is not boot-zeroed, so an accept armed before 'Q'
   survived RUN (or a reset and SYS). start now calls trust_state_init,
   which clears it and the bundle cache. Test + mutant (drop the call).
3. LOW, comb. A cold-bank refusal of trust_post (socket ERROR, latched DMA
   timeout) skipped its clears, keeping tp_ovr_armed and tp_mode. The
   resident stubs now clear both whenever the bank itself refuses. Red on
   04af8b9 (armed=1 mode=1 survived); mutant.

Under TRUST_STORE the hook's code is TRUST_HOOK_CODE, not CERT_PIN_CODE:
the comb cfgs place it in NET_CODE (CRYPTO_HOT has no room for it beside
#273's body-sink bound), the uci cfg where CERT_PIN_CODE already was. The
plain pin keeps CERT_PIN_CODE, so non-trust pinned builds do not move.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@JC-000
JC-000 merged commit c26e268 into master Oct 6, 2026
JC-000 added a commit that referenced this pull request Oct 6, 2026
…K_CODE)

1. MEDIUM-HIGH. The hook stored tp_bwarn from A after a failed
   tp_cmp_got, and A is then the bundle pin's byte where the hashes first
   differ (from byte 31 down). A $00 there left tp_bwarn clear: BUNDLE
   MISMATCH printed, RECORD KEY? was never asked, and trust_post recorded
   the server's key as TOFU -- free when the pin's byte 31 is $00, ~2^8 /
   2^16 grinds for byte 30 / 29. Now `lda #1`. Regression: pins with byte
   31 = $00, and byte 30 = $00 with 31 equal (red on 04af8b9: recorded
   without A); mutant. Audit: tp_bwarn was the only flag stored from an
   incidental A; the other stores are explicit, or clear (the safe way).
2. LOW. TRUST_POLICY_BSS is not boot-zeroed, so an accept armed before 'Q'
   survived RUN (or a reset and SYS). start now calls trust_state_init,
   which clears it and the bundle cache. Test + mutant (drop the call).
3. LOW, comb. A cold-bank refusal of trust_post (socket ERROR, latched DMA
   timeout) skipped its clears, keeping tp_ovr_armed and tp_mode. The
   resident stubs now clear both whenever the bank itself refuses. Red on
   04af8b9 (armed=1 mode=1 survived); mutant.

Under TRUST_STORE the hook's code is TRUST_HOOK_CODE, not CERT_PIN_CODE:
the comb cfgs place it in NET_CODE (CRYPTO_HOT has no room for it beside
#273's body-sink bound), the uci cfg where CERT_PIN_CODE already was. The
plain pin keeps CERT_PIN_CODE, so non-trust pinned builds do not move.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@JC-000
JC-000 deleted the fix/reu-body-sink-bound branch October 14, 2026 15:36
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.

1 participant