75116 sc medium zk proof config binding bypass allows proofs for base sepolia to execute under a forged rollup config
Submitted on Apr 27th 2026 at 10:54:13 UTC by @Brainiac5 for Audit Comp | Base Azul
Report ID: #75116
Report Type: Smart Contract
Report severity: Medium
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
Description
Brief/Intro
The ZK proof public inputs only commit to CONFIG_HASH, but that hash does not bind the local chain_id used by BootInfo::load() and does not bind the rollup hardfork schedule. A malicious prover can set the local boot chain_id to an unsupported value so the proof program loads an attacker-supplied rollup config from the oracle, while that attacker-supplied config still hashes to the official Base Sepolia CONFIG_HASH. The proof can then execute the claimed L2 block under different fork rules than Base Sepolia while still satisfying the on-chain AggregateVerifier journal.
Vulnerability Details
BootInfo::load() reads a local L2_CHAIN_ID_KEY and uses it only to decide whether to load a built-in registry config or fall back to an oracle-provided rollup config:
let rollup_config = if let Some(config) = Registry::rollup_config(chain_id) {
config.clone()
} else {
let ser_cfg = oracle
.get(PreimageKey::new_local(L2_ROLLUP_CONFIG_KEY.to()))
.await
.map_err(OracleProviderError::Preimage)?;
serde_json::from_slice(&ser_cfg).map_err(OracleProviderError::Serde)?
};There is no check that the local chain_id equals rollup_config.l2_chain_id.
The ZK public values then commit this:
But hash_rollup_config() uses PerChainConfig, whose binary encoding does not include the local boot chain_id and does not include hardfork activation times. The code comments explicitly say the hash is stable across hardfork upgrades.
That means the following two configs produce the same public CONFIG_HASH:
The real Base Sepolia rollup config.
A forged oracle-provided rollup config whose
l2_chain_idand genesis fields still match Base Sepolia, but whose hardfork schedule disables Azul.
At the Azul timestamp, the real config selects OpSpecId::AZUL, while the forged config selects OpSpecId::JOVIAN. These are different EVM rules. For example, the Osaka CLZ opcode is valid under Azul but invalid under Jovian. In a real block, this can be turned into a different output root by using a transaction whose Azul path succeeds and changes contract state, while the forged pre-Azul path fails or takes different logic.
The range program executes with boot_info.rollup_config:
The aggregation program carries forward only rollupConfigHash and commits the digest that the Solidity verifier checks:
On-chain, AggregateVerifier checks only CONFIG_HASH and ZK_RANGE_HASH inside the journal:
The Solidity verifier never sees the local boot chain_id, and CONFIG_HASH cannot distinguish the forged hardfork schedule from the real one.
Impact Details
This breaks the binding between the on-chain Base Sepolia game and the actual execution rules used inside the ZK proof.
Concrete impact chain:
The attacker creates or challenges an
AggregateVerifiergame with a ZK proof.The proof witness uses an unsupported local boot
chain_id, causingBootInfo::load()to accept oracle-provided rollup config.That oracle-provided config keeps Base Sepolia identity fields so the proof commits the official Sepolia
CONFIG_HASH.The config changes hardfork timing, so the proof executor derives/executes L2 blocks under non-canonical rules.
The aggregation proof still produces a journal matching
AggregateVerifier.CONFIG_HASH.AggregateVerifieraccepts the ZK proof for a state root that was not produced by canonical Base Sepolia execution.With
PROOF_THRESHOLD = 1, the game can later resolveDEFENDER_WINSand the invalid root can become trusted by the L1 fault-proof/withdrawal path.
This does not rely on TEE compromise, guardian inaction, or a trusted admin mistake. The weakness is in the ZK proof public-input binding.
Suggested Fix
Reject unsupported local chain ids inside the ZK proof program for all production proof paths.
Add a consistency check:
boot.chain_id == boot.rollup_config.l2_chain_id.id().Include all execution-affecting fields in the public config commitment, especially hardfork activation times and L1 chain id/config inputs.
Alternatively, do not accept oracle-provided rollup configs in production ZK/TEE proof programs.
Update
AggregateVerifier.CONFIG_HASHand proof program commitments to use the expanded hash.
References
base/crates/proof/proof/src/boot.rs:231base/crates/proof/proof/src/boot.rs:243base/crates/proof/proof/src/boot.rs:251base/crates/proof/primitives/src/per_chain_config.rs:85base/crates/proof/primitives/src/per_chain_config.rs:150base/crates/proof/primitives/src/per_chain_config.rs:188base/crates/proof/succinct/utils/client/src/boot.rs:24base/crates/proof/succinct/utils/client/src/boot.rs:48base/crates/proof/succinct/programs/range/utils/src/lib.rs:47base/crates/proof/succinct/programs/aggregation/src/main.rs:102base/crates/proof/succinct/programs/aggregation/src/main.rs:116contracts/src/multiproof/AggregateVerifier.sol:917contracts/src/multiproof/AggregateVerifier.sol:926contracts/src/multiproof/AggregateVerifier.sol:932
Proof of Concept
PoC 1 proves the boot-loader fallback accepts an oracle-controlled rollup config when the local chain id is unsupported, even if that loaded config claims Base Sepolia as rollup_config.l2_chain_id.
PoC 1 file:
Run:
Observed result:
Full PoC 1 code:
PoC 2 proves the forged config commits the official Base Sepolia CONFIG_HASH while executing the same Azul timestamp under different EVM rules.
PoC 2 file:
Run:
Observed result:
Full PoC 2 code:
Expected vs Actual
Expected:
The ZK proof should be bound to the exact Base Sepolia rollup config used by canonical execution, including the local chain id and all hardfork activation fields that affect EVM semantics.
Actual:
The ZK proof can use an unsupported local boot chain_id to load oracle-controlled rollup config, execute using altered hardfork rules, and still commit the official Base Sepolia CONFIG_HASH accepted by AggregateVerifier.
Was this helpful?