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

Fiat-Shamir Bugs: How One Missing Line Breaks a Proof System

Fiat-Shamir (FS) looks simple at first glance: derive each verifier message by hashing the protocol transcript. Yet one missing transcript update can leave a malicious prover enough freedom to forge a proof. We have found this pattern repeatedly while reviewing and auditing production proof systems, including failures that go beyond the familiar omission of a prover message.

This post turns those findings into a practical guide. We need to distinguish fixed protocol parameters from inputs the attacker can choose. By the end, you should know what must influence each challenge, how to spot dangerous omissions during a review, and how to avoid introducing them in an implementation.

From interaction to Fiat-Shamir

Many proof systems rely on probabilistic verifier checks. The verifier can combine several equations into one randomized check. The prover must fix its earlier messages before learning the challenge used in that check, so a false proof should pass only with negligible probability.

In a public-coin interactive proof, the verifier samples its messages using fresh public randomness. In the protocols below, these verifier messages are random challenges. The main problem is that interaction is costly in practice. Both prover and verifier must be online for the exchange, every round adds network latency, and the resulting proof cannot simply be generated once and verified independently later.

The Fiat-Shamir transformation produces a non-interactive argument.1 Instead of waiting for a verifier message, the prover derives it by hashing the transcript so far and mapping the output to the required message distribution. The hash acts as an unpredictable challenge generator: once the transcript prefix is fixed, the prover cannot independently choose the resulting challenge. The verifier later reconstructs the same transcript and derives the same challenge.

The following simplified protocol is enough background to understand the transcript omissions below. An interactive protocol proceeds as follows:

  1. the prover sends a message $m_1$

  2. the verifier samples a verifier message $c_1$ (here, a random challenge)

  3. the prover sends a message $m_2$

  4. the verifier samples a verifier message $c_2$

Let $\mathsf{instance}$ denote the public statement being proved, and let $\mathsf{domain}$ identify the protocol. After applying FS with a cryptographic hash function $H$, the challenges can be written schematically as:2

$$ c_1 = H(\mathsf{domain}, \mathsf{instance}, m_1) $$ $$ c_2 = H(\mathsf{domain}, \mathsf{instance}, m_1, c_1, m_2) $$

Running transcripts and duplex sponges

Implementations usually keep a running transcript: protocol values are added to an evolving hash state in order, and challenges are derived from that state. Adding data to this state is called absorbing; deriving output from it is called squeezing. A duplex sponge supports alternating between these operations. Each challenge must be derived using all previous prover messages seen in the protocol.

Where Fiat-Shamir fails in practice

A useful audit question

Can the prover change an input after learning a challenge, keep that challenge unchanged, and use the change to make the check pass? If so, the proof may be forgeable.

The advice to “just hash everything” leaves developers to decide which inputs, parameters, and labels identifying the protocol belong in the transcript.3 Many papers specify only the interactive protocol, while production code implements its non-interactive FS transformation. When the transcript schedule, encodings, domain separation, and challenge derivation are left unspecified, implementers must make security-critical decisions themselves.

A missing transcript update can allow the following attack:

  1. A malicious prover starts constructing a proof for a false statement.

  2. The incomplete transcript produces a challenge.

  3. The prover chooses or modifies an omitted value after learning that challenge.

  4. The prover uses this extra freedom to make the randomized verifier check pass.

Note

This attack is sometimes described as “rewinding.” No network conversation needs to be rewound in a non-interactive implementation. The attacker runs the prover locally, observes the deterministic challenge, changes the omitted value, and continues or retries.

A recurring mistake is misidentifying what belongs to the instance at each verification step. A verification key may be fixed for one application and caller-selected in another. An input's shape can change its meaning. When a verifier batches checks, even the completed proofs become inputs to the randomized check. The sections below examine these boundaries, then protocol identity, challenge schedules, and challenge derivation.

We use the established term weak FS for challenges that omit the public statement. Related failures involve prover messages, verification keys, encodings, prover-supplied sub-challenges, and deterministic batching. We introduce names for these other cases below and explain what to look for.

Weak FS: What belongs to the public statement?

First check what belongs to the public statement, rather than relying only on what the implementation declares as public inputs. Depending on the protocol, it can include public inputs, commitments, verification keys, circuit identifiers, upper bounds in range proofs, table sizes, modes, or any other public value that changes the relation being verified. If an intended public input is only a private witness value, with no constraint binding it to the intended value, the prover can choose it subject to the remaining constraints. Hashing the declared public inputs will not fix that omission.

Express the intended statement before hashing it

Suppose a circuit should prove authorization for a particular recipient. If the recipient is only a private witness value, with no constraint tying it to the intended public recipient, the prover can choose any recipient that satisfies the other constraints. Hashing the public inputs will not fix this missing constraint; see our post on under-constraining. First check that the circuit proves the intended claim, then that FS binds the complete public statement.

Schnorr is a well-known example of this bug. It proves knowledge of the secret exponent behind a public key, as explained in our Sigma protocol post. If the challenge hashes only the prover's commitment and omits the public key, an attacker can learn the challenge first, then choose a public key that makes verification pass without knowing its secret exponent. Absorb the complete statement before deriving the challenge, alongside the prover messages that the interactive protocol requires to be fixed at that point. The CFRG Fiat-Shamir draft, Section 5.2, specified absorbing the instance before any prover message.

Public keys are one example. Ed25519 uses SHA512(R || A || M) when deriving its challenge, where R is the nonce commitment, A is the public key, and M is the signed message. Including A makes the challenge depend on which public key the signature will be checked against.

What about ECDSA?

ECDSA hashes the message without including the public key. Its verification equation allows a suitable key to be selected for an existing signature and message, which differs from forging under a fixed victim key.5 Ed25519's key-dependent challenge prevents that direct construction.

The Let's Encrypt attack exploited related key selection in RSA. ACME binds domain validation to the account key by including its thumbprint.

For structured public inputs, the statement can depend on both the values and where they belong. The Stwo-Cairo verifier absorbed public memory values before deriving the interaction challenges, but omitted the corresponding public memory IDs. Those IDs were later used in lookup relations, so a malicious prover could change them after learning the challenges and manipulate the lookup sum.

The omission is visible in the Cairo code:

for entry in data.span() {
    let (_, val) = entry;
    flat_data.append_span((*val).span());
}
self.mix_u32s(flat_data.span());

The _ discards the memory ID, so only val reaches the hash. The fix is to absorb each ID alongside its value.

We found missing inputs in our zkLighter audit. A GKR proof verified MiMC hash computations inside a larger circuit. Its initial randomness came from a block commitment that omitted some GKR inputs and outputs. The prover could learn the initial evaluation point, then change the claimed outputs while preserving their polynomial's evaluation there. This allowed incorrect hash results to pass and could be used to forge Merkle proofs.

The report shows the vulnerable call in block_constraints.go:

api.AssertIsEqual(commitment, block.BlockCommitment)
err = gkrMimc.VerifyGKRMimc(commitment)
if err != nil {
    return err
}

The equality check validates the block commitment, but that commitment does not cover all GKR inputs and outputs. Those values formed the statement of the inner proof, even though they appeared inside the outer circuit. We recommended passing all of them to gnark's commitment mechanism before deriving the randomness. This uses the BSB22 construction, which avoids recomputing the entire initial transcript hash inside the circuit.

Tip

Write down the complete public statement for each proof, including proofs verified inside another proof. Both prover and verifier must absorb it before deriving challenges that depend on it. The API should make clear which component absorbs each input.

Circuit substitution FS: Do I need to absorb the verification key?

This is a special case of weak FS: the omitted part of the statement is easy to mistake for a fixed parameter. We call it Circuit substitution FS, and it can enable a verifier key substitution attack, as the examples below illustrate.

Some verification APIs accept a verification key alongside the proof and public inputs. If the party submitting the proof can also choose that key, it can change which circuit the verifier checks. The public input values alone do not identify the circuit, so the supplied verification key must be bound into the transcript too. If an application instead fixes one verification key independently of the attacker, its protocol identifier can identify that key, provided the verifier enforces this choice. A key registered by the attacker still counts as attacker-selected: they may have constructed the key and proof together offline.

Binding the commitments alone can still leave out the circuit they are supposed to represent. We found this omission in Aleo Synthesizer's circuit certification. A certificate was supposed to establish that a verification key's commitments represented the circuit supplied to the network. The transcript was initialized from those commitments:

let mut sponge =
    Self::init_sponge_for_certificate(fs_parameters, &verifying_key.circuit_commitments);
let mut challenges =
    sponge.squeeze_nonnative_field_elements(verifying_key.circuit_commitments.len());

The missing value was circuit_id, a hash of the circuit's constraints. An attacker could change the circuit and its ID after seeing the challenges, while keeping the commitments unchanged, and obtain a certificate for a key that did not correspond to the claimed circuit. Aleo fixed the omission by hashing the entire verification key.

Circuit commitments are one place to look for this confusion between fixed parameters and inputs the caller can choose. Mina's Kimchi transcript did not absorb the commitments in the verifier index, which describe the circuit, before deriving the proof's challenges. We explain the attack in Plonkish and AIR terms.

In a Plonkish proof, the circuit constraints are combined into a polynomial $f$. A satisfying witness makes $f$ divisible by the vanishing polynomial $Z_H$ of the circuit's evaluation domain. The prover commits to a quotient polynomial $t$, and the verifier checks the identity at a point $\zeta$ derived from the transcript:

$$ f(\zeta) = t(\zeta) Z_H(\zeta). $$

The circuit's selector polynomials contribute to $f$. If their commitments do not influence $\zeta$, an attacker can try to choose the circuit after learning that point. They first commit to witness polynomials and a claimed quotient, then adjust the circuit's selectors so that the displayed equality holds at $\zeta$, even though the polynomial identity $f = t Z_H$ fails and the witness violates the resulting circuit's constraints. The witness and quotient commitments stay fixed; the circuit is chosen to fit the check at the known point.

For an AIR constraint with a constant $c$ chosen by the circuit author, write the constraint polynomial as $g(X)-c$, where $g$ comes from the committed trace. The verifier checks

$$ g(\zeta) - c = t(\zeta)Z_H(\zeta). $$

If $c$ is omitted from the transcript, the attacker can keep the trace and quotient commitments fixed, learn $\zeta$, and choose

$$ c = g(\zeta) - t(\zeta)Z_H(\zeta). $$

This passes the evaluation check. But the trace must satisfy $g(x)=c$ at every row $x \in H$, which is impossible if $g$ takes different values across those rows. In Kimchi, the attacker would adjust the circuit's selector polynomials to make the check pass at $\zeta$. Their commitments belong to the verification key.

The attacker must construct actual selector polynomials and derive the corresponding verification key from that circuit. They can prepare the circuit, key, and malicious proof together offline, then register the key before submitting the proof. Requiring registration first does not enforce the order of these offline choices. The security failure would be accepting a proof that violates the registered circuit's own rules.

Scope of the attack sketch

This explains the circuit-selection strategy, not a complete Kimchi exploit. A concrete construction must satisfy the selector and degree restrictions, keep every relevant challenge unchanged, and pass the remaining proof checks. The team's assessment at the time concerned user-selected keys for SnarkyJS and zkApps; it explicitly excluded the Mina system deployed then.

The fix added these lines at the start of verification, with a corresponding change in the prover:

let verifier_index_digest = index.digest::<EFqSponge>();
fq_sponge.absorb_fq(&[verifier_index_digest]);

Changing the covered commitments now changes the digest and the challenges, removing the freedom to adjust the key while keeping those challenges fixed.

Even hashing the verification key may not be enough if it binds only a program that generates the constraints. Fenzi's recent work shows attacks on certain protocols where a generator controlled by the attacker can anticipate the challenges and produce a false statement that passes verification. For that setting, the paper's mitigation is to derive the first challenge from the generated statement itself.

Tip

Check who can choose or change each parameter in your application. Variable parameters are part of the statement, so validate and absorb them, directly or through a canonical, collision-resistant digest. Rely on the protocol's domain separator to identify fixed parameters only if the implementation prevents callers from changing them.

Ambiguous FS: Does my encoding preserve the complete statement?

The transcript must also preserve input structure when it changes the statement. Suppose a proof accepts two columns of public inputs. Flattening [[1, 2], [3]] and [[1], [2, 3]] produces the same sequence [1, 2, 3]. Every value reaches the hash, but the column boundaries disappear. Unless another transcript input records them, both statements produce the same challenges without any search for a hash collision.

This is a non-injective encoding: different inputs have the same representation because the encoding loses the collection boundaries. We call this Ambiguous FS.

A related example appeared in Solana's standalone range proof. The verifier absorbed only the sum of the bit lengths:

let nm: usize = bit_lengths.iter().sum();
// ...
transcript.range_proof_domain_separator(nm as u64);

The vectors [32, 32] and [31, 33] both give nm = 64, although they specify different ranges. Absorbing each bit length would preserve that distinction.

Collection boundaries are not the only information a transcript can lose. Absorbing only algebraic combinations can also make different statements indistinguishable.

We also found this problem in Signal's zkcredential in libsignal, at revision 88595dd6079572bbed404ea36a6f26237c808cbb. In presentation.rs, lines 690–693, the verifier adds only the difference between the second attribute commitment and ciphertext point:

point_args.add(
    format!("C_y{second_point_index}-E_A{second_point_index}"),
    C_y[second_point_index] - self.core.attr_points[second_point_index],
);

An attacker can shift the encrypted attribute and its commitments together while preserving the combinations used by the transcript. The challenge and the same poksho proof remain valid even though the encrypted attribute has changed. Both sides need to absorb the complete presentation commitments, including the attribute-commitment count.4

How the combinations stay unchanged

The later verify_proof call passes self.core.authenticated_message without adding the complete presentation commitments. The transcript therefore includes combinations such as $C_{y,2} - E_{A,2}$ and the verifier-computed value, shown here with unchanged public-attribute terms omitted:

$$ Z = C_V - W - x_0 C_{x,0} - x_1 C_{x,1} - \sum_i y_i C_{y,i}, $$

but does not separately include all the presentation commitments. The credential public key also exposes a ladder of points for different attribute counts, whose adjacent difference is $I_2 - I_3 = y_2 G_{y,2}$. For any chosen scalar $a$, an attacker can therefore apply:

$$ E'_{A,2} = E_{A,2} + aG_{y,2}, \qquad C'_{y,2} = C_{y,2} + aG_{y,2}, \qquad C'_V = C_V + a(I_2 - I_3). $$

Unchanged challenges alone do not make a proof valid for a different statement: the new equations must also pass. Moving values between columns can change the polynomial being checked, though padding or trailing zeros can sometimes preserve it.

Check each step: does the encoding collision produce identical challenges, does the same proof verify for a different statement, and is that statement false? A reused proof might also authorize an unintended action even if both statements are true. An encoding collision alone does not establish either outcome.

Tip

Statements with different meanings must have different transcript encodings. Keep the order and grouping of values, including message boundaries, wherever they affect the statement. For variable columns, absorb the column count and each column's length before its values. Check that preprocessing retains this information. If the encoding assumes a fixed structure, reject inputs that don't match it.

Lossy FS: Does each individual value have a distinct encoding?

The encoding of each individual value must preserve the meaning used by the verifier and the application. We call failures at this level Lossy FS. For example, reducing an arbitrary integer modulo $p$ before absorption merges $x$ and $x+p$. That is appropriate if both denote the same field element everywhere, but loses information if another verifier check or the application treats them as different integers.

A concrete exploit appeared in CIRCL's DLEQ proof implementation. The statement $(g, g_x, h, h_x)$ claimed knowledge of a common exponent $x$ such that $g_x = g^x$ and $h_x = h^x$ modulo $N$. The verifier accepted signed big.Int inputs, while challenge computation used Go's FillBytes, which encodes the absolute value. Thus $g_x$ and the signed integer $-g_x$ produced identical bytes.

An attacker could take an honest proof whose challenge $c$ was even and replace only $g_x$ with $-g_x$. Both the hash input and the relevant modular exponentiation stayed unchanged:

$$ (-g_x)^c \bmod N = (-1)^c g_x^c \bmod N = g_x^c \bmod N. $$

The verifier therefore accepted the same proof for the altered input without the attacker knowing the witness. For square bases and a modulus $N = pq$ with both primes congruent to $3$ modulo $4$, negating a square leaves the subgroup of squares, so the altered input does not satisfy the intended relation.

The fix rejected inputs outside $0 < x < N$ before hashing. This closes the signed-input attack; the protocol's other input requirements, including group membership, still need to be enforced.

Different byte strings can also decode to the same signature or argument. The serialization is malleable if an attacker can change the encoding and it still passes verification. For example, accepting both compressed and uncompressed encodings of a curve point lets an attacker switch between them without knowing the secret key or witness.

We found a similar issue in OpenVM's proof decoder: Decode::decode_from_bytes returned without checking for trailing bytes. Appending bytes to a valid proof could therefore bypass duplicate detection based on proof bytes or hashes:

fn decode_from_bytes(bytes: &[u8]) -> Result<Self> {
    let mut reader = Cursor::new(bytes);
    Self::decode(&mut reader)
}

The fix rejects the input if reader.position() != bytes.len() as u64 after decoding.

Re-encoding an argument does not make a false statement pass verification. It can still break checks that compare bytes: a new accepted signature encoding breaks strong unforgeability when freshness is defined over signature bytes. If an application identifies pseudonyms by their bytes, it can treat two encodings of the same curve point as different identities within the same scope.

Tip

Check inputs before hashing them, and make sure values with different meanings don't end up as the same bytes. Accept only the expected encoding and reject extra bytes after a proof, even if the transcript is encoded correctly. Check for reused proofs too: a consistent encoding does not make each proof unique or prevent replay.

Silent FS: Do I need to absorb verifier challenges?

Once the encoding preserves the inputs, the transcript must also distinguish the operations performed on them, including the order, types, and number of challenges requested. The protocol's domain separator can identify a fixed schedule. We call omissions here Silent FS. You do not necessarily need to absorb the resulting values themselves: they are already determined by the current state and the prescribed derivation.

For example, consider two protocols that absorb the same instance and prover messages:

  1. protocol A generates $c_1$ and $c'_1$, absorbs $m_2$, then generates $c_2$

  2. protocol B generates only $c_1$, absorbs the same $m_2$, then generates $c_2$

If both incorrectly reuse the same domain separator and derive $c_2$ from only the absorbed messages, the extra challenge request is invisible. The two protocols reach the same later challenge without anyone finding a hash collision. Reusing the domain separator is the flaw here. Giving A and B distinct domain separators prevents this overlap without absorbing verifier challenges. Whether the overlap permits a forgery depends on their verification equations.

A related failure to distinguish contexts appeared in Aleo's native/raw Schnorr signatures. Native Aleo serialization and raw encoding fed into the same signing scheme without a domain separator identifying which format was intended. When two messages encoded to the same sequence of field elements under these different formats, a signature made for one context could verify in the other using the same public key.

The report described application-dependent risks, including a message presented to a user in one format being reinterpreted as a request authorizing an Aleo credit transfer. We recommended separating native Aleo signatures from other application signatures. This finding illustrates the need to identify the signing context; it does not demonstrate an omitted challenge-count attack.

The CFRG Fiat-Shamir draft's session-identifier rules required the identifier to distinguish the argument, hash suite, and ordered codecs, including how verifier messages are sampled.

Advancing a squeeze cursor need not preserve the number of bytes read once another value is absorbed. The draft's XOF duplex construction reset its output reader on nonempty absorption. Follow the construction's domain-separation and encoding rules; do not assume that using a sponge makes different protocol schedules distinguishable.

Tip

Specify when to absorb data and request challenges. Use domain separation to identify this schedule and the context in which the proof is used. Absorb runtime choices that change the schedule before deriving the affected challenges. Check whether the transcript API keeps different schedules distinguishable; a change in internal state alone is no guarantee.

Phantom FS: Do I need to absorb prover-supplied challenges?

Yes, before deriving a later challenge that randomizes a check involving them. Unlike the verifier-derived challenges above, these values come from the prover. Treat a prover-controlled challenge as a prover message. Omitting one can cause Phantom FS.

A Sigma OR proof proves knowledge of a witness for at least one of two statements, as explained in our Sigma protocol post. The verifier derives an overall challenge $c$, while the prover supplies a branch challenge $c_0$. The other branch uses $c_1 = c - c_0$. Knowing $c$ therefore does not determine the split.

The verifier must check both branch equations. It may combine them into one check using random coefficients, an optimization called batching. Any such coefficients must account for the prover's choice of split. We discuss how to derive them in the Batched FS section.

The Solana phantom-challenge bug shows what happens when the batching randomizer does not account for that split. The prover supplied c_max_proof, while the verifier derived another randomizer w to batch equations. Because c_max_proof was not absorbed before deriving w, the prover could choose it after seeing w and forge the final equation.

The vulnerable order was:

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);
// c_max_proof is missing
let w = transcript.challenge_scalar(b"w");

The fix was to absorb the branch challenge before deriving w:

transcript.append_scalar(b"c_max_proof", &c_max_proof);
Tip

Check who supplies each value and who can change it. Anything supplied by the prover is a prover message, even if an equation calls it a challenge. Absorb it before any later challenge that requires it to be fixed.

Batched FS: Do I need to absorb the proof itself, including the last prover messages?

The batching randomizer must account for the rest of the proof too, including the final responses. All of this data is now an input to the batch check. We call omissions in deterministic batching transcripts Batched FS, even when batching is only a verifier optimization.

Note

Verifier-only batching is not FS: it removes no interaction and leaves proof generation unchanged. We use Batched FS here for missing inputs in hash-derived batching coefficients. Batching inside the proof protocol can involve FS challenges, as in the Shplonked example below.

Verifier-only batching combines verification equations using random coefficients that the prover does not need to construct the proof. It is separate from applying FS to the proof protocol. The prover learns a proof challenge before computing its response. Batching coefficients must come after all the values being checked are fixed, including that response. If the verifier derives the coefficients by hashing, it must absorb the response too. Reusing the proof challenge or its incomplete transcript can let an attacker change omitted values so that errors cancel. Individual verification can remain sound while the batch check accepts invalid proofs.

Our team reported this mistake in FastCrypto's KeyConsistencyProof. Its verifier combined several equations into one multi-scalar multiplication. The batching weights were derived from the proof challenge c:

let c = Self::challenge(
    sender_public_key,
    recipient_encryption_keys,
    ciphertexts,
    &self.a1,
    &self.a2,
    &self.a3,
);
// ...
let alpha = fiat_shamir_challenge(&("alpha", &c));
let beta = fiat_shamir_challenge(&("beta", &c));

The inner weights mu and rho were derived from c too. But c did not include the responses z1 and z2, so a malicious prover could modify them without changing any of those weights and make invalid checks cancel. The patch retained FS for the proof challenge and sampled the batching weights from fresh verifier randomness.

For verifier-only batching, the verifier can:

  1. use fresh verifier randomness after receiving the complete batch

  2. derive deterministic coefficients from all the data being checked, under a separate batching domain

  3. perform the checks separately when batching provides little benefit

When fresh verifier randomness is available, using it for verifier-only batching is often simpler. In deterministic or on-chain environments, every batched term must be fixed before the coefficients are derived.

The batch verification algorithm in Section 5.6 of the CFRG Sigma-protocol draft included the session identifiers, complete instances, and complete argument strings (including responses) when deriving deterministic coefficients, using a separate batching transcript. The draft recommended this deterministic derivation and permitted fresh verifier randomness. This algorithm bound the inputs omitted in the examples above.

Batching can also be part of the proof protocol itself. An example is Aptos's Shplonked implementation, where the missing values were the externally supplied polynomial commitments commitment_msms. The verifier derived c and x, then combined those commitments with weights derived from the challenges:

append_batch_statement_to_transcript::<E>(trs, sets, y_rev, phi_y, com_y_hid);
let c: E::ScalarField = trs.challenge_scalar();
trs.append_point(pi_1);
let x: E::ScalarField = trs.challenge_scalar();
// ...
let merged = merge_msm_inputs_with_scales(&commitment_msms, &weights)?;

The helper absorbed evaluation data but not those commitments. Unless the caller had already absorbed them, an attacker could modify them without changing either challenge while preserving their weighted sum. The API needed to absorb the commitments or enforce that the caller had done so.

Shplonked's prover also uses c and x, so replacing them with private verifier randomness would require changing the protocol. The reviewed source explicitly marked this implementation as insufficiently vetted and for benchmarking only. This example concerns that API and does not establish a vulnerability in deployed Aptos validators.

Tip

For each randomized check, list all its inputs and make sure they are fixed before choosing the coefficients. For verifier-only batching, use fresh randomness after receiving the complete batch or derive coefficients from the complete batch under a separate domain. When the prover also needs the coefficients, follow the protocol's rules for what to absorb and when to derive them.

Summary

Type What goes wrong What to check
Weak FS The public statement does not influence the challenge Absorb the complete statement alongside the required prior prover messages
Circuit substitution FS A caller-selected verification key or parameter does not influence the challenges Absorb changeable keys and parameters directly or through a collision-resistant digest; identify truly fixed parameters through the protocol
Ambiguous FS Flattening or algebraic combinations erase distinctions between statements Absorb individual values and preserve their order and variable shapes; validate shapes fixed by the protocol
Lossy FS Encoding one value erases a distinction used by verification Validate the input domain and encode each accepted value injectively
Silent FS Different protocols or challenge schedules share the same hash inputs Identify the full fixed schedule through domain separation; absorb variable choices that change it
Phantom FS A prover-supplied challenge is omitted before a later randomizer Treat branch challenges as prover messages and absorb them before later dependent challenges
Batched FS Batching challenges omit commitments or proof values used in the combined check Absorb all relevant values before their challenges; fresh verifier randomness is an option only for verifier-only batching

Towards better Fiat-Shamir implementations

These bugs have motivated transcript APIs that automatically absorb proof messages and specifications that define the FS transformation.

Merlin uses an application domain separator and labels and lengths for messages and challenge requests. These distinguish protocol contexts and the roles of transcript entries, helping prevent different protocols from using the same hash inputs to derive challenges.

Halo2's transcript API couples proof I/O with absorption: reading or writing a proof point or scalar also absorbs it into the transcript. Its byte-stream format also helps avoid a separate verifier bug. The verifier reads the messages required by the protocol and verification key, so an omitted optional field in the proof cannot tell it to skip a required check.

SAFE specifies a sponge API for permutation-based schemes, including FS, and describes how to incorporate protocol metadata.

Chiesa and Orrù give concrete security bounds for a duplex-sponge FS construction. The work also provides spongefish, a Rust implementation with codecs for converting protocol messages to and from the sponge's domain.

These tools handle transcript operations, but developers must still identify the complete statement and parameters callers can choose, and follow the challenge schedule. Absorbing proof messages cannot catch missing public inputs or verification keys, and a sponge does not guarantee sound challenge derivation. Papers may leave FS to the implementer, so specifications must say what is absorbed, how it is encoded, and when challenges are derived. If a protocol calls another verifier, they must also say which component absorbs shared inputs.

We collaborated with other cryptographers on specifying Fiat-Shamir in the CFRG. This work covered message encoding, challenge decoding, session identifiers, and proof serialization. Protocol authors can use it to document their transcript operations so developers do not have to infer them from an interactive protocol.

Conclusion

After reading this post, you should now have a practical framework for reviewing Fiat-Shamir in real systems: identify what the prover can still change, determine which challenges must depend on those values, and examine how each challenge is used by the verifier. These checks are useful during design, implementation, and auditing.

Of course, none of these things replace having an expert take a look at your code! Here at zkSecurity, we have a lot of experience looking for bugs and finding them, and zkao is designed to detect these kinds of mistakes continuously. Make sure you reach out to us when the timing is right!

Acknowledgements

We would like to thank Stefanos Chaliasos, Martín Ochoa, Alin Tomescu and Michele Orrù for reviewing this post and for the feedback.


  1. A proof remains sound even against a prover with unlimited computing power. An argument only guarantees soundness against provers with limited computing power. Fiat-Shamir with a concrete cryptographic hash gives an argument. 

  2. Including $c_1$ in the hash for $c_2$ is optional: $\mathsf{domain}$, $\mathsf{instance}$, and $m_1$ already determine it. We include it to show the full exchange. The protocol must still specify the challenge schedule; see Silent FS

  3. For example, the CFRG Fiat-Shamir draft suggested a label containing the application’s name and the Git commit hash of its cryptographic specification, so the transcript identifies the exact protocol rules being used. 

  4. The ladder of points in the public key also lets a credential holder forge a credential. They can shift an attribute by $aG_{y,2}$ and its MAC component $V$ by $a(I_2-I_3)$, then generate a fresh presentation. Absorbing the complete statement does not stop this attack; the attribute MAC keys must also be separated by attribute count. 

  5. Given a signature $(r,s)$, message representative $z$, and a compatible curve point $R$ whose x-coordinate reduces to $r$, choosing $Q = r^{-1}(sR-zG)$ makes ECDSA verification pass, subject to the usual validity checks. Changing the message gives another candidate key, without establishing knowledge of its private scalar. Ed25519 blocks this rearrangement because changing the key changes the challenge. The Let's Encrypt attack involved RSA; binding validation to the account key requires more than switching signature algorithms. 

Keep reading
Recommended

AI meets Cryptography 1: What AI Found in Cloudflare's CIRCL

We pointed our AI audit pipeline at Cloudflare's CIRCL experimental cryptography library and confirmed seven real bugs, from a critical float64 precision loss in threshold RSA to a complete access-control break in attribute-based encryption. All seven are now fixed upstream. This is the first post in a series on bugs our agents found across open source cryptography.

Stefanos Chaliasos, Hao Pham, False Witness team · July 07, 2026

Sigma dance: commit, challenge, respond

Learn the fundamentals of Σ-protocols through the classic Schnorr protocol, exploring the three-step dance of commit, challenge, and respond. This post walks through knowledge soundness and witness extraction, then shows how to compose Sigma proofs with AND/OR logic and Pedersen commitments. See working SageMath implementations, discover how Fiat-Shamir transforms interactive proofs into non-interactive signatures, and understand the deeper mathematical structure as proofs of knowledge of homomorphism pre-images.

teddav · November 18, 2025

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

In June 2025, we uncovered a serious soundness issue in Solana's ZK ElGamal Proof Program that could let attackers manipulate confidential token transfers undetected. We worked with the Anza team to quickly address the flaw by pausing and disabling vulnerable components. This post dives into the root cause, which was a subtle mistake in handling prover-generated challenges within sigma OR proofs, revealing broader lessons in zero-knowledge protocol security. If you're interested in cryptographic protocol design, this could provide valuable insights.

Suneal Gong · June 26, 2025
More to explore

Common Circom Pitfalls and How to Dodge Them, Part 1

Programming in Circom comes with its fair share of challenges. After reviewing numerous Circom codebases, we’ve identified certain anti-patterns that occur frequently. In this series, we’ll provide a comprehensive overview of these issues to help you avoid the most common pitfalls. Of course, this won’t be a complete list of every mistake possible (Circom has plenty of ways to trip you up). But the footguns we’ll cover are the ones that tend to catch developers off guard the most.

Marco Besier · June 24, 2025

Private delegated computation is here, and there will be bugs!

Dive into the intriguing world of zero-knowledge cryptography with us as we unravel its connection to trusted and confidential computing. Learn why this tech is making us rethink security like never before. From the evolution of private and delegated computing to the revolutionary power of zero-knowledge proofs, we'll explore how these advancements are shaping industries beyond cryptocurrencies. With big players like Microsoft jumping on board and the potential for new applications, zero-knowledge cryptography is poised to redefine digital trust. Join us on this journey as we launch our new security consultancy, designed to navigate and secure this cutting-edge landscape.

ZK/SEC · May 30, 2023

Comparison of formal verification frameworks for arithmetic circuits

A hands-on comparison of formal verification frameworks for arithmetic circuits, evaluating those in the ACL2 Book (r1cs, PFCS), acl2-jolt, Garden (Rocq), zk-lean, sp1-lean, and Clean. Each framework is tested on reproducibility, available examples (from basic field elements to RISC-V VM instructions), and practical verification tasks including the IsZero and weighted-sum circuits. The evaluation includes both human and Claude Code's ability to work with each framework, revealing insights about installation difficulty, proof automation capabilities, and the maturity of publicly available examples. This post maps the current landscape of formally verified ZK circuits and discusses what's coming next in this rapidly evolving field.

Yoichi Hirai · November 19, 2025