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

75363 bc medium zk proof program uses a non canonical precompile table breaking soundness across every base hardfork and enabling permanent chain split

Submitted on Apr 28th 2026 at 18:07:49 UTC by @oxeix for Audit Comp | Base Azul

  • Report ID: #75363

  • Report Type: Blockchain/DLT

  • Report severity: Medium

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

  • Impacts:

    • Unintended chain split (network partition)

Description

Brief/Intro

The SP1/ZKVM proof program used by Base's ZK proving service runs EVM execution against OpZkvmPrecompiles, a precompile provider whose table diverges from the canonical Base precompile table (BasePrecompiles) on every hardfork from Fjord onward, and additionally activates pre-Fjord precompiles that do not exist on chain. Because this provider is wired into ZkvmOpEvmFactory, which is the executor backend of the production SP1 program, an attacker can craft a transaction whose execution outcome (success/revert, gas used, return data) differs between an honest Base node and the generated ZK proof. Once submitted on L1, the unsound ZK proof finalizes a state that no honest L2 node can reproduce - producing a permanent chain split.

Vulnerability Details

https://github.com/base/base/blob/v0.8.0-rc.28/crates/succinct/utils/client/src/precompiles/mod.rs#L66-75

fn get_precompiles() -> Vec<PrecompileWithAddress> {
    vec![
        bn254::add::ISTANBUL,
        bn254::mul::ISTANBUL,
        bn254::pair::ISTANBUL,        // pre-Granite, no input-size cap
        secp256k1::ECRECOVER,
        secp256r1::P256VERIFY,        // pre-Osaka pricing, RIP-7212 only
        kzg_point_evaluation::POINT_EVALUATION,
    ]

https://github.com/base/base/blob/v0.8.0-rc.28/crates/succinct/utils/client/src/precompiles/mod.rs#L100

The extend call is keyed by precompile address, so any precompile in get_precompiles() whose address collides with one in base overrides the spec-correct variant.

Concrete divergences:

  • AZUL

    • Precompile: 0x100 P256VERIFY

    • Canonical: secp256r1::P256VERIFY_OSAKA (EIP-7951, base gas 6 900)

    • ZK (OpZkEVMPrecompiles) secp256r1::P256VERIFY (RIP-7212, base gas 3 450)

    • Observable difference: At 5 000 gas: canonical OutOfGas, ZK Ok(gas_used=3450)

  • GRANITE, HOLOCENE

    • Precompile: 0x08 bn254 pair

    • Canonical: bn254_pair::GRANITE (rejects input > 112 687 B with Bn254PairLength)

    • ZK: bn254::pair::ISTANBUL (no cap)

    • Observable difference: At 112 704 B input: canonical Bn254PairLength, ZK Ok(0x…01)

  • JOVIAN, AZUL

    • Precompile: 0x08 bn254 pair (and bls12_381 G1/G2/Pairing)

    • Canonical: bn254_pair::JOVIAN (rejects > 81 984 B) plus bls12_381 Jovian caps

    • ZK: Base built from Self::isthmus() (line 102 in mod.rs), then overridden with Istanbul pair

    • Observable difference: At 82 176 B input: canonical Bn254PairLength, ZK Ok(0x…01)

  • BEDROCK, REGOLITH, CANYON, ECOTONE

    • Precompile: 0x100 P256VERIFY

    • Canonical: precompile not present (RIP-7212 not yet active)

    • ZK: precompile present

    • Observable difference: A CALL to 0x100: canonical = empty-EOA call, ZK = P256 verifier

The ZK proof's contract with L1 is: "this output state is the result of applying canonical Base STF to the input state and the supplied transactions." The dispute game's resolution function on L1 trusts SP1 verifier output for state finalization. If the ZK program's EVM disagrees with canonical Base on any precompile observable, that contract is broken: a proposer can produce a valid SP1 proof for an output state that no honest Base node could ever reach, and the L1 game will accept it.

Impact Details

This is a soundness break in the proof program resulting in an unintended permanent chain split requiring hard fork (Critical). Every Base fullnode runs canonical BasePrecompiles. The L1-finalized state from the ZK game runs OpZkvmPrecompiles. The first time a transaction touches any of the four divergent surfaces above (e.g., a pre-Osaka-priced P256 call cheap enough to land on L2 but too expensive on canonical Azul), the L1 truth and the L2 truth diverge. Recovery requires re-rolling the SP1 program and the L1 game's verifier key - a hard-fork-equivalent migration. Both branches' history must then be reconciled by social fork.

References

https://github.com/base/base/blob/v0.8.0-rc.28/crates/succinct/utils/client/src/precompiles/mod.rs

Proof of Concept

The following tests demonstrate the chain split at transaction level. All are needed to be pasted into crates/proof/succinct/utils/client/src/precompiles/mod.rs:

Note that case3 (Jovian bn254) is skipped as it's same precompile, same input-length error, same return value as Case 2.

PoC #1 — AZUL P256VERIFY gas pricing

PoC #2 — GRANITE, HOLOCENE

PoC #4 — BEDROCK P256 chain split

Was this helpful?