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

76505 bc low proposer jit freshness check reuses cached result

#76505 [BC-Low] Proposer JIT Freshness Check Reuses Cached output_at_block Result

Submitted on May 4th 2026 at 17:38:46 UTC by @v_c0d35 for Audit Comp | Base Azul

  • Report ID: #76505

  • Report Type: Blockchain/DLT

  • Report severity: Low

  • 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

Proposer JIT Freshness Check Reuses Cached output_at_block Result

Brief/Intro

The Base Azul proof proposer caches optimism_outputAtBlock responses by L2 block number and later performs its submit-time "JIT" freshness check through the same cached accessor. If the rollup RPC returns a stale output root once during proof construction, the proposer can reuse that stale cached value during validate_and_submit and treat the proposal as fresh without performing a new RPC read. In production, this can cause the proposer to submit a stale or incorrect output root as a dispute game; the demonstrated impact is limited to proposer-side freshness validation because the independent challenger is expected to recompute and dispute invalid games before finalization.

Vulnerability Details

RollupClient stores optimism_outputAtBlock responses in an in-memory cache keyed only by the numeric L2 block number:

The cache is initialized as a capacity-bounded Moka cache, with no visible TTL, finality binding, RPC endpoint binding, L1-origin binding, or proof-context binding. output_at_block() checks this cache before making the optimism_outputAtBlock RPC call:

This makes output_at_block(N) sticky for the lifetime of the cache entry. If the first response for block N is stale root A, later calls for the same N return A even if the rollup RPC would now return canonical root B.

The same accessor is used during proof construction. In build_proof_request_for, the proposer calls self.rollup_client.output_at_block(target_block) and puts the returned claimed_output.output_root into the proof request:

Later, validate_and_submit attempts to perform submit-time JIT validation:

This looks like a fresh canonical read, but it is not guaranteed to be fresh. It calls the same cached output_at_block() method for the same target_block. Therefore, the following sequence is possible:

  1. Proof construction calls output_at_block(N).

  2. The rollup RPC returns stale output root A.

  3. RollupClient stores A in output_cache[N].

  4. The proof is built for root A.

  5. The rollup RPC later would return canonical root B for block N.

  6. validate_and_submit calls output_at_block(N) for its JIT check.

  7. The cache returns A, so the proposer compares A against A.

  8. The root mismatch is not detected, and the proposer proceeds to submit the game.

The follow-on intermediate-root validation also reuses canonical_output.output_root for the target block by inserting it into canonical_map, so the target block remains validated against the cached value rather than a newly observed canonical value.

If validation passes, validate_and_submit calls self.output_proposer.propose_output(...). ProposalSubmitter::propose_output then ABI-encodes proposal.output_root into the dispute-game creation calldata and sends the transaction to the configured factory. This means the stale root that passed the cached JIT check can become the submitted root claim.

The core issue is not merely that the proposer trusts the rollup RPC for output roots. The narrower cache/JIT bug is that the code has an explicit submit-time freshness check, but that check can be reduced to comparing the proof result against a previously cached proof-construction value. JIT validation should bypass, clear, or context-bind this cache when freshness is required.

Impact Details

The directly demonstrated impact is that the proposer-side freshness guard can be bypassed by a stale resident cache entry. A stale optimism_outputAtBlock response can pass through proof construction and then self-confirm during validate_and_submit, allowing the proposer to submit an invalid or stale dispute-game proposal that should have been rejected by a fresh submit-time read.

This weakens the protocol's defense-in-depth around output proposal freshness. The immediate operational consequence is creation of an invalid dispute game and associated proposal gas/bond handling that should have been avoided by the JIT validation step. In the default architecture, the challenger independently recomputes output roots from L2 headers and account/storage proofs, so the expected recovery path is for the invalid game to be detected and disputed before it becomes final.

References

Proof of Concept

PoC: Proposer JIT Freshness Check Reuses Cached output_at_block Result

Goal

This PoC runs entirely against the public base/base repository at tag v0.8.0-rc.28. It demonstrates that RollupClient::output_at_block(N) caches the first optimism_outputAtBlock(N) response and that a later call for the same block, modeling validate_and_submit JIT validation, returns the cached stale root without re-querying the rollup RPC. A fresh client with an empty cache then observes the updated RPC response, proving the cache is the reason the JIT freshness read stayed stale.

What This Reproduces

The production proposer code path uses RollupClient::output_at_block in both places relevant to the bug:

  • Proof construction: build_proof_request_for calls self.rollup_client.output_at_block(target_block) and stores the returned root as claimed_l2_output_root.

  • Submit-time JIT validation: validate_and_submit calls self.rollup_client.output_at_block(target_block) again and compares the proposal root against that value.

The PoC tests the shared vulnerable primitive directly. It uses a local mock rollup RPC that returns root A on the first optimism_outputAtBlock(N) call and root B on the next network call for the same block.

Expected vulnerable behavior:

  1. First call, modeling proof construction, receives root A.

  2. Second call on the same RollupClient, modeling JIT validation, returns cached root A.

  3. The mock RPC request counter remains 1, proving no fresh RPC read occurred during the JIT-model call.

  4. A fresh RollupClient with an empty cache receives root B, proving the server had changed and the stale result came from the cache.

Prerequisites

  • Rust/Cargo toolchain installed.

  • Network access for the initial clone and dependency download.

  • No Base node, rollup node, L1 RPC, L2 RPC, deployment artifact, or private test fixture is required.

1

Clone The Public Repository

The tag is annotated, so Git may print a detached-HEAD notice. That is expected.

2

Enable Tokio Test Runtime Features

Open crates/proof/rpc/Cargo.toml and replace the existing [dev-dependencies] Tokio line:

with:

This only enables the async test runtime for the PoC integration test.

3

Add The PoC Test File

Save the following file to crates/proof/rpc/tests/output_at_block_jit_cache.rs:

4

Run The PoC

Run only the new integration test:

Expected output:

Why The Passing Test Demonstrates The Vulnerability

The mock RPC server is programmed to return a stale root first and a fresh root on the next network request for the same block. After the first call, RollupClient stores the stale root in output_cache under the bare L2 block number. The second call uses the same RollupClient and same block number. The test proves that:

  • the second call returns the stale root;

  • the mock RPC request counter is still 1, so no fresh network request occurred;

  • a fresh RollupClient with an empty cache then observes the fresh root from the same mock RPC.

This reproduces the submit-time freshness failure because the production JIT check calls the same cached output_at_block(target_block) method used during proof construction. A stale output root can therefore compare equal to itself during JIT validation.

Verification Notes

These steps were verified from a fresh public v0.8.0-rc.28 checkout. The final test run completed successfully with:

Was this helpful?