devoracles.

Zero knowledge proof blockchain bugs: what our audit revealed
Security & Audits

Zero knowledge proof blockchain bugs: what our audit revealed

A zero knowledge proof blockchain integration can verify a statement perfectly and still authorize the wrong outcome.

Zero knowledge proof blockchain bugs: what public audits and research reveal

That is the uncomfortable part developers meet after the elegant demo works: the circuit compiles, the proof verifies on-chain, the transaction settles—and a malicious prover can still satisfy a statement the protocol never intended to permit.

The cryptography is rarely the only moving part. Circuit constraints, witness generation, compiler output, a verifier contract, public inputs, and sometimes a price or identity claim that began life outside the chain must all be wired together. Every seam is a security boundary. Miss one, and "valid proof" quietly stops meaning "valid business action."

This is the pattern that comes through most clearly in public ZK audit work and recent circuit-security research. A 2024 USENIX Security study built nine vulnerability detectors and ran them against more than 258 Circom circuits from popular GitHub projects. Earlier, an external review of circom-bigint and its circomlib dependency identified 14 issues, including nine critical underconstrained-circuit findings. These are not proofs that every ZK project is fragile. They are a reminder that blockchain zero knowledge cryptography is software engineering with unusually unforgiving failure modes.

The implementation gap is where soundness gets lost

At the proof-system level, three properties matter: completeness, soundness, and zero knowledge. Completeness means an honest prover can prove a true statement. Zero knowledge means the proof does not reveal the secret witness. Soundness is the property developers tend to lean on most heavily: a malicious prover should not be able to convince the verifier of a false statement.

But soundness only applies to the statement the circuit actually constrains.

That distinction sounds obvious until we look at a real protocol flow. Imagine an application wants to prove:

"This user owns a credential, has not used it before, and meets the required eligibility threshold."

The circuit may instead prove something narrower:

"This witness hashes to a known commitment and one arithmetic relation evaluates correctly."

If the nullifier is not bound correctly, if the threshold input is not range-checked, or if the verifier contract reads public signals in the wrong order, the system has proved a different statement. The verifier has done its job. The application has not.

This is why a zkp smart contract audit cannot begin and end with verifyProof(...) == true. The security claim must be traced from the product requirement into every representation it takes:

1. The intended invariant — what must be true before value moves, a vote counts, or access is granted.

2. The circuit relation — the exact equations and constraints that encode that invariant.

3. Witness construction — the off-chain code that populates private and public values.

4. Proof and verifier interface — public-signal ordering, field encoding, proof parameters, and generated verifier behavior.

5. Application enforcement — replay protection, state transitions, access control, oracle validation, and failure handling around the proof.

A proof does not patch a missing invariant in layer five. It just gives layer five more confidence in whatever layer two happened to express.

A ZK proof is not a certificate of real-world truth. It is a certificate that a particular computation accepted particular inputs.

That is the mental model worth carrying into every design review.

Underconstrained circuits: the bug that looks like valid math

An underconstrained circuit leaves the prover too much freedom. The circuit produces an expected output, but it does not sufficiently restrict the intermediate values that lead there. In ordinary application code, this could be called an input-validation bug. In a circuit, it can become a false-proof bug.

Take a familiar decomposition pattern. A 16-bit value can be represented as two bytes:

shortVal = lowByte + 256 * highByte

If lowByte and highByte are supposed to be bytes, each must live in the range [0, 255]. Without those range constraints, the arithmetic equation alone accepts multiple witnesses for the same shortVal.

For shortVal = 256, the expected witness is:

lowByte = 0, highByte = 1

But this also satisfies the equation:

lowByte = 256, highByte = 0

The second witness is not a byte decomposition at all. Yet the circuit will accept it if the byte rule is never encoded.

This is exactly why zero knowledge proof vulnerabilities are often semantic rather than cryptographic. The algebra is correct. The security meaning is absent.

What to hunt for in circuit review

When sitting with a circuit, the question is not only "Does this equation look right?" It is also "What values can a malicious prover choose while this equation remains right?"

That shifts review toward the places where witness freedom appears:

  • Missing range checks. Any value intended to be a bit, byte, bounded amount, index, timestamp, or Boolean needs a constraint that gives it that meaning. A field element is not automatically a uint16, and a signal named isMember is not automatically 0 or 1.
  • Unconstrained selector logic. A conditional formula is only safe if its selector is Boolean and every branch is bound to the expected semantics. Otherwise the prover may blend branches with arbitrary field values.
  • Partial comparisons. Threshold checks can be defeated when a borrowed comparison gadget is used outside its supported range, receives unsigned values where signed behavior was assumed, or never constrains its inputs to the gadget's domain.
  • Unused or weakly bound public inputs. A public root, nullifier, recipient, chain identifier, or oracle round identifier must influence the constrained statement—not merely travel alongside it.
  • Constraint-free helpers. A utility template can return the right result in honest tests while failing to enforce the relationship under adversarial witness generation.

The public circom-bigint and circomlib review is instructive here, not because every project shares its issues, but because it demonstrates the amount of work hidden behind a small-looking arithmetic library. That assessment took 24 person-weeks, involved four engineers, and used manual review, static analysis, and more than 20,000 lines of machine-checkable Coq proof. "It is just a helper" is not a reliable risk classification in ZK code.

Circom assignments are not constraints

Here is one of the fastest ways to create a dangerous gap in a Circom circuit: confusing assignment with enforcement.

Circom gives developers operators with visibly different jobs:

OperatorWhat it doesSecurity consequence
<--Assigns a value while generating the witnessDoes not, by itself, constrain the assigned value
-->Assigns in the opposite directionAlso does not, by itself, add a constraint
===Adds an arithmetic equality constraintBinds the relation into the circuit
<== / ==>Assigns and constrains an expression relationshipUseful when the intended relation is explicit

The dangerous operators are <-- and -->. Circom's own documentation calls them dangerous unless paired with suitable === constraints. They are sometimes needed for values derived through operations the compiler cannot directly constrain in the same way. That does not make them wrong. It means they deserve a second line of scrutiny.

Suppose a witness-side calculation looks like this:

out <-- customOperation(input); // witness computation

If the security argument requires out to equal a function of input, the corresponding circuit constraint has to be expressed as well. Otherwise an adversarial prover may simply provide an out that helps satisfy another condition.

This is a subtle point because tests can look excellent. Honest witness generation computes out correctly. The happy-path proof verifies. Even a few negative tests may fail as expected. But a malicious prover does not have to run the protocol's frontend or witness helper. They can construct a witness directly.

Assertions are helpful, but they are not the circuit

A closely related mistake is treating a witness-generation assert as if it were proof-system enforcement. An assertion can catch a developer error or reject malformed data during a normal run. It does not necessarily become a constraint that the verifier can rely on.

For a security-sensitive condition, the plain question to ask is: if someone bypasses the application-side JavaScript, TypeScript, or prover wrapper and submits their own witness, does the circuit still reject the invalid value?

If the answer depends on application-side behavior, the job is not finished.

This is where a deliberately boring audit habit helps: write each invariant in one sentence, then point to the exact constraint or contract check that enforces it. Not the helper function. Not the test name. The constraint.

Sanity checks and formal methods catch different classes of regret

Circom's --sanity_check option has three levels: 0, 1, and 2. Level 0 adds no runtime sanity checks. Level 1 inserts assertions for === instructions. Level 2, the documented default, additionally checks that components executed their subcomponents with required inputs set.

The default should stay at level 2 unless there is a measured reason to lower it. It is good engineering hygiene. It can expose accidental missing inputs and make circuit development less opaque.

But it is not an audit result, and it is not a substitute for constraints. A circuit can pass sanity checks while still encoding the wrong economic or authorization rule. Likewise, static analysis can flag suspicious patterns, but it cannot infer every product-level invariant from a repository.

The strongest review process layers tools and human reasoning instead of asking one tool to bless the entire system:

1. Map the claim before reading code. Define what the proof is supposed to establish, who benefits if it is false, and which inputs are attacker-controlled.

2. Inspect constraints, not names. Templates called RangeCheck, Verify, or Safe earn no trust from their names. Follow the equations and the signal wiring.

3. Use adversarial witness tests. Try out-of-range values, alternative decompositions, malformed selectors, swapped public inputs, reused nullifiers, and boundary values around every comparator.

4. Diff generated artifacts. The .r1cs, proving artifacts, verification key, and generated Solidity verifier are release outputs with security consequences. A source change is not the whole deployment change.

5. Audit the integration contract. Check public input order, field-to-integer conversions, proof replay behavior, upgrade permissions, and the exact state mutation after verification.

6. Apply formal methods where the blast radius merits them. Formal proofs are especially valuable for reusable primitives: integer arithmetic, comparisons, membership gadgets, signature logic, and libraries inherited across protocols.

There is a parallel lesson in infrastructure outside the circuit. A short report on Northern Virginia data-center ownership may seem far away from proof verification, but it points to a practical truth: critical digital systems depend on physical and operational concentration too. If a prover service, data pipeline, or oracle observation path has one operational dependency, the cleanest circuit in the world will not make that dependency disappear.

The verifier contract can quietly change the statement

A common ZK architecture splits trust across a circuit and a Solidity verifier. The generated verifier confirms the proof; an application contract decides what verified public inputs mean.

That handoff is fragile.

Suppose the circuit exposes public signals in this order:

[merkleRoot, nullifierHash, recipient, epoch]

The application may accidentally interpret index two as the epoch, cast a field element into an address without the intended validation, or accept a root that was never registered on-chain. The proof still verifies. The proof has simply been attached to the wrong action.

A practical review should walk the call path in both directions:

  • From the UI or API: where are public signals assembled, serialized, and passed into the prover?
  • Through the proof package: which artifact version created the proof, and which verification key does the contract expect?
  • Into Solidity: how are values decoded, indexed, checked against state, and persisted?
  • Back into application state: is the nullifier marked spent before any external call? Can the same proof be replayed on another chain, contract, pool, or epoch?

Binding context directly into the statement is usually safer than relying on surrounding convention. A proof intended for one deployment should not become valid on another merely because both accept the same verifier. Domain separation can include a chain identifier, contract address, action type, epoch, or protocol-specific context—provided those values are genuinely constrained and checked consistently.

This is also where multi-signature governance and upgrade controls belong in the conversation. A sound verifier deployed behind a casually administered proxy is not a fixed security boundary. Review who can replace the verifier, alter accepted roots, pause the protocol, or change oracle parameters—and how quickly users can understand and react to that change.

The most expensive ZK bugs are often translation bugs: a rule changed shape between product logic, circuit math, and on-chain state.

Proofs do not make oracle data true

Now we reach the integration that tends to produce the most confident-sounding misconceptions: proving something about an oracle value.

A ZK circuit can prove that a committed price was processed according to a stated formula. It can prove that a private input falls below a public threshold. It can even prove that a signature or membership path was valid under a specified root.

What it cannot do by itself is establish that the off-chain price was fresh, liquid, manipulation-resistant, or appropriate for the protocol's risk model.

For Chainlink EVM Data Feeds, latestRoundData() returns five values: roundId, answer, startedAt, updatedAt, and answeredInRound. Those fields give application developers material to build validation logic, but they do not remove the need to define that logic. On L2s, integrations also need to account for sequencer uptime before treating feed data as accurate during a sequencer outage.

The application layer should make explicit choices about:

  • Freshness. Compare updatedAt with the current block time according to a protocol-specific staleness window. There is no universal safe threshold; a lending market and a slow-moving settlement workflow do not have the same tolerance.
  • Answer validity. Reject invalid or nonsensical answers for the application's domain, including values that violate expected sign, decimal, or bounds assumptions.
  • Round and context binding. If a proof references an oracle observation, constrain the feed identity, round context, timestamp policy, and the action it authorizes. Do not let a valid price observation float freely into a different market or time window.
  • Deviation and circuit breakers. Define what happens when a new observation moves too far from an internal reference, a fallback source, or the protocol's expected operating range. The correct threshold is an economic design decision, not a number copied from another protocol.
  • Failure behavior. Decide whether to pause borrowing, liquidations, minting, or settlement when feeds are stale or a sequencer event has been detected, and make that behavior observable to users and integrators. A circuit that checks updatedAt against a hardcoded block timestamp without consulting an oracle uptime feed will quietly fail in exactly the situation it was supposed to protect.

The honest summary is uncomfortable: ZK does not outsource trust, it relocates it. Every claim the circuit can prove still rests on assumptions about inputs, identities, oracle freshness, and upgrade governance. The verifier has done its job when those assumptions hold. The protocol has not done its job until the assumptions are written down, constrained, and monitored in production.

That is the frame worth holding onto whenever a zero knowledge proof blockchain integration is sold as "trustless." The cryptography is impressive. The seam between the proof and the world it is supposed to describe is where the work actually lives—and where public audits keep finding the same kinds of mistakes.

FAQ

Why does a proof verify successfully even if the protocol logic is flawed?
A proof only confirms that the circuit's specific arithmetic constraints were satisfied. If those constraints do not fully capture the intended business logic, a malicious prover can satisfy the proof while violating the protocol's actual requirements.
What is an underconstrained circuit?
An underconstrained circuit is one that fails to sufficiently restrict intermediate values, giving a prover too much freedom. This allows a prover to provide alternative, invalid inputs that still satisfy the circuit's arithmetic equations.
Are Circom assignment operators like <-- and --> secure?
These operators are used for witness computation but do not add arithmetic constraints to the circuit. They are considered dangerous unless they are paired with explicit equality constraints (===) to ensure the witness values are properly enforced.
Can I rely on witness-side assertions for security?
No, assertions made during witness generation are not enforced by the verifier contract. A malicious prover can bypass the application-side code and submit a custom witness that ignores these assertions.
How should oracle data be handled in ZK integrations?
Oracle data must be explicitly validated for freshness, liquidity, and context. Developers should define clear thresholds for staleness and round validity, as the ZK proof only confirms the data was processed, not that the data itself is accurate or appropriate for the protocol.