For the complete documentation index, see llms.txt. This page is also available as Markdown.

75533 bc high sp1 prover precompile map drops jovian cap and osaka pricing enabling a forged outputroot and 10 bridge drain wherever the zk fast path is active

Submitted on Apr 29th 2026 at 17:01:39 UTC by @Oxodus for Audit Comp | Base Azul

  • Report ID: #75533

  • Report Type: Blockchain/DLT

  • Report severity: High

  • Target: https://github.com/base/base/tree/v0.8.0-rc.28

  • Impacts:

    • Unintended permanent chain split requiring hard fork (network partition requiring hard fork)

    • Direct loss to Base or users ≥ 10% of funds held within Bridge

Description

Brief/Intro

The SP1 ZK prover's per-spec precompile registry in base/crates/succinct/utils/client/src/precompiles/mod.rs calls precompiles.extend(get_precompiles()) after cloning the spec-correct BasePrecompiles set. Because revm's Precompiles::extend overwrites entries by address, this silently replaces the Jovian-capped bn254-pair (input cap 81 984 B) with vanilla bn254::pair::ISTANBUL (uncapped) and the Osaka-priced secp256r1::P256VERIFY_OSAKA (~6 900 gas) with vanilla secp256r1::P256VERIFY (3 450 gas). A second bug mismaps OpSpecId::JOVIAN to BasePrecompiles::isthmus() instead of jovian(). The live execution client and the FPVM (TEE) prover both apply the correct caps and pricing, so the SP1 prover proves a state-transition that no honest Base node ever derives. The divergent outputRoot finalizes through AggregateVerifier, propagates to AnchorStateRegistry, and authorizes withdrawals via OptimismPortal2 against state the attacker chose.

Vulnerability Details

The bug is the interaction of two lines in base/crates/succinct/utils/client/src/precompiles/mod.rs. First, the per-spec base set at lines 90–98 mismaps JOVIAN to the Isthmus precompile family:

let base = match spec {
    ...
    OpSpecId::ISTHMUS | OpSpecId::JOVIAN => BasePrecompiles::isthmus().clone(),
    OpSpecId::BASE_V1 => BasePrecompiles::base_v1().clone(),
};

Second, immediately after, line 100 unconditionally overwrites the spec-correct entries with Istanbul-era / pre-Osaka constants:

get_precompiles() (lines 65–75) returns:

Revm's Precompiles::extend is a HashMap::insert per entry, overwriting by address. The same overwrite primitive is used inside BasePrecompiles::base_v1() itself to upgrade pricing (extend([modexp::OSAKA, secp256r1::P256VERIFY_OSAKA])), so its semantics are not in doubt; the SP1 path immediately tramples that upgrade.

Net effect, per address: at 0x08 the live EL and FPVM run bn254_pair::JOVIAN with the 81 984-byte cap, while the SP1 prover runs vanilla bn254::pair::ISTANBUL with no cap. At 0x100 the live EL and FPVM run P256VERIFY_OSAKA at ~6 900 gas, while the SP1 prover runs vanilla P256VERIFY at 3 450 gas. The same divergence applies on JOVIAN: the base set is wrongly Isthmus (bn254-pair cap 112 687 + Isthmus BLS variants instead of Jovian variants), then extend overwrites bn254-pair to entirely uncapped.

The SP1 range program (base/crates/succinct/programs/range/ethereum/src/main.rs) and aggregation program use ZkvmOpEvmFactory (base/crates/succinct/utils/client/src/witness/executor.rs), which wires OpZkvmPrecompiles directly into the proof EVM. OpZkvmPrecompiles::run (mod.rs:152-225) executes whatever precompile is in the map (the precompile.execute(input_bytes, inputs.gas_limit) call) — there is no separate cap or pricing guard.

The trigger is a single L2 transaction. An attacker deploys a contract whose function staticcalls 0x08 with len > 81_984 bytes (multiple of 192, syntactically-valid pairing input):

  • Live EL: bn254_pair::JOVIAN returns Bn254PairLengthSTATICCALL returns 0

  • SP1 prover: bn254::pair::ISTANBUL runs the pairing → STATICCALL returns 32-byte success

The contract SSTOREs the result, the live and proven post-state roots diverge, and the SP1 prover honestly proves its own divergent execution. On-chain AggregateVerifier._verifyZkProof accepts the proof against ZK_RANGE_HASH (= the program's vkey). The game finalizes against the prover's outputRoot; closeGameAnchorStateRegistry.setAnchorState adopts the forged root; OptimismPortal2.proveWithdrawalTransaction accepts withdrawal Merkle proofs the attacker pre-planted in the divergent block.

The author's intent contradicts the implementation: the FPVM (TEE) provider in base/crates/proof/fpvm-precompiles/src/precompiles/provider.rs correctly distinguishes JOVIAN => BasePrecompiles::jovian() and uses accelerated wrappers (e.g., fpvm_bn128_pair_jovian) that re-enforce the cap. The SP1 path was clearly meant to mirror that pattern.

Impact Details

  • Direct bridge loss ≥ 10%: On any network where multiproof + ZK finalization is active, a single ZK proof under verifyProposalProof finalizes a forged outputRoot. The forged anchor authorizes any proveWithdrawalTransaction call whose Merkle proof matches the divergent state. The attacker plants L2ToL1MessagePasser-style storage in their controlled L2 contract during the divergent block and withdraws bridge funds without bound. This trivially exceeds 10% of bridge TVL since there is no rate or amount cap on a successful withdrawal proof.

  • Permanent chain split requiring hard fork: Once a forged anchor is adopted on L1, honest Base nodes cannot derive the canonical chain past that anchor without ignoring on-chain finalization. Recovery requires either deploying a fixed prover binary, rotating ZK_RANGE_HASH to the new vkey, and manually rolling back in-flight forged anchors and any withdrawals they authorized — or a coordinated hardfork.

  • The issue has two independent triggers; (i) bn254-pair cap divergence on JOVIAN / BASE_V1: any L2 transaction that staticcalls 0x08 with input > 81 984 bytes diverges between live EL and SP1 prover. (ii) P256 gas-cost divergence on BASE_V1: 3 450 vs 6 900 gas, so every P256-using contract becomes a potential trigger — any branch on gasleft() after STATICCALL(0x100, …) flips between live EL and prover.

References

  • base/crates/succinct/utils/client/src/precompiles/mod.rs

    • get_precompiles() returning vanilla Istanbul / pre-Osaka constants — lines 65–75

    • JOVIAN mismapped to BasePrecompiles::isthmus() — line 96

    • precompiles.extend(get_precompiles()) overwrite — line 100

    • OpZkvmPrecompiles::run direct execution (no cap guard) — lines 152–225 (the precompile.execute(…) call sits inside this body with no surrounding cap/pricing logic)

  • base/crates/common/evm/src/precompiles.rs — live EL BasePrecompiles::jovian() / base_v1() (correct caps and Osaka pricing) — lines 91–131 and bn254_pair::JOVIAN definition lines 197–211

  • base/crates/proof/fpvm-precompiles/src/precompiles/provider.rs — FPVM correct mirror with per-spec accelerated wrappers — lines 45–67

  • base/crates/proof/fpvm-precompiles/src/precompiles/bn128_pair.rsfpvm_bn128_pair_jovian re-enforces BN256_MAX_PAIRING_SIZE_JOVIAN = 81_984 — lines 17, 73–86

  • base/crates/succinct/utils/client/src/witness/executor.rsZkvmOpEvmFactory::new() wired into BaseExecutor — line 148

  • base/crates/succinct/programs/range/ethereum/src/main.rs — range program entrypoint — line 34

  • revm Precompiles::extend overwrite-by-address semantics (used as the standard upgrade primitive inside BasePrecompiles::base_v1() itself).

Proof of Concept

Append the following block to the existing mod tests { … } in base/crates/succinct/utils/client/src/precompiles/mod.rs:

Run with:

Output:

Was this helpful?