Uncovering the Phantom Challenge Soundness Bug in Solana's ZK ElGamal Proof Program
Written by Suneal Gong on

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.