75986 bc medium proposer cold recovery loses the current anchor game address after asr advances causing subsequent multiproof games to revert
Submitted on May 2nd 2026 at 03:13:44 UTC by @Diavol0 for Audit Comp | Base Azul
Report ID: #75986
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/base/base/tree/v0.8.0-rc.28
Impacts:
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
Description
Brief/Intro
The in-scope Rust proposer recovers its latest on-chain progress from AnchorStateRegistry.getAnchorRoot(), but that call returns only the current output root and L2 block number. After the ASR has advanced to an anchorGame, the proposer still uses the ASR address as the parent sentinel during a cold recovery. That sentinel is valid only for the first game from the immutable starting anchor. The next AggregateVerifier game must instead use the current anchor game's address as parentAddress, so a restarted or cache-reset proposer can repeatedly submit transactions that revert and fail to advance proposals.
Vulnerability Details
The affected primary asset is the off-chain proposer in base/base at crates/proof/proposer/src/pipeline.rs. The on-chain integration point is the in-scope multiproof game in src/multiproof/AggregateVerifier.sol.
Multiproof games bind the parent game address into extraData:
pub fn encode_extra_data(
l2_block_number: u64,
parent_address: Address,
intermediate_roots: &[B256],
) -> Bytes {
let mut data = vec![0u8; 52 + 32 * intermediate_roots.len()];
data[..32].copy_from_slice(&U256::from(l2_block_number).to_be_bytes::<32>());
data[32..52].copy_from_slice(parent_address.as_slice());
...
}AggregateVerifier.initializeWithInitData() treats parentAddress() == address(ANCHOR_STATE_REGISTRY) as the special "first game" case:
This means the ASR address sentinel is correct only when the new game starts from getStartingAnchorRoot(). Once ASR has advanced to a real anchorGame, the next game must use that anchorGame address as its parent. If it uses the ASR address again, the contract compares the new game against the immutable starting anchor block and reverts with UnexpectedBlockNumber.
The Rust proposer loses the required address during cold recovery. recover_latest_state() first reads the current anchor root:
However, the Rust AnchorStateRegistryClient interface only exposes get_anchor_root(), which returns the root and L2 block number. It does not expose the Solidity anchorGame() address. When the cache is absent or invalid, the recovery start state is built with the ASR address sentinel:
The forward walk then looks for the next game by UUID using this parent_address:
Correct child games created after the ASR advanced are registered under extraData containing parentAddress = address(anchorGame), not parentAddress = address(AnchorStateRegistry). A cold proposer therefore cannot discover them. If no game is found, the same ASR sentinel is passed into the proposal submission path:
ProposalSubmitter encodes that parent address into createWithInitData() calldata:
The result is a production-reachable liveness failure after the ASR has advanced and the proposer later performs a full recovery, for example after a restart, cold deployment, crash recovery, or cache reset. A continuously running proposer whose in-memory cache still contains the current parent game may avoid this path, so the issue is intentionally described as a recovery/liveness bug rather than an unconditional halt.
Impact Details
After ASR advances to a non-starting anchorGame, a cold or cache-reset instance of this proposer implementation cannot recover the current parent game address from chain. It can repeatedly build the next proposal with parentAddress = AnchorStateRegistry, and the on-chain game initialization reverts because the proposal block is checked against getStartingAnchorRoot() instead of the current anchor game.
This prevents the in-scope proposer from advancing multiproof proposals after normal anchor advancement unless the implementation is fixed or the missing parent address is supplied out of band. It can also cause repeated failed L1 transactions and operational intervention during recovery. The report does not demonstrate direct theft, proof forgery, or permanent fund loss, so the impact is classified conservatively as Medium griefing/availability damage.
References
crates/proof/proposer/src/pipeline.rs:recover_latest_state()usesget_anchor_root()and falls back toanchor_state_registry_addresson full recovery.crates/proof/proposer/src/pipeline.rs:forward_walk()looks up games by UUID usingencode_extra_data(..., parent_address, ...).crates/proof/proposer/src/output_proposer.rs:ProposalSubmitter::propose_output()encodes the recovered parent address intocreateWithInitData()calldata.crates/proof/contracts/src/anchor_state_registry.rs: Rust ASR binding exposesgetAnchorRoot()but notanchorGame().src/dispute/AnchorStateRegistry.sol:getAnchorRoot()returns the current anchor root/block, andanchorGameis a separate public state variable.src/multiproof/AggregateVerifier.sol:initializeWithInitData()usesgetStartingAnchorRoot()whenparentAddress()is the ASR address.
Proof of Concept
Rust proposer recovery PoC
Apply the following additions to crates/proof/proposer/src/pipeline.rs inside the existing #[cfg(test)] mod tests block.
Import GameAtIndex with the other contract test types:
Add the non-zero ASR sentinel constant near the other test constants:
Add the test:
Run:
Observed result:
The important assertion is that recovery returns the non-zero ASR sentinel, not the real anchorGame address, even though the mock factory contains both the current anchor game and a valid next child keyed by the real parent.
Contract-side revert PoC
Create test/multiproof/AdvancedAnchorParentSentinelPoC.t.sol with the following contents:
Run:
Observed result:
The test first proves that ASR has advanced to parentGame. It then shows that the next game reverts when the parent is the ASR sentinel, exactly matching the parent address that the cold proposer recovery returns. The control path using address(parentGame) succeeds.
Recommended Mitigation
Recover and preserve the actual current anchor game address whenever ASR has advanced.
Possible fixes:
Extend the Rust
AnchorStateRegistrybinding to readanchorGame()in addition togetAnchorRoot().Use
parentAddress = address(AnchorStateRegistry)only whenanchorGame == address(0).When
anchorGame != address(0), useanchorGameas the recoveredparent_addressbefore starting the forward walk.Add a pre-submission guard that rejects
parentAddress == AnchorStateRegistrywhen the recovered anchor block differs fromgetStartingAnchorRoot().l2SequenceNumber.Add a cross-repository recovery test that advances ASR, drops the proposer cache, and verifies that the next proposal is encoded with
parentAddress = anchorGame.
Was this helpful?