Introduction

On February 9th, 2025, Risc0 engaged zkSecurity to perform an audit of the Risc0 Solana programs. The engagement lasted for one week and focused on the risc0-solana repository.

The main components audited are the following:

  • A Groth16 proof verifier, which implements the main logic for verifying Risc0 proofs.
  • An ownable library, which provides a mechanism to implement ownership of a Solana account, and to transfer ownership with a two-step process.
  • A verifier router, which is a Solana program that acts as a router and for different verifier implementation programs, and also provides an emergency stop mechanism.

Overall, we found the code to be well-structured and well documented. The codebase presents also a good level of test coverage for the different functionalities of the programs, using the anchor testing framework.

Overview of the Programs

We now give an overview of the different programs in the codebase.

Groth16 Proof Verifier

The verifier for the Groth16 proof system is very simple, consisting of:

  1. Computing a commitment to the public input.
  2. Followed by a single pairing product equation.

The verification of:

  • A proof π
  • Under the verification key vk
  • With public input aqk.

Is as follows:

  • π=(A,B,C)𝔾1×𝔾2×𝔾1
  • vk=(γ,δ,α,β,IC)𝔾2×𝔾1×𝔾2×𝔾1k
  • PI=a,IC𝔾1
  • e(A,B)=e(PI,γ) · e(C,δ) · e(α,β)𝔾T

In the Risc0 implementation, A𝔾1 is negated and the equivalent check becomes:

1=e(A,B) · e(PI,γ) · e(C,δ) · e(α,β)𝔾T

Because of its simplicity, the scope for errors is relatively limited compared to other proof systems with more complex verifiers.

The primary pitfalls for Groth16 proof verifiers are:

  • Failing to check that the proof points (A,C𝔾1, B𝔾2) points are on the respective curves.
  • Accepting public inputs with ambiguous encodings.

In the first case, the Solana alt_bn128_pairing syscall verifies membership of the points in the respective curves before performing the pairing operation. In the latter case, the Solana implement of Groth16 does not check that every public input is canonical: it’s an integer less than the group order. We explore this in slightly more detail in the findings section, however, within the context in which the Groth16 verifier is used it does not lead to a security issue.

Ownable

The ownership mechanism allows a user to implement ownership for any Anchor account. The overall architecture is similar to the Ownable2Step contract in Solidity.

The ownership information is stored in the Ownership structure, which contains the current owner and the pending owner.

pub struct Ownership {
    /// The current owner's public key
    owner: Option<Pubkey>,
    /// The public key of the pending owner during a transfer, if any
    pending_owner: Option<Pubkey>,
}

The Ownership structure provides an implementation for all ownership operations. When this struct is created through Ownership::new, the owner field is set to the public key of the initial owner, and the pending_owner field is set to None. To transfer ownership, the owner must call Ownership::transfer_ownership, which sets the pending_owner field to the new owner’s public key. The fact that the owner has called this function is checked using Anchor’s mechanism of specifying the owner as a Signer of the transaction. The original owner can also cancel the ownership transfer by calling Ownership::cancel_transfer, which resets the pending_owner field to None.

The new owner can accept the ownership transfer by calling Ownership::accept_ownership, which sets the owner field to the pending_owner field, and the pending_owner field to None. The new owner can also renounce the ownership by calling Ownership::renounce_ownership, which is an irreversible operation that sets the owner field to None.

The ownable library also provides a macro to allow the automatic derivation of the Ownable trait for any account struct that contains an Ownership field. The macro automatically generates the transfer_ownership, cancel_transfer, accept_ownership, and renounce_ownership functions for the account struct. As a result, the user of the ownable library can easily define an ownable account by adding an Ownership field to the account struct and deriving the Ownable trait by using #[derive(Ownable)].

Verifier Router

The verifier router is a program that allows the management of different verifier programs. The state of the router is kept in a VerifierRouter account, which is Ownable, and contains the ownership information and the number of verifiers currently registered in the router.

#[account]
#[derive(Ownable)]
pub struct VerifierRouter {
    pub ownership: Ownership,
    pub verifier_count: u32,
}

This information is kept in a PDA account, always derived by the router with seed "router" using the canonical bump.

There are two operations supported by the router: registering a new verifier and verifying a proof using a registered verifier. Every registered verifier is identified by a selector, which is just a u32 incremental value.

To add a new verifier, the authority must be the router PDA owner and must provide a verifier program that has the router PDA as the upgrade authority. To check this, the transaction also asks for a PDA account owned by the LoaderV3 program, which stores the information about the deployed program. The upgrade_authority_address field of the ProgramData account must be equal to the router PDA address. This allows the router to have the authority to close the verifier in the emergency stop, by virtually signing a CPI invocation to the LoaderV3 program on behalf of the router PDA.

#[derive(Accounts)]
#[instruction(selector: u32)]
pub struct AddVerifier<'info> {
    // ...

    /// Program data account (Data of account authority from LoaderV3) of the verifier being added
    #[account(
        seeds = [
                verifier_program.key().as_ref()
            ],
            bump,
            seeds::program = bpf_loader_upgradeable::ID,
            constraint = verifier_program_data.upgrade_authority_address == Some(router.key()) @ RouterError::VerifierInvalidAuthority
    )]
    pub verifier_program_data: Account<'info, ProgramData>,

    /// The program executable code account of the verifier program to be added
    /// Must be an unchecked account because any program ID can be here
    /// CHECK: checks are done by constraint in program data account
    #[account(executable)]
    pub verifier_program: UncheckedAccount<'info>,

    // ...
}

When a verifier is added, a new PDA account is created, derived from seeds "verifier" and the selector value. This PDA contains information about the selector value and the verifier public key.

To verify a proof with any of the registered verifiers, the user provides the selector of the verifier and the proof to be verified. The router then derives the corresponding verifier PDA account, retrieves the stored verifier public key, and invokes a CPI instruction to the verifier program.

Emergency Stop Mechanism

The emergency stop mechanism aims to permanently close a verifier program if a critical vulnerability is discovered. Once closed, the verifier program cannot be used anymore to verify proofs.

There are two ways to trigger the emergency stop mechanism: by the router owner or by providing an invalid proof. The router owner can unconditionally close any verifier registered in the router by invoking the emergency_stop_by_owner function. On the other hand, any authority who provides a proof for the zero image_id and journal_digest can invoke the emergency_stop_with_proof function. Creating a valid proof with zero image_id and journal_digest proves that the verifier is critically compromised, and it is possible to prove a false statement. The proof is verified by invoking a CPI call on the selected verifier: if the proof verification succeeds, the verifier program is closed.

In either case, the selected verifier program is permanently closed by invoking a CPI instruction to the LoaderV3 program. The router has the rights to close the verifier because the verifier program has the router PDA as the upgrade authority, and the router can virtually sign the CPI call on behalf of the router PDA.