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

75627 bc medium incorrect azul zk precompile overrides can make valid base proofs attest to non canonical withdrawal state

Submitted on Apr 30th 2026 at 06:06:20 UTC by @z41zen for Audit Comp | Base Azul

  • Report ID: #75627

  • Report Type: Blockchain/DLT

  • Report severity: Medium

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

  • Impacts:

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

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

Description

Brief/Intro

Base Azul's ZK executor constructs the correct Azul precompile table and then overwrites parts of it with legacy accelerated precompile entries. As a result, the same L2 transaction can execute differently in the ZK executor than it does in canonical Base Azul EL.

This breaks the proof system's core safety property. The ZK executor can attest to a non-canonical output root, including a L2ToL1MessagePasser storage root containing a withdrawal that canonical Base EL never created. Once such an output root is accepted by the L1 bridge flow, the withdrawal can be proven and finalized against that root.

Vulnerability Details

The vulnerable code is in crates/proof/succinct/utils/client/src/precompiles/mod.rs.

For OpSpecId::AZUL, the ZK precompile provider correctly starts from the canonical Base Azul precompile table:

Immediately afterward, it extends that table with get_precompiles():

The problem is that get_precompiles() returns legacy accelerated entries:

Precompiles::extend replaces entries at matching addresses. Therefore, the ZK executor first creates the correct Azul table and then overwrites Azul entries with older rules.

The clearest exploitable mismatch is P256VERIFY at 0x0100:

  • Canonical Base Azul uses P256VERIFY_OSAKA, which costs 6900 gas.

  • The ZK executor overwrites that entry with legacy P256VERIFY, which costs 3450 gas.

Canonical Base Azul defines the correct table in crates/common/evm/src/precompiles/provider.rs:

This incorrect provider is used by the actual ZK execution path. ZkvmOpEvmFactory constructs EVMs with OpZkvmPrecompiles::new_with_spec(spec_id), and the witness executor passes ZkvmOpEvmFactory::new() into BaseExecutor::new(...). Therefore, range proof execution uses the overwritten ZK precompile provider rather than the canonical Base EL provider.

This is externally observable from normal EVM bytecode. A contract can call P256VERIFY at 0x0100 with exactly 3450 gas and branch on the CALL success bit:

  • Under canonical Base Azul EL, the call fails because P256VERIFY_OSAKA requires 6900 gas.

  • Under the ZK executor, the same call succeeds because the overwritten legacy entry costs 3450 gas.

The CALL success bit is visible to user contracts. This means the same transaction can produce different storage writes, logs, and calls depending on whether it is executed by canonical Base EL or by the ZK executor.

The bridge-relevant PoC uses that divergence as a gate:

  1. The contract calls P256VERIFY at 0x0100 with exactly 3450 gas.

  2. If the call succeeds, it calls L2ToL1MessagePasser at 0x4200000000000000000000000000000000000016.

  3. Canonical Base EL does not reach the MessagePasser branch.

  4. The ZK executor does reach the MessagePasser branch for the same bytecode.

The standalone PoC prints:

This proves the state-transition mismatch: canonical Base EL leaves the MessagePasser state untouched, while the ZK executor writes the MessagePasser state for the same transaction.

I also verified the canonical Base EL side with a live base-reth-node --dev instance. The node activated Azul at genesis:

The live EL behavior matches the expected Azul rules:

The contract-level debug_traceCall shows the internal call to 0x0100 failed before the branch could reach MessagePasser:

The L1 finalization harness demonstrates the final bridge step after an output root is accepted. It uses Base contracts' Hashing, SecureMerkleTrie, Types, and SafeCall libraries, generates a valid MessagePasser withdrawal proof with scripts/go-ffi/go-ffi, proves it against an accepted output root, and executes finalizeWithdrawalTransactionExternalProof-style logic.

The L1 harness prints:

This L1 harness is not intended to redeploy the entire production dispute game or verifier stack. It isolates the bridge finalization mechanics after an output root has been accepted. Combined with the ZK executor mismatch, it shows the impact chain: the ZK executor can create a MessagePasser state that canonical Base EL did not create, and the bridge finalization path can execute withdrawals proven against an accepted MessagePasser root.

Impact Details

The direct impact is that the ZK proof system can prove the wrong Base Azul state transition function.

This is more severe than a local gas-accounting mismatch. The proof can be valid for the ZK program while the proved output root is not the canonical Base EL output root.

The bridge-loss path is:

  1. The attacker deploys an L2 contract with a branch controlled by CALL(0x0100, gas=3450).

  2. In the success branch, the contract calls L2ToL1MessagePasser.initiateWithdrawal(...).

  3. Canonical Base EL executes under Azul/Osaka rules. The P256 call fails, so no withdrawal is written.

  4. The ZK executor executes under the overwritten legacy P256 rule. The P256 call succeeds, so the withdrawal branch executes.

  5. The ZK output root can include a messagePasserStorageRoot where sentMessages[withdrawalHash] = true.

  6. The L1 bridge flow verifies withdrawal inclusion against the accepted messagePasserStorageRoot.

  7. OptimismPortal2.finalizeWithdrawalTransactionExternalProof(...) can then execute the L1 target call with the withdrawal value.

This maps to the selected impact because the bridge trusts the accepted output root and verifies withdrawals against the MessagePasser storage root contained in that root.

The relevant L2 contract records withdrawals by storing:

The L1 portal then proves inclusion of that key/value pair against _outputRootProof.messagePasserStorageRoot:

If the accepted root was produced by the incorrect ZK executor, the L1 portal has no independent way to distinguish that the withdrawal was not present in canonical Base EL. It verifies the withdrawal against the accepted root and finalizes it.

References

  • Affected ZK precompile list and overwrite:

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

    • get_precompiles() returns legacy bn254::pair::ISTANBUL and secp256r1::P256VERIFY.

    • get_or_create_precompiles(OpSpecId::AZUL) starts from BasePrecompiles::azul() and then calls precompiles.extend(get_precompiles()).

    • https://github.com/base/base/blob/dd3b5eb575be368280a5cd4e14be57b78c30712e/crates/proof/succinct/utils/client/src/precompiles/mod.rs#L66-L100

  • Canonical Base Azul precompile table:

    • crates/common/evm/src/precompiles/provider.rs

    • BasePrecompiles::azul() installs modexp::OSAKA and secp256r1::P256VERIFY_OSAKA.

    • https://github.com/base/base/blob/dd3b5eb575be368280a5cd4e14be57b78c30712e/crates/common/evm/src/precompiles/provider.rs#L116-L124

  • ZK EVM factory uses the overwritten provider:

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

    • https://github.com/base/base/blob/dd3b5eb575be368280a5cd4e14be57b78c30712e/crates/proof/succinct/utils/client/src/precompiles/factory.rs#L45-L58

  • ZK witness executor uses ZkvmOpEvmFactory::new():

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

    • https://github.com/base/base/blob/dd3b5eb575be368280a5cd4e14be57b78c30712e/crates/proof/succinct/utils/client/src/witness/executor.rs#L144-L149

  • L2 MessagePasser withdrawal storage:

    • src/L2/L2ToL1MessagePasser.sol

    • https://github.com/base/contracts/blob/v8.1.0/src/L2/L2ToL1MessagePasser.sol#L78-L90

  • L1 withdrawal proof verification and finalization:

    • src/L1/OptimismPortal2.sol

    • https://github.com/base/contracts/blob/v8.1.0/src/L1/OptimismPortal2.sol#L390-L412

    • https://github.com/base/contracts/blob/v8.1.0/src/L1/OptimismPortal2.sol#L466-L490

https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c

Proof of Concept

The PoC is provided as a secret Gist:

https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c

To reproduce the primary state-transition mismatch:

The script checks out:

The PoC passes when it prints:

References

  • Secret Gist containing the runnable PoC and logs:

    • https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c

  • Primary reproduction files:

    • README.md: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-readme-md

    • run_poc.sh: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-run_poc-sh

    • poc_main.rs: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-poc_main-rs

  • Evidence logs and summaries:

    • zk-executor-message-passer-poc.log: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-zk-executor-message-passer-poc-log

    • live-el-summary.md: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-live-el-summary-md

    • live-node-rpc-and-trace-results.jsonl: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-live-node-rpc-and-trace-results-jsonl

    • l1-finalize-summary.md: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-l1-finalize-summary-md

    • l1-finalize-harness.log: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-l1-finalize-harness-log

Was this helpful?