From f1a7f861fe1f559c947d50e80e6be9391972b2a9 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Thu, 16 Jul 2026 12:41:31 -0300 Subject: [PATCH 1/2] fix(verifier): validate next-row OOD block shape before transcript absorption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/tests/bus_tests/soundness_tests.rs | 149 ++++++++++++++++++ crypto/stark/src/verifier.rs | 26 +++ 2 files changed, 175 insertions(+) diff --git a/crypto/stark/src/tests/bus_tests/soundness_tests.rs b/crypto/stark/src/tests/bus_tests/soundness_tests.rs index 7d8fab0ee..9cae880e1 100644 --- a/crypto/stark/src/tests/bus_tests/soundness_tests.rs +++ b/crypto/stark/src/tests/bus_tests/soundness_tests.rs @@ -904,6 +904,155 @@ fn test_malformed_ood_table_shape_rejected() { ); } +/// A next-row (g·z) OOD block whose advertised dimensions disagree with its +/// backing data must be rejected, not panic. Unlike the current-row block +/// (`test_malformed_ood_table_shape_rejected`), the next-row block is absorbed +/// into the transcript via `get_row` in Round 3 BEFORE step_2's own shape guard +/// runs, so without a pre-absorption guard a lying shape is an out-of-bounds +/// slice panic rather than a `false` verdict. Owned path. +#[test_log::test] +fn test_malformed_ood_next_block_shape_rejected_owned() { + // Same valid trace as `test_malformed_ood_table_shape_rejected`. + let mut cpu_trace = TraceTable::from_columns_main( + vec![ + vec![FE::one(), FE::zero(), FE::zero(), FE::zero()], // add_flag + vec![FE::zero(); 4], // mul_flag + vec![FE::from(5), FE::zero(), FE::zero(), FE::zero()], + vec![FE::from(3), FE::zero(), FE::zero(), FE::zero()], + vec![FE::from(8), FE::zero(), FE::zero(), FE::zero()], + ], + 1, + ); + let mut add_trace = TraceTable::from_columns_main( + vec![ + vec![FE::from(5), FE::zero(), FE::zero(), FE::zero()], + vec![FE::from(3), FE::zero(), FE::zero(), FE::zero()], + vec![FE::from(8), FE::zero(), FE::zero(), FE::zero()], + vec![FE::one(), FE::zero(), FE::zero(), FE::zero()], // multiplicity = 1 + ], + 1, + ); + let mut mul_trace = TraceTable::from_columns_main(vec![vec![FE::zero(); 4]; 4], 1); + + let proof_options = ProofOptions::default_test_options(); + let cpu_air = new_cpu_air_with_lookup(&proof_options); + let add_air = new_add_air_with_lookup(&proof_options); + let mul_air = new_mul_air_with_lookup(&proof_options); + + let air_trace_pairs: Vec<( + &dyn AIR, + _, + _, + )> = vec![ + (&cpu_air, &mut cpu_trace, &()), + (&add_air, &mut add_trace, &()), + (&mul_air, &mut mul_trace, &()), + ]; + + let mut multi_proof = + multi_prove_ram(air_trace_pairs, &mut DefaultTranscript::::new(&[])).unwrap(); + + // Forge the ADD table's next-row OOD block to advertise a far larger shape + // than its data backs (the canonical hostile archive: width/height huge, one + // data element). `get_row` would slice `data[0..width]` out of bounds during + // Round-3 absorption; the Phase A guard must reject before that. + let add_proof = &mut multi_proof.proofs[1]; + assert!( + add_proof.trace_ood_next_evaluations.width >= 1, + "next-row OOD block must open at least one column for this to be an OOB test" + ); + add_proof.trace_ood_next_evaluations.width = 1000; + add_proof.trace_ood_next_evaluations.height = 1000; + + let airs: Vec<&dyn AIR> = + vec![&cpu_air, &add_air, &mul_air]; + + assert!( + !Verifier::multi_verify( + &airs, + &multi_proof, + &mut DefaultTranscript::::new(&[]), + &FieldElement::zero(), + ), + "Proof with a lying next-row OOD block shape must be rejected, not panic" + ); +} + +/// The same attack through the rkyv-archived, read-in-place path — the real +/// attack surface, since the recursion guest verifies archived proofs. +/// `ArchivedTable::get_row` is the same unchecked slice, and rkyv's bytecheck +/// does NOT enforce `width * height == data.len()`, so a forged archive reaches +/// absorption. The Phase A guard must reject it; it must never panic. +#[test_log::test] +fn test_malformed_ood_next_block_shape_rejected_archived() { + // Same valid trace as the owned variant above. + let mut cpu_trace = TraceTable::from_columns_main( + vec![ + vec![FE::one(), FE::zero(), FE::zero(), FE::zero()], // add_flag + vec![FE::zero(); 4], // mul_flag + vec![FE::from(5), FE::zero(), FE::zero(), FE::zero()], + vec![FE::from(3), FE::zero(), FE::zero(), FE::zero()], + vec![FE::from(8), FE::zero(), FE::zero(), FE::zero()], + ], + 1, + ); + let mut add_trace = TraceTable::from_columns_main( + vec![ + vec![FE::from(5), FE::zero(), FE::zero(), FE::zero()], + vec![FE::from(3), FE::zero(), FE::zero(), FE::zero()], + vec![FE::from(8), FE::zero(), FE::zero(), FE::zero()], + vec![FE::one(), FE::zero(), FE::zero(), FE::zero()], // multiplicity = 1 + ], + 1, + ); + let mut mul_trace = TraceTable::from_columns_main(vec![vec![FE::zero(); 4]; 4], 1); + + let proof_options = ProofOptions::default_test_options(); + let cpu_air = new_cpu_air_with_lookup(&proof_options); + let add_air = new_add_air_with_lookup(&proof_options); + let mul_air = new_mul_air_with_lookup(&proof_options); + + let air_trace_pairs: Vec<( + &dyn AIR, + _, + _, + )> = vec![ + (&cpu_air, &mut cpu_trace, &()), + (&add_air, &mut add_trace, &()), + (&mul_air, &mut mul_trace, &()), + ]; + + let mut multi_proof = + multi_prove_ram(air_trace_pairs, &mut DefaultTranscript::::new(&[])).unwrap(); + + // Forge before serialization: rkyv archives `data` (by its real length), + // `width`, and `height` as independent fields, so a width/height that + // disagree with the data survive `to_bytes` and surface on the archived + // table exactly as a hostile prover would craft them. + multi_proof.proofs[1].trace_ood_next_evaluations.width = 1000; + multi_proof.proofs[1].trace_ood_next_evaluations.height = 1000; + + let bytes = rkyv::to_bytes::(&multi_proof).unwrap(); + let archived = rkyv::access::< + crate::proof::stark::ArchivedMultiProof, + rkyv::rancor::Error, + >(&bytes) + .unwrap(); + + let airs: Vec<&dyn AIR> = + vec![&cpu_air, &add_air, &mul_air]; + + assert!( + !Verifier::multi_verify_archived( + &airs, + &archived.proofs, + &mut DefaultTranscript::::new(&[]), + &FieldElement::zero(), + ), + "Archived proof with a lying next-row OOD block shape must be rejected, not panic" + ); +} + /// The transition window (`trace_ood_next_row_columns`) of a LogUp table is /// exactly the accumulator column — the sole column read at the next row after /// forward accumulation — expressed as a full-width `[main | aux]` index. diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 3fe80f5a2..2ceabdb7e 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -1045,6 +1045,32 @@ pub trait IsStarkVerifier< { return false; } + // The next-row (g·z) OOD block, unlike block0 above, has no other + // pre-absorption guard: Round 3 absorbs it into the transcript + // through `get_row` -- an unchecked `data[start..start + width]` + // slice -- BEFORE step_2's own shape check runs, so a hostile archive + // whose advertised width/height disagree with its data length would + // panic (out-of-bounds) during absorption instead of being rejected + // as a false proof. Validate its shape here, from AIR metadata only, + // mirroring step_2 exactly: width is the transition-window column + // count and height the next-row row count (both 0 when the AIR reads + // no next-row columns). `dimensions_consistent()` closes the + // `width * height != data.len()` gap that rkyv's bytecheck leaves open. + let step_size = air.step_size(); + let num_eval_points = air.context().transition_offsets.len() * step_size; + let expected_next_width = air.trace_ood_next_row_columns().len(); + let expected_next_height = if expected_next_width == 0 { + 0 + } else { + num_eval_points.saturating_sub(step_size) + }; + let trace_ood_next_evaluations = proof.trace_ood_next_evaluations(); + if trace_ood_next_evaluations.width() != expected_next_width + || trace_ood_next_evaluations.height() != expected_next_height + || !trace_ood_next_evaluations.dimensions_consistent() + { + return false; + } if air.is_preprocessed() { // Preprocessed table: VERIFY precomputed commitment matches hardcoded. // This is the critical soundness check - ensures prover used correct precomputed values. From fb9a659436d83c24e3812b60d541699aa4f7c3ed Mon Sep 17 00:00:00 2001 From: MauroFab Date: Thu, 16 Jul 2026 13:16:32 -0300 Subject: [PATCH 2/2] refactor(verifier): fold both OOD shape checks into one helper 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. --- crypto/stark/src/verifier.rs | 125 ++++++++++++++--------------------- 1 file changed, 51 insertions(+), 74 deletions(-) diff --git a/crypto/stark/src/verifier.rs b/crypto/stark/src/verifier.rs index 2ceabdb7e..c4a8eeb7d 100644 --- a/crypto/stark/src/verifier.rs +++ b/crypto/stark/src/verifier.rs @@ -125,6 +125,42 @@ pub trait IsStarkVerifier< /// Checks whether the purported evaluations of the composition polynomial parts and the trace /// polynomials at the out-of-domain challenge are consistent. /// See https://lambdaclass.github.io/lambdaworks/starks/protocol.html#step-2-verify-claimed-composition-polynomial + /// Soundness (I3): both OOD blocks' shapes are a public function of the AIR, + /// never of the (prover-controlled) proof. The current-row block opens every + /// column over `step_size` rows; the next-row block opens only the + /// transition-window columns over the remaining rows, and is empty when the + /// AIR reads none. + /// + /// Must run before Round 3, which absorbs the next-row block through + /// `get_row` — an unchecked `data[start..start + width]` slice. A hostile + /// archive whose advertised dims disagree with its data length would panic + /// there rather than be rejected as a false proof; `dimensions_consistent()` + /// closes that gap, which rkyv's bytecheck leaves open. + fn ood_blocks_well_formed( + air: &dyn AIR, + proof: StarkProofView<'_, Field, FieldExtension, PI>, + ) -> bool { + let step_size = air.step_size(); + let num_eval_points = air.context().transition_offsets.len() * step_size; + let expected_next_width = air.trace_ood_next_row_columns().len(); + let expected_next_height = if expected_next_width == 0 { + 0 + } else { + num_eval_points.saturating_sub(step_size) + }; + let current = proof.trace_ood_evaluations(); + let next = proof.trace_ood_next_evaluations(); + + // `height == step_size` also rejects a height-0 current block: every AIR + // reports `step_size >= 1`. + current.dimensions_consistent() + && current.width() == air.trace_layout().0 + air.num_auxiliary_rap_columns() + && current.height() == step_size + && next.dimensions_consistent() + && next.width() == expected_next_width + && next.height() == expected_next_height + } + fn step_2_verify_claimed_composition_polynomial( air: &dyn AIR, proof: StarkProofView<'_, Field, FieldExtension, PI>, @@ -142,33 +178,17 @@ pub trait IsStarkVerifier< .bus_table_contribution() .map(BusPublicInputs::from_contribution); - // Soundness (I3): both OOD blocks' shapes are a public function of the - // AIR, never of the (prover-controlled) proof. The current-row block - // opens every column over `step_size` rows; the next-row block opens only - // the transition-window columns over the remaining rows (and is empty - // when there are none). Reject any mismatch (including an archive whose - // advertised dims disagree with its data length) before using either - // block, so a malicious prover cannot reshape them to dodge a check or - // desync the frame reconstruction below. + // Reject either OOD block whose shape disagrees with the AIR before + // reading it, so a malicious prover cannot reshape them to dodge a check + // or desync the frame reconstruction below. + if !Self::ood_blocks_well_formed(air, proof) { + return false; + } let step_size = air.step_size(); let num_eval_points = air.context().transition_offsets.len() * step_size; let next_row_cols = air.trace_ood_next_row_columns(); - let expected_next_width = next_row_cols.len(); - let expected_next_height = if expected_next_width == 0 { - 0 - } else { - num_eval_points.saturating_sub(step_size) - }; let ood_current = proof.trace_ood_evaluations(); let ood_next = proof.trace_ood_next_evaluations(); - if ood_current.height() != step_size - || !ood_current.dimensions_consistent() - || ood_next.width() != expected_next_width - || ood_next.height() != expected_next_height - || !ood_next.dimensions_consistent() - { - return false; - } // Reconstruct the full current+next-row OOD grid (surviving values placed, // pruned next-row entries zero -- those are never read by any constraint). @@ -1017,58 +1037,15 @@ pub trait IsStarkVerifier< { return false; } - // The archive is read in place without validation; reject an OOD - // table whose advertised dimensions disagree with its data length, - // has no rows, whose width doesn't match the AIR's column layout, or - // whose height isn't a whole number of AIR steps (which `into_frame` - // below only `debug_assert!`s, not checks) — all before any row - // access indexes into it. - // - // The width check is load-bearing and prevents two distinct faults: - // (a) the AIR-derived column index `main_trace_width + c.col` in - // `step_2_verify_claimed_composition_polynomial` indexing past a - // too-narrow OOD row (a release-mode out-of-bounds panic), and - // (b) a width-0 table, whose `width * height == 0 == data.len()` - // satisfies `dimensions_consistent()` for an arbitrary advertised - // height and would otherwise slip through this guard entirely. - // An honest proof always commits exactly `main_trace_width + num_aux` - // OOD columns (the same quantities `column_idx` and the `checked_sub` - // boundary use), so exact equality never rejects a valid proof. - let trace_ood_evaluations = proof.trace_ood_evaluations(); - let expected_ood_width = air.trace_layout().0 + air.num_auxiliary_rap_columns(); - if !trace_ood_evaluations.dimensions_consistent() - || trace_ood_evaluations.height() == 0 - || trace_ood_evaluations.width() != expected_ood_width - || !trace_ood_evaluations - .height() - .is_multiple_of(air.step_size()) - { - return false; - } - // The next-row (g·z) OOD block, unlike block0 above, has no other - // pre-absorption guard: Round 3 absorbs it into the transcript - // through `get_row` -- an unchecked `data[start..start + width]` - // slice -- BEFORE step_2's own shape check runs, so a hostile archive - // whose advertised width/height disagree with its data length would - // panic (out-of-bounds) during absorption instead of being rejected - // as a false proof. Validate its shape here, from AIR metadata only, - // mirroring step_2 exactly: width is the transition-window column - // count and height the next-row row count (both 0 when the AIR reads - // no next-row columns). `dimensions_consistent()` closes the - // `width * height != data.len()` gap that rkyv's bytecheck leaves open. - let step_size = air.step_size(); - let num_eval_points = air.context().transition_offsets.len() * step_size; - let expected_next_width = air.trace_ood_next_row_columns().len(); - let expected_next_height = if expected_next_width == 0 { - 0 - } else { - num_eval_points.saturating_sub(step_size) - }; - let trace_ood_next_evaluations = proof.trace_ood_next_evaluations(); - if trace_ood_next_evaluations.width() != expected_next_width - || trace_ood_next_evaluations.height() != expected_next_height - || !trace_ood_next_evaluations.dimensions_consistent() - { + // The archive is read in place without validation, so both OOD blocks + // must be shape-checked here — before Round 3 absorbs the next-row + // block and before any row access indexes into either. The width check + // is load-bearing: it stops the AIR-derived column index + // `main_trace_width + c.col` in `step_2_verify_claimed_composition_polynomial` + // from indexing past a too-narrow OOD row, and it rejects a width-0 + // table, whose `width * height == 0 == data.len()` would otherwise + // satisfy `dimensions_consistent()` for any advertised height. + if !Self::ood_blocks_well_formed(*air, proof) { return false; } if air.is_preprocessed() {