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

75502 sc low nullified zk verifier does not invalidate prior zk games allowing invalid roots to finalize after a soundness alert

Submitted on Apr 29th 2026 at 14:15:29 UTC by @Brainiac5 for Audit Comp | Base Azul

  • Report ID: #75502

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/base/contracts/tree/v8.1.0/src/multiproof

  • Impacts:

    • Forging or bypassing TEE or ZK proof verification in AggregateVerifier to finalize an invalid state root on L1

    • Draining or stealing funds from the L1 bridge portal through invalid withdrawal proofs constructed against a forged finalized state

    • Circumventing the dispute/challenge mechanism to prevent correction of an invalid proposal before finalization

    • Bypassing the soundness alert mechanism — two conflicting valid proofs of the same type (both TEE or both ZK) fail to trigger automatic game nullification

Description

Brief/Intro

When a same-type soundness alert happens, AggregateVerifier.nullify() deletes the proof only in the game that made the alert and sets the global verifier's nullified flag. However, other in-progress games that already accepted proofs from the now-nullified verifier keep their proofCount and can still resolve DEFENDER_WINS. For a ZK-only game, this is worse because the normal correction path also becomes unusable: nullify() requires another ZK proof, but ZK_VERIFIER.verify() now reverts because the verifier is already nullified.

In plain terms: the system has an alarm that says "this ZK verifier is broken", but old games that already used the broken verifier can still cash out as valid. After the alarm is raised, users also cannot use the same ZK verifier to correct those old games anymore.

Vulnerability Details

Verifier.nullify() records that a verifier is no longer trusted:

The proof verifier then rejects future proof verification:

AggregateVerifier.nullify() correctly removes the targeted proof from the current game and calls the verifier's global nullify() hook:

But AggregateVerifier.resolve() only checks proofCount and gameOver(). It does not check whether the stored proof type came from a verifier that has since been nullified:

This means a prior ZK-only game can still resolve even after another game has proven the ZK verifier unsound and set ZK_VERIFIER.nullified == true.

The correction path is also blocked after the global alert. For a ZK-only game, nullify() requires a ZK proof because proofTypeToProver[ProofType.ZK] is the populated slot. But _verifyZkProof() calls ZK_VERIFIER.verify(), and the verifier now reverts with Verifier.Nullified.

So the state becomes:

  1. Game A has an already accepted ZK proof.

  2. Game B proves the ZK verifier is unsound and calls ZK_VERIFIER.nullify().

  3. Game A still has proofCount == 1.

  4. Game A cannot be corrected via same-type ZK nullification anymore.

  5. Game A can still resolve DEFENDER_WINS.

  6. The root from Game A can become claim-valid in AnchorStateRegistry.

Impact Details

The direct impact is that the soundness alert does not fail closed for existing games. It only blocks future proof verification and removes the proof from the alerting game.

If any already-created ZK-only game contains an invalid root from the now-nullified verifier, that game can still finalize as DEFENDER_WINS after the slow finalization delay. Because the verifier is already nullified, normal same-type nullification cannot be used to correct it. Once the game resolves, AnchorStateRegistry.isGameClaimValid() treats that stale root as valid and OptimismPortal2 can finalize withdrawals proven against it.

This is not a trusted-admin issue. No privileged action is needed to create the stale game, trigger verifier nullification, resolve the stale game, or finalize the withdrawal. A manual blacklist can act as an emergency backstop, but the automatic soundness-alert path itself fails open: after the protocol has cryptographic evidence that a verifier is unsound, resolve() still counts old proofs from that verifier.

This is also not the same as saying "assume a forged proof exists, so anything is possible." The report starts from the soundness-alert condition already handled by the protocol: two conflicting same-type proofs exist and one game successfully nullifies the verifier. The bug is what happens after that alert: other games using the same verifier remain live and become harder to correct.

Suggested Fix

Track proof validity against verifier state at resolution time.

Possible fixes:

  • In resolve(), reject unresolved games that have a populated ZK proof if ZK_VERIFIER.nullified() is true, and reject games with a populated TEE proof if TEE_VERIFIER.nullified() is true.

  • Add a verifier epoch or generation number. Store the verifier epoch when a proof is accepted and invalidate unresolved games when the verifier epoch is nullified.

  • When a verifier is nullified, make AnchorStateRegistry.isGameClaimValid() reject unresolved or resolved games that depend on that verifier unless they were explicitly revalidated under a later verifier epoch.

  • Add tests where one game nullifies a verifier and another unresolved game with the same proof type attempts to resolve.

References

  • Base Azul Immunefi scope page: https://immunefi.com/audit-competition/audit-comp-base-azul/scope/

  • contracts/src/multiproof/AggregateVerifier.sol:443

  • contracts/src/multiproof/AggregateVerifier.sol:458

  • contracts/src/multiproof/AggregateVerifier.sol:465

  • contracts/src/multiproof/AggregateVerifier.sol:548

  • contracts/src/multiproof/AggregateVerifier.sol:589

  • contracts/src/multiproof/AggregateVerifier.sol:598

  • contracts/src/multiproof/AggregateVerifier.sol:763

  • contracts/src/multiproof/AggregateVerifier.sol:788

  • contracts/src/multiproof/Verifier.sol:14

  • contracts/src/multiproof/Verifier.sol:27

  • contracts/src/multiproof/Verifier.sol:39

  • contracts/test/multiproof/AuditNullifiedVerifierStaleWithdrawalEndToEnd.t.sol:79

  • contracts/test/multiproof/AuditNullifiedVerifierFinalizes.t.sol:12

Confidence

Confirmed locally with two runnable Foundry PoCs.

The end-to-end PoC proves that after a same-type ZK soundness alert nullifies the verifier, a separate prior ZK-only game using that verifier can no longer be same-type nullified, still resolves DEFENDER_WINS, becomes claim-valid, and is accepted by OptimismPortal2 to finalize a withdrawal.

Proof of Concept

Primary End-to-End Proof of Concept

This PoC demonstrates the full end impact through the actual OptimismPortal2 withdrawal path:

  1. Game A accepts a ZK proof for an attacker-controlled withdrawal output root while the ZK verifier is trusted.

  2. Game B triggers the same-type ZK soundness alert and globally nullifies the ZK verifier.

  3. Game A can no longer be corrected with another ZK proof because ZK_VERIFIER.verify() now reverts with Verifier.Nullified.

  4. Game A still resolves DEFENDER_WINS because AggregateVerifier.resolve() only checks proofCount, not current verifier validity.

  5. AnchorStateRegistry.isGameClaimValid(Game A) returns true.

  6. OptimismPortal2.proveWithdrawalTransaction() and finalizeWithdrawalTransaction() pay the withdrawal from the stale root.

PoC file:

Run:

Observed result:

Important implementation notes from the PoC:

  • The helper verifier is strict: it only accepts the exact journal that AggregateVerifier computes.

  • The nullification proof uses the segment-level journal used by nullify(), not the full proposal-range journal.

  • The nullification proof is submitted by the prover address bound into the journal, matching the msg.sender binding in _verifyZkProof().

  • The portal proof uses the real ffi.getProveWithdrawalTransactionInputs() helper and the real OptimismPortal2 prove/finalize functions.

Full PoC code:

Secondary Minimal Proof of Concept

PoC file:

Run:

Observed result:

Full PoC code:

Expected vs Actual

Expected:

Once a verifier has been nullified due to a same-type soundness alert, unresolved games that rely on existing proofs from that verifier should not be able to finalize as valid. They should either be automatically invalid, rejected by resolve(), or tied to a verifier epoch that becomes invalid when the verifier is nullified.

Actual:

Verifier.nullify() only blocks future verify() calls. AggregateVerifier.resolve() still counts previously accepted proofs from the nullified verifier and allows the game to resolve DEFENDER_WINS. For ZK-only games, the same-type nullification path is then blocked because the ZK verifier is already nullified.

Preconditions and Constraints

  • A same-type soundness alert occurs for the ZK verifier. The primary PoC models this with a strict local verifier that only accepts the exact journals computed by AggregateVerifier.

  • At least one other unresolved ZK game already accepted a proof before the verifier was nullified.

A manual blacklist would be an emergency intervention. The issue is that the automatic verifier-nullification path does not invalidate already accepted proofs, so stale games can remain claim-valid unless manually handled.

Was this helpful?