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/DLTSelected 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::resetSubmitOutcome::RootMismatch, which resets the pipeline when canonical output changed between proof generation and submissionrepeated 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.
Recommended Remediation
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/95f4751f6c344f172eacafb4ead6473dSelected asset:
https://github.com/base/base/tree/v0.8.0-rc.28Supporting contract asset:
https://github.com/base/contracts/tree/v8.1.0/src/multiproofPoC script:
ColdStartProposerCannotResume.s.solPoC output:
coldstart_run.logCode evidence:
coldstart_code_evidence.txtSUBMISSION.mdSHA256:9d9d0651606ed77ef93cca602adbdd6f90c8d6a89af85815b46b75c6be5cbe8eColdStartProposerCannotResume.s.solSHA256:e465bec08291d612f633eb3b7535406482cf91af10f53f3cdb787c64bf944895coldstart_run.logSHA256:82bb813c2081d163f84737e15f945d9766dbd23c2f020c54829a61f58614bb68coldstart_code_evidence.txtSHA256:175bcb52b07c8319cec46a2aa2b11787bc09c7d08f045bb96744d541896559d3
Link to Proof of Concept
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
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:
deploys v8.1.0 source contracts
creates a valid game
resolves that game
advances ASR through the public
setAnchorState()pathconstructs the next game using the same parent/block shape as the proposer cold-start branch
calls
DisputeGameFactory.createWithInitDataobserves 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?