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

76399 bc insight toctou race in snark session creation allows duplicate sp1 cluster job submission leading to permanent proof loss

Submitted on May 4th 2026 at 08:19:18 UTC by @Pig46940 for Audit Comp | Base Azul

  • Report ID: #76399

  • Report Type: Blockchain/DLT

  • Report severity: Insight

  • 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

Brief/Intro

StatusPoller and the GetProof RPC concurrently call sync_and_update_proof_status() in base/crates/proof/zk/service/src/proof_request_manager.rs on the same proof request with no mutual exclusion, allowing both to independently trigger SNARK aggregation when a STARK session completes. This creates two live Groth16 jobs on the SP1 cluster and two SNARK session rows in the database. In the worst-case ordering, where one SNARK session fails before the other completes, the proof request is permanently marked Failed, the valid receipt from the completing session is silently discarded, and the proof request has no automated recovery path despite a valid proof having been produced.


Vulnerability Details

OpSuccinctBackend::process_proof_request determines whether to trigger stage-2 aggregation using a plain read from the database with no locking:

  • base/crates/proof/zk/service/src/backends/op_succinct/backend.rs

// backend.rs line process_proof_request() 210-220
            let has_stark_completed = updated_sessions.iter().any(|s| {
                s.session_type == SessionType::Stark && s.status == DbSessionStatus::Completed
            });
            let has_snark_session =
                updated_sessions.iter().any(|s| s.session_type == SessionType::Snark);

            if has_stark_completed && !has_snark_session {
                info!(
                    proof_request_id = %proof_request.id,
                    "STARK completed, triggering stage-2 aggregation proof (SNARK Groth16)"
                );

Two concurrent callers can both read has_snark_session = false before either has committed a new session row. Both then enter submit_aggregation_proof, which calls create_request() on the SP1 cluster before writing to the database:

  • base/crates/proof/zk/service/src/backends/op_succinct/backend.rs

  • base/crates/proof/zk/service/src/backends/op_succinct/backend.rs

The database provides no protection. create_proof_session in repo.rs is a plain INSERT with no ON CONFLICT clause, and the proof_sessions table has no UNIQUE constraint on (proof_request_id, session_type):

  • base/crates/proof/zk/db/migrations/002_add_proof_sessions.sql

The result is two live Groth16 jobs on the SP1 cluster and two SNARK rows in proof_sessions for the same proof request.

determine_status in backend.rs iterates sessions and immediately returns Failed upon encountering any session with Failed status, without evaluating the remaining sessions:

  • base/crates/proof/zk/service/src/backends/op_succinct/backend.rs

If either SNARK session fails while the other is still running, this loop marks the proof request terminal via update_status_if_non_terminal in repo.rs:

  • base/crates/proof/zk/db/src/repo.rs

When the other session subsequently completes, sync_session_with_sp1_cluster in backend.rs calls update_receipt_if_non_terminal, which is gated on the same condition and becomes a no operation. The valid SNARK receipt is never written to the proof_requests row. The proof request is permanently stuck in Failed state with a valid SNARK receipt having been produced but never stored.


Impact Details

This vulnerability matches the Medium impact category: a bug in the layer 2 network code that results in unintended behavior with no concrete funds at direct risk.

The race between StatusPoller and GetProof RPC unconditionally submits two Groth16 jobs to the SP1 cluster on every occurrence. If either SNARK session fails while the other is still running, determine_status marks the proof request permanently Failed while a valid proof is still being produced. When the second session subsequently completes, update_receipt_if_non_terminal is no operation, the valid SNARK receipt is silently discarded, and the proof request remains stuck in Failed state with no automated recovery path.

The trigger is a normal operational condition. StatusPoller runs on a fixed polling interval and GetProof RPC fires on every client query. Their overlap at STARK completion is not an adversarial edge case, it occurs in normal operation on every OpSuccinctSp1ClusterSnarkGroth16 request where a client queries status around the STARK to SNARK transition.

No funds are directly at risk. The recovery cost, issuing a new CreateProofRequest and rerunning the full pipeline, is identical to that of any single SNARK session failure under normal operation.

Potential Fix

The vulnerability has two layers that both require a fix.

Layer 1: Database constraint

Add a UNIQUE(proof_request_id, session_type) constraint to proof_sessions and change create_proof_session in repo.rs to use INSERT ... ON CONFLICT DO NOTHING. This prevents the second SNARK row from being created, but does not prevent the duplicate create_request() call to the SP1 cluster since that fires before the DB insert in submit_aggregation_proof.

  • base/crates/proof/zk/db/src/repo.rs

Layer 2: Row-level lock before the check

Wrap the has_snark_session check and submit_aggregation_proof call inside a serializable transaction with a SELECT ... FOR UPDATE on the proof_requests row. This ensures only one caller can pass the check and submit to the cluster.

Both fixes are needed together. Without the lock, the DB constraint stops duplicate rows but the SP1 cluster still receives two jobs. Without the DB constraint, the lock alone is sufficient but leaves the schema unprotected against future callers that bypass the lock path.


References

  • base/crates/proof/zk/service/src/backends/op_succinct/backend.rs

  • base/crates/proof/zk/db/src/repo.rs

  • base/crates/proof/zk/db/migrations/002_add_proof_sessions.sql

  • base/crates/proof/zk/service/src/proof_request_manager.rs

Proof of Concept

The following test can be added directly into the existing mod tests block in proof_request_manager.rs. It demonstrates that StatusPoller and GetProof RPC both pass the has_snark_session guard simultaneously and each create a separate SNARK session in the database.

RacingSnarkBackend mirrors the exact SNARK-trigger block from OpSuccinctBackend::process_proof_request in backend.rs: it calls get_sessions_for_request, evaluates has_snark_session, and calls create_proof_session. A Barrier(2) is inserted between the check and the insert, forcing both callers to complete the check before either proceeds to the insert. This makes the race window deterministic instead of relying on scheduling timing.

The precondition matches the exact production state that triggers the race: a RUNNING OpSuccinctSp1ClusterSnarkGroth16 proof request with one Completed STARK session and no SNARK session. Both callers read has_snark_session = false simultaneously, both enter the branch, and both call create_proof_session. In production, create_request() on the SP1 cluster fires before create_proof_session inside submit_aggregation_proof, so two cluster jobs are unconditionally submitted before any DB write occurs.

The two assertions confirm both layers of the vulnerability. snark_submissions == 2 proves both callers passed the guard. snark_count == 2 proves the database has no constraint preventing two SNARK rows for the same proof request, confirming that 002_add_proof_sessions.sql has no UNIQUE(proof_request_id, session_type) constraint and create_proof_session in repo.rs has no ON CONFLICT clause.

PoC Code

Running PoC

Add the PoC code to the test module in proof_request_manager.rs, then run:

Was this helpful?