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

75101 bc critical zk proof executor uses wrong azul precompile semantics allowing proofs for invalid l2 state roots

#75101 [BC-Critical] ZK Proof Executor Uses Wrong Azul Precompile Semantics, Allowing Proofs For Invalid L2 State Roots

Submitted on Apr 27th 2026 at 08:46:05 UTC by @Brainiac5 for Audit Comp | Base Azul

  • Report ID: #75101

  • Report Type: Blockchain/DLT

  • Report severity: Critical

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

  • Impacts:

    • A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk

Description

Brief

The zkVM proof executor does not use the same Azul precompile table as normal Base execution. In Azul, normal Base execution rejects bn254Pairing inputs above the Jovian/Azul size limit of 81,984 bytes. The proof executor starts from the Azul precompile table but then overwrites the Base-specific bn254Pairing precompile with the vanilla Istanbul precompile, which does not enforce that Base limit. This means the same L2 transaction can execute one way on real Base nodes and another way inside the proof program. A proof generated over the proof executor can therefore attest to an output root that normal Base execution would not produce.

Vulnerability Details

The normal Base precompile table correctly installs fork-specific precompiles. For Azul, it starts from Jovian and then applies the Azul Osaka changes:

OpSpecId::JOVIAN => Self::jovian(),
OpSpecId::AZUL => Self::azul(),

For Jovian, bn254Pairing is replaced with the Base-specific version:

That bn254_pair::JOVIAN precompile rejects inputs longer than 81,984 bytes:

The proof executor uses OpZkvmPrecompiles, not the normal BasePrecompiles, when executing blocks for witness/proof generation:

ZkvmOpEvmFactory installs OpZkvmPrecompiles:

The bug is in OpZkvmPrecompiles::get_or_create_precompiles. It first chooses the Base fork table, but then blindly extends it with generic accelerated precompiles:

Because extend() replaces the precompile at the same address, the Azul proof executor loses the Base-specific bn254_pair::JOVIAN limit and uses bn254::pair::ISTANBUL instead.

The result is not only a local precompile discrepancy. The PoC below deploys a tiny contract that branches on whether STATICCALL(0x08) succeeds:

  • Normal Base Azul execution rejects the oversized bn254Pairing input, so the contract returns/stores 2.

  • The zkVM proof executor accepts the exact same transaction and calldata, so the contract returns/stores 1.

That is a state-transition mismatch. A state root produced by the proof executor for this transaction is not the state root produced by normal Base execution.

Impact Details

An attacker can build an L2 transaction that calls a contract which branches on the result of an oversized bn254Pairing precompile call.

The concrete exploit chain is:

  1. The transaction calls bn254Pairing with a valid input of 82,176 bytes.

  2. 82,176 > 81,984, so normal Base Azul execution rejects the precompile call.

  3. The same call succeeds in the proof executor because OpZkvmPrecompiles overwrote the Azul precompile with the vanilla Istanbul one.

  4. The contract updates state differently depending on the precompile result.

  5. The output root generated by the proof executor differs from the output root generated by normal Base nodes.

  6. A ZK proof over that wrong execution can still satisfy AggregateVerifier, because the verifier checks the proof image/journal, not whether the proof program's precompile table matches the Base node precompile table.

  7. If accepted and finalized, L1 can trust an invalid L2 state root.

This does not rely on the rejected TEE-only bootstrap issue, guardian inaction, old games, or a compromised admin key. The issue is in the prover execution semantics themselves.

This also does not require proving direct bridge theft to be serious. The in-scope impact explicitly includes finalizing an invalid state root on L1 through proof verification. Once an invalid root is finalized, bridge withdrawals and any L1 logic depending on that root inherit the wrong state.

Suggested Fix

Do not overwrite Base fork-specific precompiles with generic accelerated precompiles.

Specific fixes:

  • For OpSpecId::AZUL, keep bn254_pair::JOVIAN for bn254Pairing and keep secp256r1::P256VERIFY_OSAKA for P256.

  • For OpSpecId::JOVIAN, do not map to BasePrecompiles::isthmus(); use BasePrecompiles::jovian() and preserve all Jovian limits.

  • If accelerated precompiles are required, wrap the accelerated implementation with the same Base fork-specific bounds and gas semantics before installing it.

  • Add fork-by-fork tests that compare every precompile address/id installed by BasePrecompiles and OpZkvmPrecompiles.

  • Add transaction-level regression tests like the PoC above, not only direct precompile table tests.

References

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

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

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

  • base/crates/common/evm/src/precompiles/provider.rs:88

  • base/crates/common/evm/src/precompiles/provider.rs:116

  • base/crates/common/evm/src/precompiles/bn254_pair.rs:22

  • base/crates/proof/succinct/utils/client/src/witness/executor.rs:144

  • base/crates/proof/succinct/utils/client/src/precompiles/factory.rs:57

  • contracts/src/multiproof/AggregateVerifier.sol:917

Note: I used AI for the proof-of-concept writing and for refactoring the report for better structure, as rust can be tricky sometimes.

Proof of Concept

PoC file:

Run:

Observed result:

Full PoC code:

Expected vs Actual

Expected:

The proof executor must execute Azul blocks with exactly the same EVM/precompile semantics as normal Base Azul nodes. If normal Base execution rejects an oversized bn254Pairing input, the proof executor must reject it too.

Actual:

Normal Base Azul execution rejects the oversized bn254Pairing input and the test contract takes branch 2. The proof executor accepts the same input and the same transaction takes branch 1.

Preconditions And Constraints

  • Azul must be active for the block being proven.

  • The L2 transaction must reach a contract that branches on the success/failure of an oversized bn254Pairing call.

  • The attacker must be able to generate or submit a ZK proof that relies on the zkVM proof executor output root.

  • This report does not require an admin mistake, TEE-only bootstrap assumptions, or guardian inaction.

Was this helpful?