ECSM operand addresses: the AIR accepts 7 addresses the executor rejects (implementation deviates from spec/src/ecsm.toml) - #902
ECSM operand addresses: the AIR accepts 7 addresses the executor rejects (implementation deviates from spec/src/ecsm.toml)#902MauroToscano wants to merge 2 commits into
Conversation
Demonstrates that the ECSM AIR accepts operand low limbs the executor rejects with EcsmAddressOverflow. The charged mechanism (unconstrained address limbs => forgeable) is NOT the finding and was refuted: a non-canonical limb is self-defeating on the Memory bus. What survives is an off-by-N: the executor guards +31, the AIR only needs +24, because MEMW carries the last dword's trailing bytes correctly. a0 low limb executor proof verifies 0xFFFF_FFE0 accepts yes (positive control) 0xFFFF_FFE1 EcsmAddressOverflow yes <- GAP 0xFFFF_FFE7 EcsmAddressOverflow yes <- GAP 0xFFFF_FFE8 EcsmAddressOverflow NO (LogUp imbalance) The off=24 / off=25 discrimination is the +-1 the mechanism predicts, so the harness is not rubber-stamping. Only the executor's guard is bypassed (env var); trace builder, AIRs and verifier are untouched. Gain is halt-vs-continue on an attacker-authored ELF: nothing is forged, and the bytes land at arithmetically correct 64-bit addresses. Adjudged LOW. Fix direction: the AIR moves (add the range check), NOT the executor -- the two guards accept incomparable sets, so relaxing the executor to +24 would accept H=0xFFFF_FFFF cases the AIR cannot prove, turning a soundness gap into a liveness bug.
`cargo fmt --check` failed CI on two `assert!` calls whose message argument pushed them over the line limit. Formatting only — no assertion, no test, and in particular no part of the `ecsm_addr_ok` env-var gate is touched. That gate is deliberate: it is what lets the PoC run at all, and this branch is a demonstration, not a merge candidate.
|
Adjudication finished. Two additions, one of which settles the fix-direction question with evidence I did not have when I opened this. The
|
|
Converted to draft — this branch is a demonstration and must not merge as-is (it env-gates the executor's To be clear about what is and is not fixed: this finding has no fix PR. It is unrelated to #909, which closes a different class (trace-opening widths, in the STARK verifier). This one is ECSM's operand base addresses in the VM prover, and it is still open on Keeping the branch as the executable record of the finding. The fix, when someone picks it up, is small and fully specified:
Severity remains LOW and it is nobody's blocker: the guest heap tops out at Happy to open the fix as its own PR against |
Summary
The ECSM AIR accepts operand addresses that the executor rejects. A witness whose operand low limb
falls in a 7-value window balances every bus and verifies, while
cli executeon the sameinput aborts with
EcsmAddressOverflow.The spec got this right — this is an implementation deviation, not a spec gap.
spec/src/ecsm.tomlspecifies both the address range checks and carry-correct derivation of theper-access addresses;
prover/src/tables/ecsm.rsimplements neither. Details inRoot cause below. Nothing needs to be
designed to fix this — the constraints already exist on paper and simply are not built.
The executor's
ecsm_addr_okguard is a compensating control for that deviation, and because theguard and the AIR constrain different things, their accepted sets drifted apart — which is where
the window comes from.
One caveat on provenance:
spec/src/ecsm.tomlcurrently lives on thespec/ecsmbranch and has notbeen merged into
spec/main, so the constraints are specified but not landed. That is worthresolving alongside the code, since an unlanded spec is easy for a second implementation to miss.
Severity is low: nothing is forged, no address is aliased, and the reachable gain is
halt-vs-continue on an attacker-authored program. Filing it because it is a real
prover/verifier-vs-executor divergence in a shipped table, and because the spec deviation behind it
is worth correcting on its own terms.
The divergence
ecsm_addr_ok(addr, 31)(executor/src/vm/instruction/execution.rs) requires(addr mod 2^32) + 31 < 2^32, i.e.lo32 <= 0xFFFF_FFE0.The AIR only needs
lo32 <= 0xFFFF_FFE7. ECSM sends its four doubleword accesses asbase_lo = ADDR_*_0 + 8i,base_hi = ADDR_*_1— a bare field addition on the low limb with nocarry column. Each of those four bases must therefore be a canonical low limb, so the binding
constraint is
lo32 + 24 < 2^32. The trailing 7 bytes of the last doubleword are fine, becausegeneral MEMW carries bytes 1..7 correctly (
prover/src/tables/memw.rs: byte 0 uses the raw(BASE_ADDRESS_0, BASE_ADDRESS_1)pair, bytes 1..7 usebase_0 + i - 2^32*carry[i],base_1 + carry[i]).Gap =
lo32 ∈ [0xFFFF_FFE1, 0xFFFF_FFE7], 7 values per operand, plus a mapped page at highlimb
hi + 1.Reproduction
a0low limb0xFFFF_FFE0(off 32)0xFFFF_FFE1(off 31)EcsmAddressOverflow0xFFFF_FFE7(off 25)EcsmAddressOverflow0xFFFF_FFE8(off 24)EcsmAddressOverflowThe off=24 / off=25 boundary is the load-bearing detail: it is exactly the ±1 the mechanism
predicts (byte 0 of doubleword 3 sits at
ADDR_0 + 24with no carry), which is what distinguishes areal mechanism from a harness that accepts everything.
Two supporting tests:
poc_plain_sd_across_limb_boundary— a plain 8-bytesdat0xFFFF_FFFCspanning into highlimb 1 is executor-accepted and provable. The VM supports limb-crossing accesses generally;
only the ECSM ecall refuses them.
poc_ecsm_limb_gap_31_shows_pages_above_2p32— the off=31 proof's declared runtime pages are0xFFFC_0000and0x1_0000_0000, confirming the write really did spill past 2^32.Verification goes through the production
prove_with_options/verify_with_optionspath. Theonly source change is the env-var gate on
ecsm_addr_ok— trace builder, AIR definitions andverifier are untouched. Bypassing the executor guard is the point: a malicious prover builds traces
with its own tooling and is not bound by the reference executor, so the question that matters is
whether the verifier accepts the trace.
Root cause: the implementation does not follow the spec
spec/src/ecsm.toml(onspec/ecsm) specifies two things the implementation omits.1. Range-check every address, not just the base —
ec:c:range_addr_xG:IS_HALFover i = 0..3: all four doubleword addresses, both halves each.2. Derive the per-doubleword addresses with real addition —
ec:c:extrapolate_addr_xG:So in the spec each
addr_xG[i]is its own DWordWL column, tied to the base through theADDtemplate — full 64-bit addition, carry into the high limb included.
prover/src/tables/ecsm.rshas noaddr_xG[i]columns at all. It hasADDR_XG_0/ADDR_XG_1andbuilds each access base as a
LinearTermADDR_XG_0 + 8iwithbase_hi = ADDR_XG_1passedthrough unchanged. No per-access column, no
ADD, no carry, noIS_HALF. The same applies toaddr_kandaddr_xR.With the spec's constraints in place the window cannot exist:
IS_HALFon all four addresses forceseach to be a canonical 32-bit-limbed value, and
ADDpropagates the carry, so the AIR's acceptedset is the executor's.
For contrast,
KECCAKdoes implement this shape — it carry-propagates its lane addresses with realcarry pairs and needs no executor precondition.
Impact
columns compute the arithmetically correct 64-bit addresses, so the bytes land where a correct
RV64 machine would put them.
attacker-authored ELF that places such an address in an operand register, and the deployed guest
is pinned by the DECODE commitment.
ecsm_oracleuses[u8; 32]stack arrays, andSTACK_TOP = 0xFFFF_FFFF_FFFF_FFF0puts them at high limb0xFFFF_FFFF, wherehi + 1 = 2^32is not representable by any page — so the stack case is blocked outright, independent of the low
limb.
Suggested direction
Implement
ec:c:range_addr_*andec:c:extrapolate_addr_*as written: per-access address columnsderived via
ADD, withIS_HALFon each. That makes the AIR self-contained and letsecsm_addr_okbe relaxed or dropped.Do not "fix" this by relaxing the executor guard from
+31to+24. The two guards acceptincomparable sets, not nested ones. The AIR is more permissive on the low limb but strictly
less permissive on the high limb: whenever
lo32 >= 0xFFFF_FFE1the tail bytes carry, so theAIR needs
hi <= 0xFFFF_FFFE. Relaxing the executor to+24would accepthi = 0xFFFF_FFFFoperands the AIR cannot prove, converting a low-severity soundness gap intoa liveness bug on honest executions.
+31is the unique offset-only bound that ishi-independent:it guarantees no byte of the 32-byte operand ever crosses
2^32, so no carry is needed anywhere.Relatedly,
addr_limb_ok's doc comment states that a straddling operand "makes the traceunprovable". That is true of the
+8ibase derivation but false of MEMW's within-row bytes, andit is the assumption the 7-value window lives in.
Note on scope
The
HINTtable added in #876 reproduces the sameADDR_OUT_0 + 8ishape and has the same window;that instance is addressed separately. This PR is only about ECSM on
main.