75312 bc low offchain challenger nullify retries are unbounded when dual proof game is externally challenged after proof readiness
Submitted on Apr 28th 2026 at 13:20:14 UTC by @pks271 for Audit Comp | Base Azul
- Report ID: #75312
- 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
- Unbounded gas consumption in any in-scope contract function callable by external parties
Description
Bug Description
commit hash: e3467a2048881213b56739a54a876efb9c6ea103 (v0.8.0-rc.28)
In the challenger dual-proof path (InvalidDualProposal), the driver can build a nullify() proof from an unchallenged game state (tee_prover != 0, zk_prover != 0, countered_index == 0) and move the entry to ReadyToSubmit.
If the game is externally challenged before the submit happens, the on-chain nullify() rules change immediately:
counteredByIntermediateRootIndexPlusOne > 0requires nullifying the challenged index exactly;intermediateRootToProvemust equal the challenged root;- proof type must be
ZK.
The current off-chain submit path does not re-validate countered_index compatibility before sending the pending nullify() call. It only checks status, tee_prover, and zk_prover. As a result, stale pre-challenge (invalid_index, expected_root) is repeatedly submitted and deterministically reverts.
On revert, the entry stays in ReadyToSubmit and is retried every tick. MAX_PROOF_RETRIES is not applied because retry_count only tracks proving retries, not submit retries.
Call trace
crates/proof/challenge/src/driver.rs::step -> process_invalid_proposal (InvalidDualProposal) -> initiate_proof(intent=Nullify) -> pending ReadyToSubmit -> external challenge sets countered index -> poll_or_submit submits stale nullify -> AggregateVerifier.nullify() challenged branch rejects -> Err branch keeps ReadyToSubmit -> next tick repeats
Code
// crates/proof/challenge/src/driver.rs
if self.pending_proofs.contains_key(&game_address) {
debug!(game = %game_address, "skipping game with pending proof session");
return Ok(());
}
let (status, tee_prover, zk_prover) = tokio::try_join!(
self.verifier_client.status(game_address),
self.verifier_client.tee_prover(game_address),
self.verifier_client.zk_prover(game_address),
)?;
// no countered_index re-validation before submit
let result = self
.submitter
.submit_dispute(game_address, proof_bytes, invalid_index, expected_root, intent)
.await;
match result {
Ok(_) => { self.pending_proofs.remove(&game_address); }
Err(e) => {
warn!(error = %e, game = %game_address, "dispute tx failed, will retry next tick");
// Leave entry as ReadyToSubmit for retry.
}
}
// base-contracts/src/multiproof/AggregateVerifier.sol
function nullify(
bytes calldata proofBytes,
uint256 intermediateRootIndex,
bytes32 intermediateRootToProve
) external {
...
if (counteredByIntermediateRootIndexPlusOne > 0) {
if (intermediateRootIndex != counteredByIntermediateRootIndexPlusOne - 1) {
revert InvalidIntermediateRootIndex();
}
if (intermediateRootToProve != intermediateOutputRoot(intermediateRootIndex)) {
revert IntermediateRootMismatch(intermediateRootToProve, intermediateOutputRoot(intermediateRootIndex));
}
if (proofType != ProofType.ZK) revert InvalidProofType();
} else {
_checkIntermediateRoot(intermediateRootIndex, intermediateRootToProve);
}
...
}
Impact
An external actor can race the challenger by challenging a dual-proof game after proof readiness but before submit. This forces deterministic submit reverts on stale nullify() parameters. Because submit retries are unbounded in ReadyToSubmit, the challenger can enter a persistent gas/liveness degradation loop for the same game.
Recommendation
- Add submit-side stale-context checks before
nullify():- fetch
countered_index; - if challenged (
countered_index > 0), ensure pendinginvalid_indexandexpected_rootstill match challenged index/root semantics; - otherwise drop/rebuild pending entry from fresh scan classification.
- fetch
- Add
MAX_SUBMIT_RETRIES(separate from proof retries) and stop retrying deterministic terminal reverts.
Proof of Concept
steps:
- Game is dual-proof and unchallenged (
tee_prover != 0,zk_prover != 0,countered_index == 0). - Challenger validates roots and creates a
Nullifypending proof with pre-challenge(invalid_index, expected_root). - Before submit, external actor calls
challenge(), moving the game into challenged state (countered_index > 0). - Challenger submits stale
nullify()parameters; on-chain call reverts deterministically. - Driver keeps entry
ReadyToSubmitand retries on every tick.
Patch
diff --git a/crates/proof/challenge/tests/driver.rs b/crates/proof/challenge/tests/driver.rs
index 341b0d6e5..0a9b6e2e4 100644
--- a/crates/proof/challenge/tests/driver.rs
+++ b/crates/proof/challenge/tests/driver.rs
@@ -1164,6 +1164,56 @@ async fn test_step_dual_proof_tee_fails_falls_back_to_zk_nullify() {
);
}
+#[tokio::test]
+async fn test_dual_proof_ready_to_submit_retry_remains_unbounded_after_external_challenge() {
+ base_cli_utils::init_test_tracing();
+
+ // AUDIT: start from a dual-proof, unchallenged game where the challenger
+ // enters the Nullify path and builds a pending proof from pre-challenge state.
+ let (l2, factory, root_15, _root_20) = base_game_mocks();
+ let initial_state = MockGameState {
+ tee_prover: DEFAULT_TEE_PROVER,
+ zk_prover: ZK_PROVER_ADDR,
+ intermediate_output_roots: vec![root_15, BOGUS_ROOT],
+ countered_index: 0,
+ ..game_state(20)
+ };
+ let verifier = single_game_verifier(initial_state.clone());
+
+ let zk = succeeded_zk_prover("dual-race-retry", vec![0xDE, 0xAD]);
+
+ // AUDIT: model deterministic nullify() submit reverts once the game is
+ // externally challenged before the pending submission lands.
+ let total_submit_failures =
+ Driver::<MockL2Provider, MockZkProofProvider, MockTxManager>::MAX_PROOF_RETRIES as usize
+ + 2;
+ let tx_manager = MockTxManager::with_responses(
+ (0..total_submit_failures)
+ .map(|_| Ok(receipt_with_status(false, DEFAULT_TX_HASH)))
+ .collect(),
+ );
+
+ let mut driver = test_driver(factory, Arc::clone(&verifier), l2, zk, tx_manager);
+
+ // AUDIT: dual-proof candidate should initiate ZK Nullify proof flow.
+ driver.step().await.unwrap();
+ let entry = driver.pending_proofs.get(&addr(0)).expect("proof should be pending after initiation");
+ assert!(matches!(entry.phase, ProofPhase::AwaitingProof { .. }));
+ assert_eq!(entry.intent, DisputeIntent::Nullify);
+
+ // AUDIT: external challenge flips countered_index semantics before submit.
+ let mut challenged_state = initial_state;
+ challenged_state.countered_index = 2; // 1-based => challenged index 1
+ verifier.update_game(addr(0), challenged_state);
+
+ for _ in 0..total_submit_failures {
+ driver.step().await.unwrap();
+ let entry = driver.pending_proofs.get(&addr(0)).expect("entry should be preserved");
+ assert!(entry.is_ready(), "phase should remain ReadyToSubmit after revert");
+ assert_eq!(entry.retry_count, 0, "submit retries are not bounded by proof retry counter");
+ }
+}
+
// ──────────────────────────────────────────────────────────────────────────
// Bond lifecycle integration tests
Run:
CARGO_ENCODED_RUSTFLAGS='-Clink-arg=-fuse-ld=lld' cargo test -p base-challenger --test driver test_dual_proof_ready_to_submit_retry_remains_unbounded_after_external_challenge -- --nocapture
Result:
running 1 test
2026-04-28T12:34:49.931248Z INFO base_challenger::scanner: scan complete games_found=1 scan_head=0 games_scanned=1
2026-04-28T12:34:49.931270Z INFO base_challenger::validator: validating intermediate output roots game=0x0000000000000000000000000000000000000000 starting_block=10 end_block=20 interval=5 intermediate_count=2
2026-04-28T12:34:49.931544Z WARN base_challenger::validator: invalid output root detected game=0x0000000000000000000000000000000000000000 block=20 index=1 expected=0x401da1446febeae61109677bc2f52e9e7bbb8313a6f3507529006ef0b49c2f60 claimed=0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
2026-04-28T12:34:49.931567Z INFO base_challenger::driver: invalid intermediate root detected, requesting proof game=0x0000000000000000000000000000000000000000 invalid_index=1 expected_root=0x401da1446febeae61109677bc2f52e9e7bbb8313a6f3507529006ef0b49c2f60 intent=Nullify
2026-04-28T12:34:49.931623Z INFO base_challenger::driver: proof job initiated game=0x0000000000000000000000000000000000000000 session_id=dual-race-retry
2026-04-28T12:34:49.931651Z INFO base_challenger::driver: proof ready, submitting dispute transaction game=0x0000000000000000000000000000000000000000 proof_len=3 action="nullify"
2026-04-28T12:34:49.931684Z INFO base_challenger::submitter: submitting dispute transaction game=0x0000000000000000000000000000000000000000 action="nullify" intermediate_root_index=1 intermediate_root_to_prove=0x401da1446febeae61109677bc2f52e9e7bbb8313a6f3507529006ef0b49c2f60 calldata_len=164
2026-04-28T12:34:49.931706Z WARN base_challenger::driver: dispute tx failed, will retry next tick error=transaction reverted: 0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd game=0x0000000000000000000000000000000000000000
...