ZK/SEC Research notes from zkSecurity
All posts
security · zk · solana

Uncovering the Phantom Challenge Soundness Bug in Solana's ZK ElGamal Proof Program

Solana phantom challenge bug

In June 2025, we discovered a critical soundness vulnerability in Solana's ZK ElGamal Proof Program. The bug allowed a malicious prover to forge a sigma OR proof, bypassing fee validation in confidential transfers. Exploiting this flaw, an attacker could arbitrarily mint or burn tokens by manipulating encrypted fee amounts without ever revealing the actual transfer value.

We responsibly disclosed the issue to the Anza team via GitHub Security Advisory. The response was swift. The confidential transfer extension in the token-2022 program was immediately paused, and the ZK ElGamal Proof Program was later fully disabled at the runtime level. Fortunately, no in-the-wild exploit was discovered.

At the heart of the vulnerability lies a subtle mistake in the Fiat-Shamir transformation. One algebraic component, a prover-generated "challenge" value, was not absorbed into the transcript. While challenges are typically generated by the verifier in zero knowledge proof protocols, sigma OR proofs reverse this role. The prover provides part of the challenge, which must still be included in the Fiat-Shamir transcript to maintain soundness. This overlooked value, which we call the Phantom Challenge, silently broke the protocol's security.

In this post, we explain the origin of the bug, the structure of sigma OR proofs, how this particular omission led to a break in soundness, and the broader lessons it reveals for zero knowledge protocol engineering.

ZK ElGamal Proof Program and Confidential Transfer

Solana's Confidential Transfer is an extension to the token-2022 program that enables users to transfer tokens without revealing the transfer amount. This feature relies on the ZK ElGamal Proof Program to verify zero-knowledge proofs, including sigma protocols and bulletproofs.

All operations in the extension are performed on encrypted balances and transfer amounts, ensuring confidentiality. The system uses the twisted ElGamal encryption scheme, which provides linear homomorphism:

$$\text{encrypt}(a) + \text{encrypt}(b) = \text{encrypt}(a + b)$$

This property allows the network to update balances directly on ciphertexts in a transfer transaction:

new_sender_balance_ciphertext := sender_balance_ciphertext - transfer_amount_ciphertext
new_receiver_balance_ciphertext := receiver_balance_ciphertext + transfer_amount_ciphertext

To ensure validity, the sender must generate several zero-knowledge proofs, such as:

  • Proving the sender's remaining balance is non-negative (checked via a bulletproof range proof).
  • Proving the transfer amount is correctly encrypted for both the receiver and a global authority (checked via a sigma proof).

When processing a confidential transfer, the program first verifies these proofs before updating encrypted balances.

Confidential transfers also support fees: the authority can specify a fee_rate (percentage) and a max_fee (cap). The fee is always rounded up. For example, with fee_rate = 1% and max_fee = 2, a transfer of 50 incurs a fee of 1 (rounded up from 0.5), while a transfer of 300 incurs a fee of 2 (capped).

To preserve confidentiality, the fee amount is also encrypted. The program uses a sigma OR proof to enforce that the encrypted fee is either the correct percentage of the transfer amount or equals max_fee, without revealing which case applies. This prevents leaking information about the transfer amount. The next section explains how the sigma OR proof works.

The Sigma OR Proof

The sigma OR proof (also known as a disjunctive sigma protocol) is a cryptographic technique that allows a prover to demonstrate knowledge of at least one of two statements, without revealing which one. For example, a prover can convince the verifier that they know at least one of the private keys corresponding to two public keys.

The core idea is that the prover can simulate/forge a proof for one statement and generate a valid proof for the other, without revealing which is which. In the challenge phase, the verifier generates a random challenge $c$ for the prover. The prover selects a challenge $c_1$ for one proof and sets $c_2 = c - c_1$ for the other, then sends $(c_1, c_2)$ to the verifier. The verifier checks that $c_1 + c_2 = c$. This allows the prover to freely choose one challenge (and thus simulate that proof), but forces the other to be honest, since it is constrained by $c$. The verifier, not knowing which challenge was chosen by the prover, cannot distinguish which proof is simulated and which is real. It only learns that at least one of the proofs is valid.

To illustrate, consider the proof of knowledge of one out of two private keys in elliptic curve cryptography. Let $G$ be the generator and $p$ the group order. Recall the standard Schnorr protocol for proving knowledge of the private key $x$ behind public key $P = x \cdot G$:

  1. Commitment Phase: The prover samples a random $k$ and computes $R = k \cdot G$. $R$ is sent to the verifier.

  2. Challenge Phase: The verifier generates the challenge $c$ using the Fiat-Shamir transformation: $c = \text{hash}(P || R)$. $c$ is sent to the prover.

  3. Response Phase: The prover computes $s = k + c \cdot x$ and sends $s$ to the verifier.

  4. Verification: The verifier checks $s \cdot G \stackrel{?}{=} R + c \cdot P$.

For the sigma OR proof, suppose the prover knows $x_1$ for $P_1 = x_1 \cdot G$ but not $x_2$ for $P_2 = x_2 \cdot G$. The prover wants to convince the verifier that they know at least one of $x_1$ or $x_2$. The protocol combines two Schnorr proofs as follows:

  1. Commitment Phase: The prover samples random $k_1$ and computes $R_1 = k_1 \cdot G$. For the unknown key, they pick random $c_2$ and $s_2$, and compute a fake commitment $R_2 = s_2 \cdot G - c_2 \cdot P_2$ (so that the verification equation will hold). The prover sends $R_1$ and $R_2$ to the verifier.

  2. Challenge Phase: The verifier generates the challenge $c = \text{hash}(P_1 || P_2 || R_1 || R_2)$ and sends it to the prover.

  3. Response Phase: The prover splits $c$ into $c_2$ (chosen above) and $c_1 = c - c_2$. For the simulated proof (unknown $x_2$), they use $(c_2, s_2)$. For the real proof (known $x_1$), they compute $s_1 = k_1 + c_1 \cdot x_1$. The prover sends $(c_1, s_1, c_2, s_2)$ to the verifier.

  4. Verification: The verifier checks:

    • Challenge consistency: $c \stackrel{?}{=} c_1 + c_2$.
    • First proof validity: $s_1 \cdot G \stackrel{?}{=} R_1 + c_1 \cdot P_1$.
    • Second proof validity: $s_2 \cdot G \stackrel{?}{=} R_2 + c_2 \cdot P_2$.

If all checks pass, the verifier is convinced that the prover knows at least one of $x_1$ or $x_2$. As the verifier cannot tell whether $c_1$ or $c_2$ is pre-generated, it cannot distinguish which proof is forged.

A key subtlety in sigma OR proofs is that some "challenges" ($c_1$ and $c_2$) are provided by the prover, not the verifier. This unintuitive detail is crucial, and as we'll see in the next section, it was the root cause of the Phantom Challenge bug.

The Phantom Challenge Bug

The PercentageWithCapProof in Solana's ZK ElGamal Proof Program is implemented as a sigma OR proof. Its purpose is to enforce that the encrypted fee amount is either a fixed percentage of the transfer amount or exactly equal to max_fee, all without revealing which case applies.

In the relevant code below, the verifier generates a challenge c from the Fiat-Shamir transcript. The prover provides c_max_proof (the challenge for the "max fee" branch), and the verifier computes c_equality = c - c_max_proof for the "percentage" branch (this implicitly ensures that c = c_max_proof + c_equality). Later, the verifier absorbs various proof elements into the transcript and derives a randomizer w to combine multiple equation checks into a single multiscalar multiplication.

pub fn verify(...) -> Result<(), PercentageWithCapProofVerificationError> {
    // ...existing code...
    let c = transcript.challenge_scalar(b"c");
    let c_max_proof = self.percentage_max_proof.c_max_proof;
    let c_equality = c - c_max_proof;

    transcript.append_scalar(b"z_max", &z_max);
    transcript.append_scalar(b"z_x", &z_x);
    transcript.append_scalar(b"z_delta_real", &z_delta_real);
    transcript.append_scalar(b"z_claimed", &z_claimed);
    let w = transcript.challenge_scalar(b"w");
    let ww = w * w;

    let check = RistrettoPoint::vartime_multiscalar_mul(
        vec![
            c_max_proof,
            -c_max_proof * m,
            -z_max,
            Scalar::ONE,
            w * z_x,
            w * z_delta_real,
            -w * c_equality,
            -w,
            ww * z_x,
            ww * z_claimed,
            -ww * c_equality,
            -ww,
        ],
        vec![
            C_max,
            &G,
            &(*H),
            &Y_max,
            &G,
            &(*H),
            C_delta,
            &Y_delta_real,
            &G,
            &(*H),
            C_claimed,
            &Y_claimed,
        ],
    );

    if check.is_identity() {
        Ok(())
    } else {
        Err(SigmaProofVerificationError::AlgebraicRelation.into())
    }
}

However, the implementation originally failed to absorb c_max_proof into the transcript before generating w. This omission allowed a malicious prover to freely choose c_max_proof after seeing w, enabling them to forge a proof that passes the final verification equation. In other words, the prover could manipulate the challenge in a way that should have been impossible if all prover-generated values were properly absorbed into the Fiat-Shamir transcript.

This bug is subtle because, in most sigma protocols, challenges are generated by the verifier and do not need to be absorbed. But in sigma OR proofs, some challenges are generated by the prover and must be included in the transcript to ensure soundness. Otherwise, the prover can exploit this gap.

We created a proof-of-concept to demonstrate the attack. By carefully selecting c_max_proof, an attacker can always satisfy the final equation check. To reproduce the issue, clone the repository and run:

cargo test --package solana-zk-sdk:3.0.0 --lib -- sigma_proofs::percentage_with_cap::test::test_fake_proof --exact --show-output

The Impact And Fix

The PercentageWithCapProof is primarily used in token-2022 to enable confidential transfers with fee configurations. This Fiat-Shamir vulnerability allowed a malicious prover to bypass the fee check and set an arbitrary fee amount in a token transfer transaction. As a result, an attacker could set the fee to zero to avoid paying, or worse, manipulate the fee to burn tokens from a receiver's account or mint tokens to the authority. As the transfer amount and fee are also encrypted to the receiver and the authority, any exploitation would be detectable.

Upon identifying the vulnerability, we immediately reported it to the Anza team via GitHub Security Advisory along with a proof-of-concept. The Anza team responded quickly, disabling the confidential transfer extension in token-2022 and later rolling out a patch to all validators to disable the ZK ElGamal Proof Program instruction in the Solana runtime. The underlying bug was ultimately fixed by adding a single line of code to absorb c_max_proof into the Fiat-Shamir transcript:

        transcript.append_scalar(b"z_x", &z_x);
+       transcript.append_scalar(b"c_max_proof", &c_max_proof);
        transcript.append_scalar(b"z_delta_real", &z_delta_real);
        transcript.append_scalar(b"z_claimed", &z_claimed);

No exploit was found in the wild for this issue.

The vulnerability was identified through personal research conducted outside of regular work hours. We would like to thank the Anza team for their quick response and the broader zkSecurity team for supporting the disclosure process.

Conclusion

The Phantom Challenge bug shows how a subtle oversight in cryptographic protocol design can undermine the security of an entire system. By failing to absorb a prover-generated challenge into the Fiat-Shamir transcript, the soundness of the sigma OR proof was broken, allowing attackers to bypass critical safeguards in confidential transfers.

This incident highlights the importance of understanding the nuanced roles of the prover and the verifier in advanced protocols. In constructions like sigma OR proofs, every value that can influence the proof, especially those generated by the prover, must be carefully absorbed into the transcript. This kind of issues can be avoided by following the golden rule of Fiat-Shamir: "hash everything the prover sends". Overlooking such details can have far-reaching consequences, even for experienced teams.

At zkSecurity, we specialize in cryptographic protocol security. If you are building with ZK proofs, homomorphic encryption, or other advanced cryptography, reach out to us to strengthen your protocol.

Keep reading
Recommended

Verifying Cairo proofs on Ethereum

We've been teaming up with StarkWare to create the EVM adapter, and we're thrilled to say that we've recently verified Cairo proofs on the Ethereum mainnet! Building on the work of Andrew Milson and Aditya Bisht, we're making StarkWare’s technology more accessible to everyone. Our blog dives into how Cairo programs are verified on Ethereum and showcases our new library, `stark-evm-adapter`, which helps parse Cairo proofs for Ethereum. Interested in seeing how it works? Check out our demo and learn how to integrate this tech into your system. Plus, stay tuned for future updates as we refine and expand this exciting tool!

Jason Park · August 06, 2024

Unveiling the Magic Behind Starknet: A Deep Dive into New Specifications

In our deep dive into Starknet's cryptographic components, we tackled the challenge of missing comprehensive specifications by reverse-engineering and publishing draft specs. Readers can explore our work on key protocols like Starknet Channels and STARK Verifier, with an open invitation to join us in refining these efforts. Curious to see what we've pieced together? Check out our drafts and get involved!

David Wong · November 15, 2024

Powers-of-Funbenius

A one-character repeated-squaring bug in Inferno's Limbo implementation turns the intended random polynomial check into a linearized Frobenius check over a binary extension field. This post walks through why the usual Schwartz-Zippel argument disappears, how periodicity of the Frobenius map makes collisions immediate after 64 multiplication gates, and how even smaller circuits can cheat with noticeably higher probability.

Mathias Hall-Andersen · May 05, 2026
More to explore

zk.golf: Fearless and Collaborative Optimization of Circuits

zk.golf is a platform where people can compete on creating the most efficient zk circuits for specific problems. It is enabled by what we call "fearless optimization", which is achieved by combining formal verification and frontier AI models. By the end, you will be convinced that nobody should look at constraints ever again in their life.

Giorgio Dell'Immagine, Mathias Hall-Andersen · July 02, 2026

Detecting boomerang values in zero-knowledge circuits using tag analysis

Ever wonder how zkApps ensure their execution is spot-on? This post digs into the idea of *boomerang values*, sneaky bugs that can crop up when zkApps mix in-circuit and out-of-circuit logic. We explore how these values disrupt your circuit's trustworthiness, especially when variables are reintegrated unverified. Plus, we share how tools like MIRAI's tag analysis can help spot these issues, making your zkApps more secure. Perfect for anyone into zero-knowledge applications or budding Rust enthusiasts looking for insights on taming complex bugs!

ZK/SEC · August 25, 2023

Archetype x zkSecurity - Proof in the Pudding: Introduction to Data Availability (Sampling)

In the latest "Proof is in the Pudding" session, we team up with Archetype to break down the essentials of Data Availability Sampling. We dive into how rollups and Ethereum's DA system work, explore the role of DA chains, and touch on the basics of verifiable sharding. This introduction is perfect for anyone curious about the foundations of data availability sampling and how these concepts are playing out in the blockchain world.

ZK/SEC · October 02, 2025