A ZK proof can verify correctly while the system still leaks private data, proves the wrong semantics, or authorizes the wrong action.
Most engineers hear "ZK audit" and think the main question is simple: can someone produce a proof for a false statement?
That is one part of the job.
You still need to find missing constraints, bad verifier keys, weak public input handling, and circuits that accept witnesses they should reject.
But that is not enough.
The better mental model is this:
A ZK system is a security promise implemented across math, code, configuration, and product flow.
The proof is only one object inside that system.
The pattern I keep seeing is this: teams verify the proof, then stop tracing the promise.
When I review ZK protocols, I try to split the audit into three questions:
- Soundness: is the prover forced to prove the intended statement?
- Zero-knowledge: does the proof leak anything the system claims to hide?
- Binding: is the proof tied to the exact action the protocol performs?
Most interesting bugs are violations of one of those promises.
The proof may still verify.
The protocol may still be broken.
1. Zero-Knowledge Can Fail Even When Verification Is Correct
The easiest trap is treating "the verifier accepts" as the only security property.
In a zero-knowledge proof, verification is not enough. The proof also has to hide the witness. If an implementation leaks enough structure for a verifier to recover private values, then the proof can be complete and sound while no longer being zero-knowledge.
This is not theoretical.
In 2024, gnark disclosed CVE-2024-45040. The issue affected Groth16 proofs with commitments before gnark v0.11.0. The commitments to private witnesses did not provide the required hiding property. If committed values had low entropy, an attacker could enumerate candidate witnesses and compare against the proof commitment. The fix added randomness to mask committed values.
That is an important audit lesson:
Commitments are not private just because they are called commitments.
They need the hiding property. In practice, that often means blinding.
A recent Sherlock Pico ZKVM finding applied this exact class of issue in a concrete system. The circuit used RangeChecker.Check() in paths that could trigger the affected commitment behavior. For small or low-entropy witness values, a verifier could brute-force guesses and compare deterministic commitments. The proof still verified. The leakage came from how private values were committed inside the proving flow.
OpenZeppelin reported a related privacy failure in its Linea PLONK prover audit. The quotient polynomial shards were committed into the proof without individual blinding. The impact was not "fake proof accepted." The impact was that the implementation did not provide the expected statistical zero-knowledge property. The fix introduced blinding for those shards, with an option to disable statistical ZK when the memory cost was not desirable.
That tradeoff matters.
Zero-knowledge is not free. It costs memory, randomness, constraints, complexity, and implementation discipline. If a team disables or skips blinding for performance, that may be a reasonable engineering tradeoff in a non-private setting. But it cannot be marketed as the same privacy property.
When auditing privacy-sensitive ZK systems, I would ask:
- which witness values are committed?
- are those commitments hiding or only binding?
- are low-entropy witnesses exposed to guessing?
- where is randomness introduced?
- can a verifier recompute proof components for candidate values?
- is "statistical zero-knowledge" optional, disabled, or inconsistently applied?
The bug class is simple:
the proof verifies, but the witness is no longer private.
That is a ZK failure even if soundness is intact.
The verifier accepting is not the finish line.
2. The Circuit Can Prove A Weaker Statement Than The Developer Thinks
The second class is the classic circuit-audit problem: the code computes something, but the constraints do not force it.
In circuit work, computing a value is not the same as constraining it.
This is where many Circom-style bugs live.
A witness generator can compute the honest value. A malicious prover does not have to use the witness generator honestly. They only need values that satisfy the constraints.
So the audit question is not:
what does the code appear to compute?
The audit question is:
what is the prover actually forced to prove?
In a recent private audit, I found a simple version of this pattern around Merkle path selection. The circuit expected each path selector to be binary: at every tree level, the current node is either on the left or on the right. But the selector was not explicitly constrained to 0 or 1.
The intended statement was:
this is a valid left/right Merkle path to a known root.
The actual statement was weaker:
this is a path equation using selector field elements that are not fully restricted to left/right bits.
That distinction matters.
It did not automatically mean an attacker could steal funds. Exploitation still depended on whether the extra freedom could be used to land on a known root under the hash assumptions. But it was still a real circuit bug, because the statement being proven was not the statement the developers intended.
This is where severity discipline matters.
Weak statement does not always mean practical exploit.
No practical exploit does not mean no bug.
A serious ZK finding should say:
- intended statement
- actual constrained statement
- extra freedom given to the prover
- whether that freedom creates an exploit path
- what assumption blocks exploitation if no exploit is known
- exact constraint or redesign needed
The same mindset applies outside hand-written app circuits.
In a recent Pico ZKVM finding, the issue was not "forgot to constrain a Merkle selector." It was compiler semantics. A read_ghost_addr method incremented a multiplicity counter even though ghost reads were meant to be non-computational. Other ghost-read paths did not increment multiplicity. That inconsistency could affect the memory-access accounting used later by the circuit.
This is a more advanced version of the same problem.
In a zkVM, the circuit is not only the thing developers write by hand. The compiler, memory model, lookup arguments, multiplicity counters, opcode tables, and trace-generation semantics are part of the proof statement.
If the compiler records the wrong semantics, the proof can faithfully prove the wrong thing.
That is why zkVM audits need a different mindset from ordinary application audits. You are not only reviewing functions. You are reviewing the translation layer between program execution and constraints.
For this class, I check:
- are boolean values actually boolean-constrained?
- are range assumptions explicit?
- are field elements later treated as fixed-width integers?
- are component outputs connected?
- are selectors, lookup multiplicities, and memory counters updated consistently?
- does "debug" or "ghost" behavior affect proof-relevant state?
- does the trace encode the semantics the VM claims to prove?
The bug class is:
the proof is valid, but it proves a weaker or different statement.
A proof can preserve exactly the wrong thing.
3. A Valid Proof Can Authorize The Wrong Action
The third class is the one Web3 engineers understand fastest.
The proof proves something true, but the protocol uses it to authorize something not fully bound to that proof.
OpenZeppelin's SSO Account OIDC Recovery audit has a clean example. The recovery flow used a ZK proof to show ownership of an OIDC identity. Once the proof and related data were verified, startRecovery could begin account recovery. Most parameters were validated, but pendingPasskeyHash was not bound to the proof before being written into account storage.
That matters because the passkey hash determines who can complete recovery.
If a caller can supply a pendingPasskeyHash they control while reusing a valid proof, the proof of identity becomes a way to set the attacker's recovery key. The proof is not false. The action is under-bound.
The fix was conceptually simple: include pendingPasskeyHash in the data checked by the proof, via the JWT nonce construction, so the user intent covers the exact passkey being installed.
This is one of the most important ZK application lessons:
If a value changes the action being authorized, it must be bound to the proof.
I found a related pattern in a recent private audit of a privacy-style protocol.
The withdrawal proof bound several public values: root, nullifier, recipient, relayer, fee, and an additional settlement value. Verification succeeded. The contract used the settlement value in payout arithmetic.
But the protocol did not fully honor that value after verification.
It affected the accounting, but did not have the complete transfer semantics the proof implied.
This is not a cryptographic break. It is a boundary break. The circuit and the settlement code disagreed about what a public input meant.
Every public input is a promise.
If the proof binds recipient, settlement must pay that recipient.
If the proof binds fee, settlement must cap and route that fee correctly.
If the proof binds pendingPasskeyHash, recovery must install that exact intended key.
If the proof binds a pool, root, denomination, chain ID, domain separator, relayer, refund, nonce, or account, the protocol must use that value with the same semantics.
Otherwise the proof is being used as a generic permission slip.
That is dangerous.
If a value changes user authority, funds, recovery, or privacy, it belongs inside the statement.
For this class, I ask:
- what action does this proof authorize?
- which values define that action?
- are all of those values public inputs or otherwise committed into the proof?
- does the contract use the same values after verification?
- can a caller swap an action parameter after proof generation?
- can the same proof be replayed in another account, pool, chain, epoch, or recovery session?
- can config select a verifier or circuit that proves a different statement?
The bug class is:
the proof is valid, but it is not bound to the exact protocol action.
The Auditor Mindset
The best ZK audits are not "read circuit, check proof."
They are systems audits with math inside them.
A good review moves through layers:
- Statement: write the proof statement in plain English.
- Constraints: check that the circuit enforces that statement.
- Privacy: check that proving artifacts do not leak the witness.
- Verifier: check key, public input order, curve, field, and proof-system assumptions.
- Binding: check that every action-critical value is part of the statement.
- Settlement: trace what happens after
verifyProof. - Integration: check relayers, APIs, logs, compliance checks, hosted provers, and frontend flows.
This is where ZK audits become psychologically different from normal smart contract audits.
In a normal contract, the dangerous line is often directly visible: bad access control, wrong arithmetic, unsafe external call.
In ZK systems, the dangerous line may be a mismatch between worlds:
- field element vs integer
- witness assignment vs constraint
- commitment vs hiding commitment
- proof verification vs action authorization
- VM execution vs trace semantics
- privacy claim vs actual integration behavior
That is why "the proof verifies" is such a weak stopping point.
In ZK audits, the dangerous line is often the boundary between two systems.
The useful question is:
What promise does this proof create, and does the rest of the system keep it?
If the answer is no, the protocol can fail even with a valid proof.
Conclusion
ZK is powerful because it lets systems prove precise statements without revealing the full witness.
But precision is also the trap.
If the statement is weaker than intended, the proof preserves the wrong semantics.
If the proving implementation leaks witness commitments, the proof stops being zero-knowledge.
If the protocol performs an action not fully bound to the proof, the proof becomes a partial authorization primitive.
Valid proof. Broken protocol.
That is the class of bug engineers miss when they treat ZK as a black box.
The best auditors do the opposite.
They trace the promise around the proof.
References
- RareSkills ZK Book
- CVE-2024-45040: gnark Groth16 commitments to private witnesses break zero-knowledge
- Linea PLONK Prover and Verifier Audit - OpenZeppelin
- SSO Account OIDC Recovery Solidity Audit - OpenZeppelin
- Public Sherlock Pico ZKVM judging finding #128
- Public Sherlock Pico ZKVM judging finding #108