Direct loss to Base or users ≥ 10% of funds held within Bridge.
Description
Brief/Intro
The challenger's GameScanner permanently caches the INTERMEDIATE_BLOCK_INTERVAL value per game type on first lookup and never invalidates it. After a legitimate governance upgrade via DisputeGameFactory.setImplementation() that changes the interval, the challenger computes the wrong expected checkpoint count for all new games, triggering a CheckpointCountMismatch error that causes the game to be silently skipped. A malicious proposer who creates a game during this post-upgrade window has their invalid game escape the entire challenge window, resolving as DEFENDER_WINS when CHALLENGER_WINS should apply.
Vulnerability Details
Affected Code
File: crates/proof/challenge/src/scanner.rs, function resolve_intermediate_block_interval:
asyncfnresolve_intermediate_block_interval(&self,game_type:u32)->Result<u64>{{letcache=self.interval_cache.lock().unwrap();ifletSome(&interval)=cache.get(&game_type){returnOk(interval);// BUG: never re-validated after contract upgrade}}letimpl_address=self.factory_client.game_impls(game_type).await?;letinterval=self.verifier_client.read_intermediate_block_interval(impl_address).await?;letmutcache=self.interval_cache.lock().unwrap();cache.insert(game_type,interval);Ok(interval)}
interval_cache is a Mutex<HashMap<u32, u64>> that maps game_type to the interval value. It is populated on the first cache miss via an RPC call to the implementation contract's INTERMEDIATE_BLOCK_INTERVAL constant. Once populated, the cache entry persists for the entire process lifetime. No mechanism exists to detect that the implementation contract address has changed or that the on-chain interval value has been updated.
Attack Sequence
Challenger starts. The first call to resolve_intermediate_block_interval(game_type=1) fetches INTERMEDIATE_BLOCK_INTERVAL = 100 from impl_v1. The cache stores {1 => 100}.
Governance upgrade.DisputeGameFactory.setImplementation(1, impl_v2) is called, where impl_v2 has INTERMEDIATE_BLOCK_INTERVAL = 50.
Malicious proposer creates a game with starting_block = 1000, l2_block = 1200, producing 4 intermediate roots based on the new interval of 50.
Challenger evaluates the game.resolve_intermediate_block_interval(1) hits the cache and returns the stale value 100. The validator computes expected_checkpoints = (1200 - 1000) / 100 = 2, but the game has 4 roots. The result is ValidatorError::CheckpointCountMismatch { expected: 2, actual: 4 }.
Silent skip. The validate_game function logs a warning, returns Ok(None), and the game receives no further processing. No challenge is submitted.
Challenge window expires. The game resolves as DEFENDER_WINS. The proposer's invalid state root is finalized.
Note: Why This Is Not the Known Proposer Config Issue
The publicly disclosed known vulnerability "Proposer Config Values Require a Redeploy to Change" describes the proposer failing to update bond values and block intervals, causing the proposer's own transactions to revert (self-harm, liveness issue). This finding targets the challenger side, where the stale cache causes the challenger to silently skip a game that should be challenged. The failure mode is fundamentally different:
Dimension
Known Issue (Proposer)
This Finding (Challenger)
Component
Proposer
Challenger scanner
Failure mode
Proposer transactions revert
Game silently skipped
Who is harmed
Proposer (self-harm)
Protocol and users
Exploitability
Not attacker-exploitable
Attacker creates game during upgrade window
Security impact
Liveness (proposer restarts)
Safety (invalid game finalizes)
Impact Details
A malicious proposer who monitors governance upgrade transactions can exploit this vulnerability to finalize a game with an incorrect state root. The impact chain is:
Invalid state root finalized on L1 - the game resolves DEFENDER_WINS without challenge, anchoring a fraudulent L2 state in the AnchorStateRegistry.
Fraudulent withdrawals - with a finalized invalid state root, the attacker can prove withdrawals against fabricated L2 state via OptimismPortal.proveWithdrawalTransaction(), potentially draining bridge funds.
All games of the upgraded type are affected - every game created after the upgrade but before the challenger is manually restarted is silently skipped. The window is unbounded until operator intervention.
This matches the in-scope Blockchain/DLT impact: "Direct loss to Base or users ≥ 10% of funds held within Bridge." The attacker controls the fabricated L2 state root and can prove withdrawals for arbitrary amounts against it via OptimismPortal.proveWithdrawalTransaction(), putting the entire bridge balance at risk - well above the 10% threshold.
References
Vulnerable code: crates/proof/challenge/src/scanner.rs lines 338-366 in https://github.com/base/base/releases/tag/v0.8.0-rc.15
interval_cache retained as optimization in PR #2219 (stateless scanner, 2026-04-16) - the cache was not changed
No fix found in any PR up to #2336
Related (but distinct): Known vulnerability "Proposer Config Values Require a Redeploy to Change"
Proof of Concept
The PoC code is provided after the setup and containing two unit tests and all supporting mock types. To integrate and run:
Setup
Copy the UpgradeableImplMockFactory struct and its DisputeGameFactoryClient impl into crates/proof/challenge/src/test_utils.rs, alongside the existing mock types (after MockDisputeGameFactory).
Add the impl_interval_map: HashMap<Address, u64> field to the existing MockAggregateVerifier struct, add the with_impl_intervals constructor, and update read_intermediate_block_interval to use it (all shown in the PoC file).
Paste the two poc_stale_interval_* test functions into the #[cfg(test)] mod tests block at the end of the same file.
Run
What the tests demonstrate
Test 1 - poc_stale_interval_cache_populated_from_impl (baseline): On a fresh GameScanner, the first scan() call fetches INTERMEDIATE_BLOCK_INTERVAL from the implementation contract (IMPL_OLD) and caches INTERVAL_OLD = 100. Confirms the cache-population path works correctly.
Test 2 - poc_stale_interval_cache_after_governance_upgrade (exploit): After the first scan populates the cache, upgrade_impl(IMPL_NEW) simulates a governance call to DisputeGameFactory.setImplementation(). The second scan() call hits the stale cache and returns INTERVAL_OLD = 100 instead of querying the new implementation which would return INTERVAL_NEW = 50. The test asserts:
Post-upgrade, the cached interval is still 100 (stale) - not 50 (current on-chain value)
The scanner never re-queries game_impls() or read_intermediate_block_interval() for a game type that was already cached
Any game created with the new interval would have a checkpoint count computed from INTERVAL_NEW, but the challenger validates against INTERVAL_OLD - producing a CheckpointCountMismatch that causes the game to be silently skipped