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

74791 bc medium stale intermediate block interval cache forces dispute games into incorrect resolved state

Submitted on Apr 24th 2026 at 21:22:24 UTC by @abarbatei for Audit Comp | Base Azul

  • Report ID: #74791

  • Report Type: Blockchain/DLT

  • Report severity: Medium

  • Target: https://github.com/base/base/tree/v0.8.0-rc.24

  • Impacts:

    • 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:

async fn resolve_intermediate_block_interval(&self, game_type: u32) -> Result<u64> {
    {
        let cache = self.interval_cache.lock().unwrap();
        if let Some(&interval) = cache.get(&game_type) {
            return Ok(interval);  // BUG: never re-validated after contract upgrade
        }
    }
    let impl_address = self.factory_client.game_impls(game_type).await?;
    let interval = self.verifier_client
        .read_intermediate_block_interval(impl_address).await?;
    let mut cache = 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

  1. 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}.

  2. Governance upgrade. DisputeGameFactory.setImplementation(1, impl_v2) is called, where impl_v2 has INTERMEDIATE_BLOCK_INTERVAL = 50.

  3. Malicious proposer creates a game with starting_block = 1000, l2_block = 1200, producing 4 intermediate roots based on the new interval of 50.

  4. 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 }.

  5. Silent skip. The validate_game function logs a warning, returns Ok(None), and the game receives no further processing. No challenge is submitted.

  6. 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:

  1. Invalid state root finalized on L1 - the game resolves DEFENDER_WINS without challenge, anchoring a fraudulent L2 state in the AnchorStateRegistry.

  2. Fraudulent withdrawals - with a finalized invalid state root, the attacker can prove withdrawals against fabricated L2 state via OptimismPortal.proveWithdrawalTransaction(), potentially draining bridge funds.

  3. 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

  1. Copy the UpgradeableImplMockFactory struct and its DisputeGameFactoryClient impl into crates/proof/challenge/src/test_utils.rs, alongside the existing mock types (after MockDisputeGameFactory).

  2. 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).

  3. 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:

  1. Post-upgrade, the cached interval is still 100 (stale) - not 50 (current on-chain value)

  2. The scanner never re-queries game_impls() or read_intermediate_block_interval() for a game type that was already cached

  3. 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

Was this helpful?