Skip to content

fix(verifier): validate next-row OOD block shape before transcript absorption#835

Merged
MauroToscano merged 2 commits into
feat/logup-acc-current-rowfrom
fix/verifier-ood-next-block-guard
Jul 16, 2026
Merged

fix(verifier): validate next-row OOD block shape before transcript absorption#835
MauroToscano merged 2 commits into
feat/logup-acc-current-rowfrom
fix/verifier-ood-next-block-guard

Conversation

@MauroToscano

Copy link
Copy Markdown
Contributor

The bug (merge-blocker from the #823 review)

replay_rounds_after_round_1 (Round 3) absorbs BOTH pruned OOD blocks into the transcript using each block's self-declared width()/height():

for ood in [proof.trace_ood_evaluations(), proof.trace_ood_next_evaluations()] {
    for col_idx in 0..ood.width() {
        for row_idx in 0..ood.height() {
            transcript.append_field_element(&ood.get_row(row_idx)[col_idx]);
        }
    }
}
  • Block0 (trace_ood_evaluations) is validated before absorption by the Round-1 Phase A guard in multi_verify_views.
  • Block1 (trace_ood_next_evaluations, new with OOD pruning) had no pre-absorption guard. Its only shape check lives inside step_2, which runs after absorption.

ArchivedTable::get_row does an unchecked slice data[start..start + width], and rkyv's bytecheck does not enforce width * height == data.len(). So an archived proof advertising e.g. width=1000/height=1000 with a single data element makes get_row slice out of bounds and panics the verifier during absorption — a guest trap / host crash instead of a false verdict. On the recursion guest (which verifies archived proofs) this is a DoS.

The fix

Extend the Phase A guard in multi_verify_views — right where block0 is already validated — with the block1 shape check, derived from AIR metadata only and mirroring step_2 exactly:

  • expected_width = air.trace_ood_next_row_columns().len()
  • expected_height = 0 when expected_width == 0, else num_eval_points.saturating_sub(step_size) with num_eval_points = transition_offsets.len() * step_size
  • check width, height, and dimensions_consistent(); on mismatch return false.

Phase A runs before Round 3 for every proof, so the block1 get_row slices are only ever reached after this guard has accepted the shape. This is the same fault class the block0 guard already prevents, extended to the new block.

Design constraints honored

  • Never panic: a malformed proof is a false proof, not a crash. The guard turns the OOB panic into a clean return false.
  • Cycle-lean: constant per-proof integer comparisons (width/height/dimensions_consistent), no loop over proof data.
  • Defense-in-depth: step_2's existing post-absorption guard is left in place; it is now redundant for the archived slice path but still guards frame reconstruction, so it stays.

Tests (soundness_tests.rs, modeled on test_malformed_ood_table_shape_rejected)

  • Owned path: forge the ADD table's block1 to advertise width=1000/height=1000 over its 1-element data; assert multi_verify returns false without panicking. (Table::width/height are pub, so the forge needs no other route.)
  • Archived path (the real attack surface): forge the dims, rkyv::to_bytes + rkyv::access, assert multi_verify_archived returns false without panicking. rkyv archives data/width/height as independent fields, so the lying dims survive serialization.
  • Confirmed regression: with the guard stashed, the archived test panics at crypto/stark/src/table.rs:236range end index 1000 out of range for slice of length 1 — proving both the vulnerability and the fix.

Validation

  • make lint (fmt --check --all + all four --workspace --all-targets clippy passes incl. cuda): green.
  • cargo test --release -p stark: bus_tests 77 (was 75, +2 new), ood:: 4, logup_single_source 7 — all pass.

…sorption

The next-row (g·z) OOD block (`trace_ood_next_evaluations`, new with OOD
pruning) is absorbed into the transcript in Round 3 via `get_row` -- an
unchecked `data[start..start + width]` slice -- BEFORE step_2's shape guard
runs. rkyv bytecheck does not enforce `width * height == data.len()`, so a
hostile archive advertising e.g. width=1000/height=1000 with a single data
element panics the verifier out of bounds (guest trap / host crash) instead
of being rejected as a false proof.

Extend the Round-1 Phase A guard in `multi_verify_views` (where block0 is
already validated) with the block1 shape check, derived from AIR metadata
only and mirroring step_2 exactly (width, height, dimensions_consistent).
Constant per-proof integer comparisons, no loop over data -- the verifier
stays cycle-lean and never panics on a malformed proof. step_2's own
post-absorption guard is left in place as defense-in-depth.

Tests (soundness_tests.rs): a next-row block whose advertised dims disagree
with its backing data is rejected, not panicked, on both the owned and
archived paths. Confirmed the archived case panics in table.rs `get_row`
without this guard.
step_2 and the pre-absorption guard in multi_verify_views each derived
step_size, num_eval_points and the expected next-row dims, then ran the
same three checks on the next-row block. Extract ood_blocks_well_formed
and call it from both; the comment keeps only what the code cannot say
(the Round 3 absorption ordering, and why the width check is load-bearing).

The pre-absorption guard also still described the pre-split table: it
accepted any nonzero height that was a multiple of step_size, which was
correct when trace_ood_evaluations held the whole OOD grid. Since the
current/next split, block0 is exactly step_size rows tall -- what step_2
already required. Both sites now use the stricter equality, which also
subsumes the height-0 case as no AIR reports step_size 0.

Net -23 lines; stark suite 195 passing.
@MauroToscano MauroToscano merged commit 2ecb199 into feat/logup-acc-current-row Jul 16, 2026
13 checks passed
@MauroToscano MauroToscano deleted the fix/verifier-ood-next-block-guard branch July 16, 2026 16:21
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