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

76536 bc medium medium blockchain dlt proposer cold start uses asr sentinel as parent after anchor advancement causing aggregateverifier initializewithinitdata to revert unexpectedblocknumber

Submitted on May 4th 2026 at 19:21:32 UTC by @Devomacky for Audit Comp | Base Azul

  • Report ID: #76536

  • 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 Base Azul proposer cold-start path reconstructs its next game from AnchorStateRegistry.getAnchorRoot() but encodes parent_address = AnchorStateRegistry in extraData. Once AnchorStateRegistry.anchorGame has been advanced by a valid finalized game, getAnchorRoot() returns the live advanced anchor while AggregateVerifier.initializeWithInitData() interprets parentAddress() == ANCHOR_STATE_REGISTRY as the genesis/sentinel case and reads getStartingAnchorRoot() instead. The result is a deterministic UnexpectedBlockNumber(100, 200) revert on every proposer cold-start after the anchor has advanced. This is a Base layer-2 network-code bug that produces unintended smart contract behavior with no concrete funds at direct risk.

Vulnerability Details

Selected asset:

https://github.com/base/base/tree/v0.8.0-rc.28
Blockchain/DLT

Selected impact:

The root cause is a cross-component semantic mismatch between the off-chain proposer and the on-chain AggregateVerifier.

In the selected base/base@v0.8.0-rc.28 asset, crates/proof/proposer/src/pipeline.rs::recover_latest_state reads the live anchor:

On the cold-start branch it then returns a recovered state with:

This combines a live advanced anchor block with the ASR sentinel parent address.

In the supporting in-scope contract code, AggregateVerifier.initializeWithInitData() treats parentAddress() == ANCHOR_STATE_REGISTRY as the first-game sentinel and reads getStartingAnchorRoot():

AnchorStateRegistry.getStartingAnchorRoot() and getAnchorRoot() are intentionally different after an anchor game has been accepted:

After anchorGame is set, the proposer reads the advanced block from getAnchorRoot(), computes advanced + BLOCK_INTERVAL, and dispatches a game whose parent is still encoded as the ASR sentinel. The contract then compares that advanced block against getStartingAnchorRoot().l2SequenceNumber + BLOCK_INTERVAL and reverts.

The Rust binding in crates/proof/contracts/src/anchor_state_registry.rs exposes getAnchorRoot() but does not expose anchorGame(). Therefore the proposer cannot recover the live anchor-game proxy address and use it as the parent on cold-start. There is no retry path in forward_walk that switches from the ASR sentinel to the actual anchor-game address after the revert.

Reachability

The trigger condition is ASR.anchorGame != address(0).

That condition is reachable through AnchorStateRegistry.setAnchorState(game), which is a public permissionless entrypoint in base/contracts@v8.1.0. Only one accepted finalized DEFENDER_WINS game is needed for the ASR live anchor to diverge from the starting anchor.

The condition is also amplified by mid-contest PR base/base#2372, which automates this same permissionless action. The PR description states that the challenger sends a permissionless setAnchorState(game) transaction to the ASR contract after a game resolves. That makes anchor advancement a routine outcome of normal successful game finalization, not an exotic attacker-only path.

The second trigger is a proposer cold-start or pipeline reset. This is a normal operational event:

  • first process start after deployment

  • binary redeploy

  • host/container restart

  • OOM/panic restart

  • task panic paths that call PipelineState::reset

  • SubmitOutcome::RootMismatch, which resets the pipeline when canonical output changed between proof generation and submission

  • repeated proof failures after max retries

The bug relies on restarts happening, not on restarts failing to happen. This matters because the program's own Known Vulnerabilities material states that some proposer configuration changes require redeploy. Cold starts are therefore expected operational behavior.

Impact Details

The impact is exactly:

The affected layer-2 network code is the Base proposer recovery pipeline and the Rust ASR binding in the selected base/base@v0.8.0-rc.28 asset.

The unintended smart-contract behavior is the deterministic AggregateVerifier.UnexpectedBlockNumber(expected, actual) revert when the proposer attempts to create the next game after a valid anchor advancement.

The PoC observes:

There are no concrete funds at direct risk in this report. The revert occurs during AggregateVerifier.initializeWithInitData() before the proposal is accepted and before the game bond can become protocol-risky. This report does not claim bridge drain, fund theft, chain split, or permanent fund freeze.

The practical effect is proposer liveness failure after a valid anchor advancement: every cold-start repeats the same construction and hits the same revert until the client code is patched to use the actual anchorGame() address as parent when anchorGame != address(0).

Why The Common Closure Arguments Do Not Apply

Not out of scope: the primary bug is in base/base@v0.8.0-rc.28, specifically the proposer cold-start recovery path and Rust ASR binding. The contract code is cited only because it is the in-scope smart contract boundary where the unintended behavior is observed.

Not a unit-test PoC: the PoC is a forge script run through forge script, not a forge test unit test. It inherits from forge-std/Script.sol, has a run() entrypoint, and exercises the full deployed-contract lifecycle locally against v8.1.0 source bytecode.

Not dependent on invalid TEE/ZK proofs: the revert occurs before proof verification. _verifyProof() is not reached.

Not dependent on Base failing to blacklist or retire an invalid game: there is no invalid proposal to blacklist or retire. The failing proposal reverts before it exists as a valid game. Guardian actions do not clear anchorGame or rewrite startingAnchorRoot; recovery requires code changes.

Not duplicate of PR #2372: PR #2372 is the amplifier. It causes the challenger to perform the anchor update that makes cold-start fail. It does not patch recover_latest_state, the Rust ASR binding, or AggregateVerifier's starting/current anchor semantic mismatch.

The audit competition mid-contest changelog states verbatim: "If a new bug is introduced by their fix then it is valid for a reward." This finding is exactly that case: PR #2372 introduces the precondition (anchorGame != address(0) becoming routine via permissionless setAnchorState) that makes the cold-start path deterministically revert.

Not a known issue: grep across the provided audits, Optimism reviews, and Known Vulnerabilities material found no disclosure of the cold-start proposer / getStartingAnchorRoot() / UnexpectedBlockNumber interaction. Adjacent ASR issues concern different invariants.

Patch the Rust ASR binding to expose anchorGame():

Then patch recover_latest_state so cold-start uses the actual anchor game address if present:

With parent_address = anchorGame, AggregateVerifier.initializeWithInitData() enters the non-ASR branch, reads the parent game's l2SequenceNumber(), and the block-number check matches the proposer's construction.

References

  • Secret Gist with supporting artifacts: https://gist.github.com/dsantoreis/95f4751f6c344f172eacafb4ead6473d

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

  • Supporting contract asset: https://github.com/base/contracts/tree/v8.1.0/src/multiproof

  • PoC script: ColdStartProposerCannotResume.s.sol

  • PoC output: coldstart_run.log

  • Code evidence: coldstart_code_evidence.txt

  • SUBMISSION.md SHA256: 9d9d0651606ed77ef93cca602adbdd6f90c8d6a89af85815b46b75c6be5cbe8e

  • ColdStartProposerCannotResume.s.sol SHA256: e465bec08291d612f633eb3b7535406482cf91af10f53f3cdb787c64bf944895

  • coldstart_run.log SHA256: 82bb813c2081d163f84737e15f945d9766dbd23c2f020c54829a61f58614bb68

  • coldstart_code_evidence.txt SHA256: 175bcb52b07c8319cec46a2aa2b11787bc09c7d08f045bb96744d541896559d3

https://gist.github.com/dsantoreis/95f4751f6c344f172eacafb4ead6473d

Proof of Concept

This PoC runs only locally. It does not broadcast transactions to Sepolia or mainnet. It compiles the in-scope base/contracts@v8.1.0 source, deploys the relevant contracts locally, advances ASR through the public setAnchorState() path, and then replays the proposer cold-start dispatch shape produced by base/base@v0.8.0-rc.28.

Secret Gist with supporting artifacts:

Environment

Repository:

Compiler:

PoC file:

The script is a forge script, not a unit test:

It is not:

Run Command

Observed Output

The clean reproduced output is:

Step-By-Step Explanation

1

The script deploys the relevant v8.1.0 contracts locally

  • DisputeGameFactory

  • AnchorStateRegistry

  • DelayedWETH

  • AggregateVerifier

2

The script confirms the starting anchor state

3

The script creates the first valid AggregateVerifier game

This succeeds because AggregateVerifier.initializeWithInitData() reads getStartingAnchorRoot() and expects:

4

The script resolves the first game as DEFENDER_WINS

The script calls the public ASR entrypoint:

After this call:

This reproduces the on-chain state that exists after the anchor has been advanced by a finalized DEFENDER_WINS game.

5

The script replays the proposer cold-start construction

6

The script submits the next game

The next game is submitted through the factory with parentAddress == ASR and l2SequenceNumber == 200.

7

AggregateVerifier.initializeWithInitData() interprets the ASR parent as the first-game sentinel

It reads getStartingAnchorRoot() instead of getAnchorRoot().

The contract expects:

But the proposer has constructed:

8

The call reverts with UnexpectedBlockNumber(100, 200)

The selector is verified:

Why This PoC Is Not Self-Referential

The PoC does not compare two locally invented encodings and does not stop at a unit-level assertion.

It performs the full contract lifecycle locally:

  1. deploys v8.1.0 source contracts

  2. creates a valid game

  3. resolves that game

  4. advances ASR through the public setAnchorState() path

  5. constructs the next game using the same parent/block shape as the proposer cold-start branch

  6. calls DisputeGameFactory.createWithInitData

  7. observes the real AggregateVerifier.initializeWithInitData() revert

The only mocked component is MockVerifier, which is shipped by Base in:

Base's own test/multiproof/BaseTest.t.sol uses the same mock to stand in for proof verification in AggregateVerifier tests. The revert occurs before proof verification, so proof validity is not relevant to the demonstrated failure.

Artifacts

All artifacts are OpenTimestamp-stamped. The .ots files and manifest are included in the supporting Gist.

Was this helpful?