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 costs6900gas.The ZK executor overwrites that entry with legacy
P256VERIFY, which costs3450gas.
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_OSAKArequires6900gas.Under the ZK executor, the same call succeeds because the overwritten legacy entry costs
3450gas.
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:
The contract calls
P256VERIFYat0x0100with exactly3450gas.If the call succeeds, it calls
L2ToL1MessagePasserat0x4200000000000000000000000000000000000016.Canonical Base EL does not reach the MessagePasser branch.
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:
The attacker deploys an L2 contract with a branch controlled by
CALL(0x0100, gas=3450).In the success branch, the contract calls
L2ToL1MessagePasser.initiateWithdrawal(...).Canonical Base EL executes under Azul/Osaka rules. The P256 call fails, so no withdrawal is written.
The ZK executor executes under the overwritten legacy P256 rule. The P256 call succeeds, so the withdrawal branch executes.
The ZK output root can include a
messagePasserStorageRootwheresentMessages[withdrawalHash] = true.The L1 bridge flow verifies withdrawal inclusion against the accepted
messagePasserStorageRoot.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.rsget_precompiles()returns legacybn254::pair::ISTANBULandsecp256r1::P256VERIFY.get_or_create_precompiles(OpSpecId::AZUL)starts fromBasePrecompiles::azul()and then callsprecompiles.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.rsBasePrecompiles::azul()installsmodexp::OSAKAandsecp256r1::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.rshttps://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.rshttps://github.com/base/base/blob/dd3b5eb575be368280a5cd4e14be57b78c30712e/crates/proof/succinct/utils/client/src/witness/executor.rs#L144-L149
L2 MessagePasser withdrawal storage:
src/L2/L2ToL1MessagePasser.solhttps://github.com/base/contracts/blob/v8.1.0/src/L2/L2ToL1MessagePasser.sol#L78-L90
L1 withdrawal proof verification and finalization:
src/L1/OptimismPortal2.solhttps://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
Link to Proof of Concept
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-mdrun_poc.sh: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-run_poc-shpoc_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-loglive-el-summary.md: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-live-el-summary-mdlive-node-rpc-and-trace-results.jsonl: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-live-node-rpc-and-trace-results-jsonll1-finalize-summary.md: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-l1-finalize-summary-mdl1-finalize-harness.log: https://gist.github.com/s-zaizen/420f1caf8965753715be81b32483e56c#file-l1-finalize-harness-log
Was this helpful?