Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours
Causing network processing nodes to process transactions from the mempool beyond set parameters
Description
Brief/Intro
This bug affects both the smart contract and the blockchain.
The off-chain proposer (crates/proof/proposer/src/pipeline.rs) recovers its place in the dispute-game chain on every cache miss (cold restart, pipeline reset, anchor advance past tip) by seeding a deterministic forward walk with parent_address = AnchorStateRegistry and l2_block_number = anchor.l2_block_number taken from getAnchorRoot(). It then submits the next game with parent = AnchorStateRegistry.
On-chain, AggregateVerifier.initializeWithInitData resolves parent = AnchorStateRegistry to startingOutputRoot = ANCHOR_STATE_REGISTRY.getStartingAnchorRoot() — the immutable OG root and OG block. It then enforces l2SequenceNumber() == startingOutputRoot.l2SequenceNumber + BLOCK_INTERVAL. So the only L2 block at which a parent = ASR game is acceptable is the OG starting block + BLOCK_INTERVAL, fixed forever at deploy time.
After the first call to setAnchorState(...) (triggered by the permissionless AggregateVerifier.closeGame()), getAnchorRoot() returns the new anchor block while getStartingAnchorRoot() still returns the OG. From that moment forward, any honest proposer that loses its in-memory cache (cold start, deploy, OOM, crash, container respawn) is permanently stuck — its very next submission attempts parent = ASR at anchorBlock + BLOCK_INTERVAL ≠ OG_START + BLOCK_INTERVAL, and the L1 reverts every retry with UnexpectedBlockNumber(OG_START+BI, anchorBlock+BI). The off-chain client trait AnchorStateRegistryClient does not expose either anchorGame() (the proxy address that should be the new parent) or getStartingAnchorRoot() (the OG block) so no runtime configuration recovers the proposer.
Attack cost is one L1 gas fee for one closeGame() call (~50k gas). No keys, no bond, no proposer registration, no infra access required. Once any single game in the chain has resolved DEFENDER_WINS and passed the finality delay, the attacker calls closeGame() to advance the anchor, then waits for any of the honest proposers to restart for any reason — deploy, OOM, machine reboot, scaling event — and the first one that does is bricked. Every subsequent restart of every other instance hits the same broken path.
Vulnerability Details
1
The on-chain bug parent==ASR means "from genesis," not "from current anchor"
AnchorStateRegistry initializer:
startingAnchorRoot has no setter outside this initializer. setAnchorState only updates a different storage slot:
AggregateVerifier.initializeWithInitData reads the immutable root, not the current one, when parentAddress() == ANCHOR_STATE_REGISTRY:
After setAnchorState lands once, the only L2 block that satisfies this check with parent = ASR is OG_START + BLOCK_INTERVAL. That game already exists (it was created at deploy). Every other block reverts.
2
The off-chain code cache-miss recovery uses ASR as the parent unconditionally
When the cache is cold (cold start) or invalidated (anchor.l2_block_number > cached.state.l2_block_number), the function falls into the _ branch and seeds the walk with the wrong parent for any anchor that has advanced past OG_START.
forward_walk then probes:
If X > OG_START, no game has UUID (gameType, root_at_X+BI, encode_extra_data(X+BI, ASR, intermediates)) because the only ASR-rooted game lives at OG_START + BI (different block, different intermediates, different UUID). lookup == Address::ZERO is hit at the first iteration. The walk returns (parent=ASR, l2_block=X) — the broken state.
The pipeline then dispatches a proof for L2 block X + BI and submits with parent = ASR:
On L1, this is a createWithInitData call whose extraData packs (uint256 X+BI, address ASR, intermediates). On-chain, getStartingAnchorRoot().l2SequenceNumber == 0 != X so the contract reverts with UnexpectedBlockNumber(BI, X+BI).
3
The trait surface forecloses any in-binary recovery
This is the entire trait. There is no anchor_game() (which would expose the proxy address that should be the new parent). There is no get_starting_anchor_root() (which would expose the OG block needed to know that we're past it). Re-running the binary with different config flags cannot fix the bug because the data isn't reachable from the binary at all. Only a source-code change to the trait + client + caller fixes it.
4
The attacker's lever: closeGame() is permissionless
No onlyOwner, no signature, no Merkle gate. Any EOA paying gas can advance the anchor as soon as the first game finalizes. Once advanced, the bug is armed against every proposer in the fleet.
5
The retry loop locks the bug in
After max_retries = 3 consecutive failures (default in config.rs), the pipeline calls state.reset(), wiping cached_recovery. The next tick re-enters the same broken _ arm of recover_latest_state. The proposer is permanently stuck in this loop until the binary is patched.
Impact Details
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.
Temporary freezing of funds for at least 24 hours (e.g., stuck withdrawal proofs, locked dispute game bonds).
After the first closeGame() on a finalized AggregateVerifier game (a permissionless call by any EOA), every honest proposer that subsequently cold-starts is locked out of submitting new dispute games via DisputeGameFactory.createWithInitData so the L1 reverts every retry with UnexpectedBlockNumber. With no working proposer, setAnchorState cannot advance further, so any L2→L1 withdrawal proof targeting state past the broken anchor remains unprovable.
References
Add any relevant links to documentation or code
Proof of Concept
1
Pre-requisites
gcloud CLI authenticated with access to the VM (or any Linux box with foundry).
The in-scope contract source from https://github.com/base/contracts/tree/v8.1.0/src/multiproof?utm_source=immunefi.
Foundry installed.
2
One-time setup of the contracts environment
3
Drop in the PoC scripts
Save scripts/F1LivePhase.sol (deploys + creates G1 + resolves G1) and run_recover_against_live.py (replays the off-chain code path against the live chain). Both are alongside this report.
4
Spin up anvil and deploy the system
After these 7 steps the chain is in the broken-anchor state. Verify: