fix(verifier): validate next-row OOD block shape before transcript absorption#835
Merged
MauroToscano merged 2 commits intoJul 16, 2026
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-declaredwidth()/height():trace_ood_evaluations) is validated before absorption by the Round-1 Phase A guard inmulti_verify_views.trace_ood_next_evaluations, new with OOD pruning) had no pre-absorption guard. Its only shape check lives insidestep_2, which runs after absorption.ArchivedTable::get_rowdoes an unchecked slicedata[start..start + width], and rkyv's bytecheck does not enforcewidth * height == data.len(). So an archived proof advertising e.g.width=1000/height=1000with a single data element makesget_rowslice out of bounds and panics the verifier during absorption — a guest trap / host crash instead of afalseverdict. 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 mirroringstep_2exactly:expected_width = air.trace_ood_next_row_columns().len()expected_height = 0whenexpected_width == 0, elsenum_eval_points.saturating_sub(step_size)withnum_eval_points = transition_offsets.len() * step_sizewidth,height, anddimensions_consistent(); on mismatchreturn false.Phase A runs before Round 3 for every proof, so the block1
get_rowslices 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
return false.dimensions_consistent), no loop over proof data.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 ontest_malformed_ood_table_shape_rejected)width=1000/height=1000over its 1-element data; assertmulti_verifyreturnsfalsewithout panicking. (Table::width/heightarepub, so the forge needs no other route.)rkyv::to_bytes+rkyv::access, assertmulti_verify_archivedreturnsfalsewithout panicking. rkyv archivesdata/width/heightas independent fields, so the lying dims survive serialization.crypto/stark/src/table.rs:236—range 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-targetsclippy passes incl. cuda): green.cargo test --release -p stark:bus_tests77 (was 75, +2 new),ood::4,logup_single_source7 — all pass.