fix(soundness): range-check HintsTable init limbs (#999)#1359
Merged
Conversation
`HintsTable` is the only RAM table with prover-witnessed (non-zero) init
values. `DynVolatileRamTableInitConfig::construct_circuit` created those
init-value limbs as raw `WitIn`s with no range check, so a malicious
prover could supply non-canonical limbs (>= 2^16). The load path reads
memory via `UInt::new_unchecked` (e.g. `LW` in load_v2.rs) and forwards
the limbs to the destination register unconstrained, so a non-canonical
hint word would propagate into the computation — an under-constraint
soundness bug.
Fix: bind every non-zero-init limb to `LIMB_BITS` (u16) in
`construct_circuit`, making the reconstructed word a canonical u32.
Wiring notes:
- A range check is a "looking" lookup (`lk_expressions`). The default
`TableCircuit::build_gkr_iop_circuit` only wires table records, so
`DynVolatileRamCircuit` now overrides it to size the r/w/lk groups
with looking + table lengths. For zero-init tables (heap, stack) the
looking lengths are 0, so the wiring is byte-for-byte identical to the
default.
- The lookup multiplicity must land in `combined_lk_mlt`, so the hint
init circuit is assigned via a new `ZKVMWitnesses::assign_table_circuit_with_lk`
(mirrors `assign_shared_circuit`) and moved before
`finalize_lk_multiplicities` in `generate_witness` (main + GPU-debug
paths), matching the existing ShardRam pre-finalize ordering.
Validated: `cargo check`, `cargo clippy -D warnings`, the `ram_impl`
unit tests — including a new soundness regression
`test_hint_init_rejects_non_canonical_limb` that forces each init limb to
`2^LIMB_BITS` and asserts MockProver rejects the witness via the
`init_v_limb_{i}_in_u16` range lookup (the test fails if the range check
is removed) — and real (non-mock) prove+verify of fibonacci with hints,
single shard and 61-shard. The proof balances the global logup including
the hint circuit's range-check records, confirming the looking lookups
are enforced (a dropped lookup would unbalance the recorded multiplicity).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
93a5895 to
c22ce1b
Compare
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.
Problem
HintsTableis the only RAM table with prover-witnessed (non-zero) initvalues — it holds the guest's private hint inputs.
DynVolatileRamTableInitConfig::construct_circuitcreated those init-valuelimbs as raw
WitIns with no range check, so a malicious prover couldsupply non-canonical limbs (
>= 2^16).The load path reads memory via
UInt::new_unchecked(e.g.LWinload_v2.rs) and forwards the limbs to the destination registerunconstrained, so a non-canonical hint word would propagate into the
computation. This is an under-constraint soundness bug: a crafted proof
using out-of-range hint limbs would verify.
Design Rationale
Bind every non-zero-init limb to
LIMB_BITS(u16) inconstruct_circuit,making each reconstructed hint word a canonical u32 — the same range
discipline every other prover-supplied word in the system already follows.
The fix is on the constraint side (the load-bearing surface for soundness);
per-row addresses are already formula-bound structural witins and the table
base (
hint_start_addr) is already range-checked by the verifier'svalidate_mem_state, so this closes the one remaining unconstrained axis (theinit value limbs).
A range check is a looking lookup, which the default
TableCircuit::build_gkr_iop_circuitdoes not wire for table circuits, so thefix needs three coordinated touches (below). For zero-init tables (heap,
stack) the looking lengths are 0, so all wiring stays byte-for-byte
identical to the current behaviour.
Change Highlights
ceno_zkvm/tables/ram/ram_impl.rs— range-check every non-zero-initlimb (
assert_ux::<LIMB_BITS>) inconstruct_circuit; thread an optionalLkMultiplicitythroughassign_instances/assign_instances_dynamicsothe per-limb u16 lookups are recorded. Adds the range check on hint table #999 soundness regression
test.
ceno_zkvm/tables/ram/ram_circuit.rs—DynVolatileRamCircuitoverrides
build_gkr_iop_circuitto size the r/w/lk/zero out-evalgroups with looking + table lengths; adds
assign_instances_with_lk_multiplicities.ceno_zkvm/structs.rs— newZKVMWitnesses::assign_table_circuit_with_lk(mirrors
assign_shared_circuit) to fold a table circuit's own lookupmultiplicity into
lk_mltsbefore finalize.ceno_zkvm/instructions/riscv/rv32im/mmu.rs— routeHintsInitCircuitthrough
assign_table_circuit_with_lk;HeapInitCircuitstays on the plainpath (zero-init, no lookups).
ceno_zkvm/e2e.rs— move the dynamic-init-table assignment beforefinalize_lk_multiplicitiesso the hint range-check lookups land incombined_lk_mlt, matching the existing pre-finalize ShardRam ordering(main + GPU-debug-compare paths).
Rebased onto current
master; merged with #1350's dynamic-init padding guardso the u16 multiplicity is recorded only for real rows
(
i < num_instances && let Some(rec) = rec_opt), consistent with the prefixselector that gates the range-check constraints.
Benchmark / Performance Impact
Not benchmarked — the change adds only
O(hint_words)u16 range-lookupsduring witness generation and leaves the verifier's structural shape
unchanged (zero-init tables produce identical circuits). Prover impact is
negligible relative to the per-shard logup it already performs.
Testing
test_hint_init_rejects_non_canonical_limb:honest (canonical) limbs satisfy every range lookup; forcing any single init
limb to
2^LIMB_BITSmakes theinit_v_limb_{i}_in_u16lookup fall outsidethe u16 table and
MockProverrejects the witness. The test fails if therange check is removed.
61-shard — the global logup balances including the hint circuit's
range-check records (a dropped lookup would unbalance the recorded
multiplicity).
.github/workflows/integration.yml); thefibonacci
--hintsMOCK path is confirmed passing on this branch.Risks and Rollout
under-constraint. No verifier semantic-contract change.
HintsTablegains the lk group.Follow-ups (optional)
PR: make the u32 arithmetic in the verifier's
validate_mem_statehint/heap bound explicit via
checked_add/checked_mul.)Copilot Reviewer Directive (keep this section)
When Copilot reviews this PR, apply
.github/copilot-instructions.mdstrictly.