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

75413 sc high zk proof executor derives blobbasefee from base s da footprint header field allowing invalid output roots

Submitted on Apr 29th 2026 at 01:55:46 UTC by @Brainiac5 for Audit Comp | Base Azul

  • Report ID: #75413

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/base/contracts/tree/v8.1.0/src/multiproof

  • Impacts:

    • Forging or bypassing TEE or ZK proof verification in AggregateVerifier to finalize an invalid state root on L1

    • Draining or stealing funds from the L1 bridge portal through invalid withdrawal proofs constructed against a forged finalized state

    • Circumventing the dispute/challenge mechanism to prevent correction of an invalid proposal before finalization

Description

Brief/Intro

Base disables L2 blob transactions. Its protocol docs explicitly state that BLOBBASEFEE is present but must always push 1 because no L2 blobs are processed. Canonical Base execution implements this rule by hardcoding BlobExcessGasAndPrice { excess_blob_gas: 0, blob_gasprice: 1 } when building the EVM block environment.

The ZK proof executor does not use the same Base-specific rule. For post-Ecotone/Isthmus blocks, it derives the next block's blob gas price from the parent header using Ethereum blob-gas rules. This is wrong after Jovian because Base reuses the header blob_gas_used field to store the block's DA footprint, not Ethereum blob gas.

As a result, an attacker can create a normal high-DA-footprint parent block, then include a transaction in the next block whose contract branches on block.blobbasefee. Canonical Base execution sees 1; the proof executor can see a value greater than 1. The same transaction can therefore produce a different output root in the ZK proof path than it produces on real Base.

Vulnerability Details

Base documents the opcode semantics as:

Canonical Base EVM environment construction follows that rule:

The same hardcoded value is used in the normal header path, next-block path, and payload path:

  • base/crates/execution/evm/src/lib.rs:81

  • base/crates/execution/evm/src/lib.rs:112

  • base/crates/execution/evm/src/lib.rs:292

The proof executor builds the block environment differently:

Relevant code:

  • base/crates/proof/executor/src/builder/env.rs:104

  • base/crates/proof/executor/src/builder/env.rs:112

That logic is valid for Ethereum blob-gas headers, but not for Base post-Jovian headers. Jovian intentionally stores DA footprint in blob_gas_used:

Relevant code:

  • base/crates/execution/evm/src/build.rs:83

  • base/crates/proof/executor/src/builder/assemble.rs:64

  • base/crates/common/evm/src/executor/block_executor.rs:171

  • base/crates/common/evm/src/executor/block_executor.rs:306

The proof executor therefore treats a Base DA-footprint value as Ethereum blob gas. Once the parent DA footprint is above the Prague blob-gas target, BlobExcessGasAndPrice::new() returns a blob gas price greater than 1, contradicting Base's canonical BLOBBASEFEE semantics.

Exploit Path

1

1. Create a high-DA-footprint parent block

The attacker creates or waits for a normal Base L2 parent block with high DA footprint. This is reachable with calldata-heavy transactions; after Jovian, Base stores that DA footprint in the parent header's blob_gas_used.

2

2. Include a contract that branches on block.blobbasefee

In the next L2 block, the attacker calls a contract that branches on block.blobbasefee.

3

3. Canonical Base follows the harmless branch

Canonical Base execution sees block.blobbasefee == 1, so the contract takes the harmless branch.

4

4. The proof executor follows the proof-only branch

The ZK proof executor derives blob gas price from the parent header's DA-footprint value and can see block.blobbasefee > 1, so the same transaction takes a proof-only branch.

5

5. The proof-only branch creates attacker-controlled effects

The proof-only branch can write attacker-controlled storage or call the real L2ToL1MessagePasser.initiateWithdrawal(...).

6

6. The range proof computes a forged output root

The range proof computes an output root for the proof executor's non-canonical state transition.

7

7. The aggregation program commits that root

The aggregation program commits that output root into the same public-input journal that AggregateVerifier verifies.

8

8. AggregateVerifier accepts the proof

AggregateVerifier accepts the ZK proof for the forged output root. Sepolia game type 621 was deployed with PROOF_THRESHOLD=1, so one accepted proof is enough for resolution after the finalization delay.

9

9. The portal can finalize a forged withdrawal

If the proof-only branch created an L2ToL1MessagePasser withdrawal marker, OptimismPortal2.proveWithdrawalTransaction() can prove the forged storage slot against the accepted output root and finalization can release L1 bridge funds.

Impact Details

This is a proof-system soundness failure. The ZK proof can be valid for the proof executor's transition while the transition is not the canonical Base transition.

The dangerous downstream path is the same bridge-root flow used by normal withdrawals:

  • AggregateVerifier._verifyZkProof() hashes proposer, L1 origin, start root, end root, L2 sequence range, intermediate roots, config hash, and ZK_RANGE_HASH.

  • ZK_VERIFIER.verify() accepts the aggregate proof for that journal.

  • A resolved DEFENDER_WINS game can become claim-valid in AnchorStateRegistry.

  • OptimismPortal2.proveWithdrawalTransaction() proves the withdrawal storage slot under the game's root claim.

  • OptimismPortal2.finalizeWithdrawalTransactionExternalProof() then releases funds if the game remains claim-valid.

Relevant downstream code:

  • base/crates/proof/succinct/utils/client/src/client.rs:188

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

  • base/crates/proof/succinct/programs/aggregation/src/main.rs:102

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

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

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

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

  • contracts/src/L1/OptimismPortal2.sol:385

  • contracts/src/L1/OptimismPortal2.sol:549

This does not require:

  • Compromising Base-operated infrastructure.

  • A leaked TEE or ZK key.

  • An invalid SP1 proof.

  • A privileged address.

  • An upstream OP Stack bug.

The emergency ability to blacklist, retire, pause, or replace a verifier may reduce exploitability after detection, but the vulnerability is that Base's in-scope ZK proof executor accepts a state transition that canonical Base execution rejects.

Why This Is In Scope

The Immunefi scope page lists Base Sepolia after the April 20, 2026 Azul activation as the competition environment and includes Offchain Components, Base Azul, and Implementation Contracts. It also calls out proof-system integration and Base-native execution logic as high-value areas. Base mainnet is a planned target deployment, but not the competition environment.

This report is in scope because:

  • The root cause is in base/base proof executor code, not an out-of-scope actions, devnet, baseup, or etc folder.

  • The bug is Base-specific: Base's Jovian DA-footprint reuse of blob_gas_used conflicts with the proof executor's Ethereum blob-gas calculation.

  • The report does not depend on core Op-Succinct circuit soundness. It depends on Base's integration code feeding the wrong block environment to the execution proof.

  • The downstream impact crosses into the in-scope AggregateVerifier and OptimismPortal2 verification/finalization boundary.

  • The PoC is runnable locally and does not touch public testnet/mainnet.

This is not an upstream OP Stack issue. The mismatch exists because Base changed the header semantics for Jovian DA footprint while the Base proof executor still treats blob_gas_used as Ethereum blob gas for BLOBBASEFEE.

References

  • Immunefi Base Azul scope: https://immunefi.com/audit-competition/audit-comp-base-azul/scope/

  • Base execution spec for disabled L2 blobs and BLOBBASEFEE: base/docs/specs/pages/protocol/execution/index.md:446-448

  • Canonical Base EVM hardcodes blob gas price to 1: base/crates/execution/evm/src/lib.rs:81-84

  • Canonical next-block env hardcodes blob gas price to 1: base/crates/execution/evm/src/lib.rs:112-115

  • Canonical payload env hardcodes blob gas price to 1: base/crates/execution/evm/src/lib.rs:292-295

  • Proof executor derives blob gas price from parent header: base/crates/proof/executor/src/builder/env.rs:104-115

  • Jovian header construction stores DA footprint in blob_gas_used: base/crates/execution/evm/src/build.rs:83-87

  • Proof executor assembled headers also store DA footprint in blob_gas_used: base/crates/proof/executor/src/builder/assemble.rs:64-65

  • Jovian DA footprint is charged per transaction: base/crates/common/evm/src/executor/block_executor.rs:171-190

  • Sepolia activates Jovian and Azul: base/crates/common/chains/src/config.rs:332-333

  • Sepolia max gas limit: base/crates/common/chains/src/config.rs:356

  • Sepolia multiproof game type and proof threshold: contract-deployments/sepolia/2026-04-20-activate-multiproof/.env:6-16

  • Apr 23 ZK config update in the contract-deployments repo on origin/main: sepolia/2026-04-23-update-zk-config/.env

  • AggregateVerifier ZK journal construction: contracts/src/multiproof/AggregateVerifier.sol:917-932

  • Portal withdrawal proof/finality checks: contracts/src/L1/OptimismPortal2.sol:385-414, contracts/src/L1/OptimismPortal2.sol:518-550

  • Runnable state-divergence PoC: base/crates/proof/succinct/utils/client/tests/audit_blobbasefee_withdrawal_impact.rs

  • Runnable portal-payout PoC: contracts/test/multiproof/AuditBlobbasefeeForgedWithdrawalEndToEnd.t.sol

Suggested Fix

Build the proof executor's blob_excess_gas_and_price the same way canonical Base execution does:

Do not feed Base's Jovian DA-footprint blob_gas_used header value into EIP-4844/EIP-7691 blob gas pricing.

Add regression tests that:

  • Assert prepare_block_env() returns blob_gasprice == 1 for all post-Ecotone Base blocks.

  • Execute a contract that branches on BLOBBASEFEE under canonical and proof-executor environments and assert identical state roots.

  • Use a Jovian parent header with nonzero/high DA footprint to prevent this exact regression.

Confidence

Confirmed locally with two runnable PoCs.

PoC 1 proves the exact attacker-controlled state divergence: canonical Base semantics keep BLOBBASEFEE at 1 and no withdrawal marker is created, while the proof-executor environment derives BLOBBASEFEE > 1 from Base's DA-footprint blob_gas_used field and creates the real L2ToL1MessagePasser.sentMessages[withdrawalHash] marker.

PoC 2 proves the downstream bridge impact once that forged output root is accepted by the multiproof game: OptimismPortal2 proves and finalizes the withdrawal and the target receives 1 ETH.

Note: PoC for this report and detailed impact extraction & reproduction was aided with the help of AI.

Proof of Concept

This report has two runnable PoCs.

Fresh-checkout reproduction note:

  • The two PoC files listed below are audit-only test files and are not present in the upstream repositories. Include them as attachments, or paste them into the exact paths shown before running the commands.

  • PoC 1 does not require Cargo.toml changes; it uses dependencies already available to base-proof-succinct-client-utils.

  • The --config 'target.aarch64-apple-darwin.rustflags=["-C","link-arg=-fuse-ld=ld"]' flag is a local macOS linker workaround. Linux triage environments can omit it if unnecessary.

  • PoC 2 uses the repository's existing OptimismPortal2 FFI helper, the same helper used by existing portal tests.

PoC 1 proves the root-cause execution divergence and the attacker impact on L2 state. The same attacker transaction is executed twice with the same Base Azul precompile table and same L2ToL1MessagePasser runtime bytecode. The only difference is the block blob_gasprice value:

  • Canonical Base env: blob_gasprice = 1.

  • Proof-executor env: blob_gasprice is derived from a Jovian DA-footprint value in the parent header.

PoC 1 file:

Run PoC 1:

Observed result for PoC 1:

Important PoC 1 assertions:

The attack contract condition is only:

So PoC 1 shows the actual attacker capability:

  • On canonical Base, no withdrawal marker exists and the attacker contract keeps its 1 ETH.

  • In the proof-executor state, the withdrawal marker exists, msgNonce increments, and the message passer receives 1 ETH.

PoC 2 proves the downstream L1 payout path once that forged withdrawal output root is accepted by AggregateVerifier.

PoC 2 file:

Run PoC 2:

Observed result for PoC 2:

Important PoC 2 assertions:

PoC 2 intentionally uses a strict local verifier that accepts only the exact expected ZK public-input journal for the forged output root. PoC 1 proves the Base proof-executor path can create that non-canonical withdrawal state from the BLOBBASEFEE mismatch. PoC 2 proves that once the root is accepted by the normal game/portal flow, the bridge payout succeeds.

Full PoC 1 Source

Show full PoC 1 source

Save as:

Full PoC 2 Source

Show full PoC 2 source

Save as:

Expected vs Actual

Expected:

Every Base execution path, including the ZK proof executor, should expose BLOBBASEFEE == 1 for Cancun-and-later Base blocks because L2 blobs are disabled.

Actual:

Canonical Base execution hardcodes BLOBBASEFEE == 1, but the ZK proof executor derives it from the parent header. After Jovian, the parent header's blob_gas_used is a DA-footprint value, so the proof executor can expose BLOBBASEFEE > 1 for the same block where canonical Base exposes 1.

Was this helpful?