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:
Game A has an already accepted ZK proof.
Game B proves the ZK verifier is unsound and calls
ZK_VERIFIER.nullify().Game A still has
proofCount == 1.Game A cannot be corrected via same-type ZK nullification anymore.
Game A can still resolve
DEFENDER_WINS.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 ifZK_VERIFIER.nullified()is true, and reject games with a populated TEE proof ifTEE_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:443contracts/src/multiproof/AggregateVerifier.sol:458contracts/src/multiproof/AggregateVerifier.sol:465contracts/src/multiproof/AggregateVerifier.sol:548contracts/src/multiproof/AggregateVerifier.sol:589contracts/src/multiproof/AggregateVerifier.sol:598contracts/src/multiproof/AggregateVerifier.sol:763contracts/src/multiproof/AggregateVerifier.sol:788contracts/src/multiproof/Verifier.sol:14contracts/src/multiproof/Verifier.sol:27contracts/src/multiproof/Verifier.sol:39contracts/test/multiproof/AuditNullifiedVerifierStaleWithdrawalEndToEnd.t.sol:79contracts/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:
Game A accepts a ZK proof for an attacker-controlled withdrawal output root while the ZK verifier is trusted.
Game B triggers the same-type ZK soundness alert and globally nullifies the ZK verifier.
Game A can no longer be corrected with another ZK proof because
ZK_VERIFIER.verify()now reverts withVerifier.Nullified.Game A still resolves
DEFENDER_WINSbecauseAggregateVerifier.resolve()only checksproofCount, not current verifier validity.AnchorStateRegistry.isGameClaimValid(Game A)returns true.OptimismPortal2.proveWithdrawalTransaction()andfinalizeWithdrawalTransaction()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
AggregateVerifiercomputes.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.senderbinding in_verifyZkProof().The portal proof uses the real
ffi.getProveWithdrawalTransactionInputs()helper and the realOptimismPortal2prove/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?