76410 bc medium stateless gamescanner permanently drops invalid in progress dispute games whose factory index falls below gamecount lookback games after any single missed tick
Submitted on May 4th 2026 at 10:26:48 UTC by @yesofcourse for Audit Comp | Base Azul
Report ID: #76410
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-challenger service's GameScanner re-derives a trailing lookback_games window from the live gameCount on every tick with no monotonic watermark and no per-game backlog. The same crate's BondManager solves the equivalent discovery problem with a bond_scan_head watermark plus a periodic full rescan; the dispute scanner has neither. As a result, any in-progress invalid game whose first scan tick produces no candidate (because Driver::validate_game soft-skips on a transient ValidatorError) and whose factory index then falls below gameCount - lookback_games is excluded from every future scan. Game creation is permissionless on DisputeGameFactory.createWithInitData, so any L1 caller paying initBonds(gameType) can drive that index drift. The Base-run challenger then never validates the buried game, never requests a proof, and never submits a nullify() or challenge() transaction for it, even after the originating transient error has fully cleared.
Vulnerability Details
The window arithmetic in crates/proof/challenge/src/scanner.rs, function GameScanner::scan, is recomputed from the live gameCount on every tick:
let game_count = self.factory_client.game_count().await?;
// ...
let end = game_count - 1;
let start = game_count.saturating_sub(self.config.lookback_games);
let results: Vec<(u64, Result<Option<CandidateGame>>)> = stream::iter(start..=end)
.map(|i| async move { (i, self.evaluate_game(i).await) })
.buffer_unordered(Self::SCAN_CONCURRENCY)
.collect()
.await;There is no scan watermark and no per-game backlog. Once enough new games are appended to the factory, the index of an older in-progress game becomes strictly less than start and the scanner stops calling evaluate_game(N) for it.
The same crate's BondManager solves the equivalent discovery problem differently. In crates/proof/challenge/src/bond.rs, function discover_claimable_games, the doc comment is explicit:
BondManager maintains a monotonic bond_scan_head: u64 watermark, advances it forward after each scan, and resets it backward by lookback on a periodic timer, so a game once missed by index drift is unconditionally re-evaluated on the next discovery_interval boundary. GameScanner has neither.
The challenger ticks every --poll-interval seconds (default 12s), and at L1 throughput an attacker cannot append lookback_games = 1000 games in one such window, so the bug only matters if the very first tick on the target index produces no pending_proofs entry. The driver has exactly such a path. In crates/proof/challenge/src/driver.rs, function Driver::validate_game:
Every ValidatorError variant collapses to Ok(None). This includes BlockNotAvailable (an L2 RPC blip on header_by_number or get_proof, mid-import re-org, transient timeout) and the generic Rpc(_) variant. Then in Driver::process_invalid_proposal:
On Ok(None) the function returns early without calling initiate_proof and without inserting into pending_proofs. The same soft-skip applies to Driver::process_fraudulent_zk_challenge, so the bug applies symmetrically across the four GameCategory variants.
The burial chain in production:
An invalid in-progress game is created at factory index
Nvia permissionlessDisputeGameFactory.createWithInitData.The Base challenger's first tick on
Nhits a transient L2 error on at least one checkpoint.validate_gamereturnsOk(None). Nopending_proofsentry is created.Permissionless
createWithInitDatacalls pushgameCountpastN + lookback_games. The attacker only needs one tick where the target was missed, and step 2 produces it.L2 recovers.
Every subsequent tick recomputes
start = gameCount - lookback_games > N. The scanner never includesNagain.pending_proofsfor the buried address remains empty for the lifetime of the running service.
No indirect recovery path exists. Driver::poll_pending_proofs only iterates pending_proofs.addresses(), so an empty entry is never re-touched. Driver::discover_claimable_bonds calls BondManager::discover_claimable_games, which has the watermark, but it concerns bond claiming and does not re-trigger dispute submission. There is no fallback discovery by respectedGameType lineage or by unresolved-status filter.
The Azul proofs spec describes challenger coverage without any window qualification. From the "Challenger" section: "Anyone can run a challenger. A challenger independently recomputes checkpoint output roots for in-progress games, identifies the first invalid claim, and submits the required dispute transaction." In the implementation the property only holds for indices within gameCount - lookback_games of the tip.
Impact Details
The Base-run challenger, the official challenger that the Base team operates as the security backstop for the dispute system, can be permanently blinded to specific invalid in-progress dispute games. Both triggers are reachable without any privileged action: a transient ValidatorError on the first tick is a property of normal L2 RPC operation, and the index drift past lookback_games is reachable via permissionless DisputeGameFactory.createWithInitData calls.
This maps to the Blockchain / DLT row "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". The qualifying behavior is that an in-progress dispute game whose rootClaim is provably wrong remains undisputed by the Base challenger, and the bond-slashing guarantee for its proposer does not materialize through the Base-operated dispute path, until a third-party challenger intervenes or an operator manually restarts the service with different configuration.
The bug does not by itself let an invalid output root finalize on-chain. Per "Anyone can run a challenger", a third-party challenger can still validate and dispute the buried game via the permissionless ZK path. The program's downgrade rule for reports that assume Base will not dispute or nullify an invalid proposal within the proof system therefore caps severity at Medium.
The post-Azul Sepolia deployment sets INIT_BOND = 50000000000000000 (0.05 ETH) for GAME_TYPE=621, so an attacker locks up to lookback_games * 0.05 ETH = 50 ETH of recoverable bond capital across the appended games. Bonds return when each appended game resolves DEFENDER_WINS, so the net economic cost is gas plus the opportunity cost of the bond lockup for one finality window. On Sepolia this is gas only.
References
Azul proofs spec, "Challenger" section: https://specs.base.org/upgrades/azul/proofs
GameScanner::scanwindow arithmetic: https://github.com/base/base/blob/v0.8.0-rc.28/crates/proof/challenge/src/scanner.rsBondManager::discover_claimable_gameswatermark and periodic-rescan implementation: https://github.com/base/base/blob/v0.8.0-rc.28/crates/proof/challenge/src/bond.rsDriver::validate_gamesoft-skip path andDriver::process_invalid_proposalearly-return onOk(None): https://github.com/base/base/blob/v0.8.0-rc.28/crates/proof/challenge/src/driver.rsDriver::process_fraudulent_zk_challengesymmetric soft-skip: https://github.com/base/base/blob/v0.8.0-rc.28/crates/proof/challenge/src/driver.rs--lookback-gamesCLI flag (default 1000): https://github.com/base/base/blob/v0.8.0-rc.28/crates/proof/challenge/src/cli.rsExisting soft-skip-on-
BlockNotAvailabletest (test_step_validation_error_blocks_not_available) that asserts the tick survives but not the post-recovery behavior: https://github.com/base/base/blob/v0.8.0-rc.28/crates/proof/challenge/tests/driver.rsAudit snapshot tag: https://github.com/base/base/tree/v0.8.0-rc.28
Sepolia post-Azul deployment,
GAME_TYPE=621andINIT_BOND=50000000000000000: https://github.com/base/contract-deployments/blob/main/sepolia/2026-04-20-activate-multiproof/.env#L6-L17
Link to Proof of Concept
https://gist.github.com/asendz/02e464a77824f7d050a83d94143345a0
Proof of Concept
Running the PoC
Prerequisite: a Rust toolchain matching the workspace's rust-toolchain.toml. The workspace's .cargo/config.toml configures the macOS linker to lld at /opt/homebrew/opt/lld/bin/ld64.lld. Either install lld with brew install lld, or pass RUSTFLAGS="" to override the rustflags from .cargo/config.toml for this run.
The PoC test file ships at crates/proof/challenge/tests/poc_lookback_burial.rs. Run from the workspace root, in release mode:
If lld is not installed:
PoC:
Was this helpful?