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

Do in secret. Assert in public. Don't under-constrain your prover's witness computation in ZK programs

underconstrained bugs

Now is the time to learn and dive deep into ZK application development. A large class of security errors can crop up if you're not careful when starting to build ZK applications. These issues often stem from having the wrong frame of reference in your head and paying attention to the wrong details. Read this article to learn about the mindset you need, and the gotchas to look out for when programming with zero-knowledge proofs.

Background

When I started working on what would become Mina Protocol in fall of 2017, writing applications using zero-knowledge in production was extremely rare. However, today there is a boom of ZK programming in the crypto/web3 space, and, soon, I believe there will be a boom in the web2 space too. Early on, to manage complexity of large ZK applications, developers needed to build their own DSLs and SDKs, but these days there are many to choose from: A few zkSecurity auditors including myself currently maintain SnarkyJS at O(1) Labs, for example. Tools like SnarkyJS or Noir or Cairo1.0 and execution runtimes like Mina Protocol or Aleo make it easier for developers to get started writing ZK applications, and they are starting! There are now ZK meetups, conferences, podcasts, workshops, hacker houses and companies, teams, and at this point an uncountable number of developers starting to use this technology first for fun, but then in production. If you're curious, this is a great time to start yourself!

As David mentioned in ZK programmability adds a whole new layer to worry about, there are many facets of developing ZK applications where security issues can poison your application. I want to focus on sharing an understanding that prover computation must be fully constrained in your ZK application or you'll be proving nonsense as this was something I personally struggled to really understand when I was first getting started with ZK programming five years ago.

Preliminaries

Let's make sure we're on the same page before we get into these under-constraining issues with some informal definitions:

A real-world ZK application is an application that includes a phase separation in computation between at least two parties: a prover and a verifier. Typically, the prover does extra work and is creating and manipulating and working with private data either for scalability or privacy purposes. The verifier can check a ZK proof that if defined and constructed correctly can efficiently verify computations without observing the private data. The verifier reasons about private variables as existentially quantified ones in other words thinking that "there exists some data, w, that the prover knows that I don't, such that some properties hold on w". Sometimes the values corresponding to the private variables are referred to as a witness since they act as a witness, a form of proof by evidence that the existential variables do exist. Note that the prover and verifier(s) don't trust one another, and we must think adversarially, ie. that the prover will try to cheat the verifier if they are able.

Since the verifier computation does not work with the private data directly, rules for your application are imposed by introducing assertions or constraints about certain properties of the data. The implementation details of these constraints materialize differently in different proof systems, but if you are working with a decent SDK that abstracts away those details, most of the time you just need to think about what assertions you need.

Finally, under-constraining private data describes a class of security errors that arise when these existentially quantified variables do not have enough assertions about them for the verifier to properly enforce that the shape of the data is what is needed for applications.

When building a ZK application, you must constantly jump between the prover and verifier worlds. Different toolkits take different approaches to exposing these universes to their developers. For example, Noir assumes that prover computation happens elsewhere and Noir code just describes the verifier side of the computation.

// my current age is the sum of my age at account creation time and years since account created
// somewhere else: Go lookup the account information and check the signature before returning it, thread that value through your code until this function call

struct AccountInfo { startAge: Field, yearsSince: Field }

fn getMyAgeIfNotChild(retrievedAccountInfo : AccountInfo) -> Field {
   let age = retrievedAccountInfo.startAge + retrievedAccountInfo.yearsSince;
   assert(age >= 18);
   age
}

SnarkyJS, on the other hand, interleaves these worlds by asking the developer to witness the existential variables in order to simultaneously define the prover and verifier computations.

// my current age is the sum of my age at account creation time and years since account created

class AccountInfo extends Struct({ startAge: Field, yearsSince: Field }) {}

function getMyAgeIfNotChild(userid: String, serverPk: PublicKey): Field {
  const accountInfo = Provable.witness(AccountInfo, () =>
     const accountInfo = loadAccount(userid);
     if (!accountInfo.signature.verify(serverPk, [accountInfo.yearsSince, accountInfo.signature])) {
       throw new Error("my account database is busted");
     }
     return new AccountInfo({ ...accountInfo });
  );

  const age = accountInfo.startAge.add(accountInfo.yearsSince);
  age.assertGreaterThanOrEqual(18);
  return age;
}

Common Mistake: Failing to recognize the prover/verifier phase separation

In either case, we can't assume any properties hold on the data without checking them! For developers new to working with ZK, the biggest mistake I have seen which leads to underconstraining errors is failing to acknowledge this phase separation and thinking that the verifier side will inherit properties of the proving side of the computation. Acknowledge the difference! Did you catch that we forgot to constrain that the signature was valid inside the verification phase in the above examples?

// my current age is the sum of my age at account creation time and years since account created
// somewhere else: Go lookup the account information and check the signature before returning it, thread that value through your code until this function call

struct AccountInfo { startAge: Field, yearsSince: Field, signature: [u8;64] }

fn getMyAgeIfNotChild(retrievedAccountInfo : AccountInfo, serverPkX : pub Field, serverPkY : pub Field) -> Field {
   let chunk1 = retrievedAccountInfo.startAge.to_be_bytes(2);
   let chunk2 = retrievedAccountInfo.yearsSince.to_be_bytes(2);
   let data = combine_arrays(chunk1, chunk2); // assume combine_arrays exists
   verify_signature(serverPkX, serverPkY, retrievedAccountInfo.signature, data);

   let age = retrievedAccountInfo.startAge + retrievedAccountInfo.yearsSince;
   assert(age >= 18);
   age
}
// my current age is the sum of my age at account creation time and years since account created

class AccountInfo extends Struct({ startAge: Field, yearsSince: Field, signature: Signature }) {}

function getMyAgeIfNotChild(userid: String, serverPk: PublicKey): Field {
  const accountInfo = Provable.witness(AccountInfo, () =>
     const accountInfo = loadAccount(userid);
     if (!accountInfo.signature.verify(serverPk, [accountInfo.yearsSince, accountInfo.signature])) {
       throw new Error("my account database is busted");
     }
     return accountInfo;
  );

  accountInfo.signature.verify(serverPk, [accountInfo.yearsSince, accountInfo.signature]).assertTrue();

  const age = accountInfo.startAge.add(accountInfo.yearsSince);
  age.assertGreaterThanOrEqual(18);
  return age;
}

Ultimately, this is the root of all errors related to under-constraining. And, unfortunately, even with a good prover/verifier phase-separate mindset there is space for more subtle errors to slip in: Computations with private data can taint future assertions. This is best demonstrated through a few examples.

Division

The division operation is typically provided to you as a primitive in ZK languages. However, if we look at implementing it ourselves, it's a nice example: One way to divide in a circuit is to structure the logic such that the prover computes division privately and then asserts correctness by verifying that this valued multiplied.

// dividend / divisor = quotient (in psedocode)
function unsafeDivide(dividend: Field, divisor: Field): Field {
  let quotient = witness { dividend / divisor };
  constraint(quotient * divisor == dividend);
  return quotient;
}

However, what about zero! Division is supposed to be undefined at zero. But if we pass zero as a divisor, we can prove anything is the quotient! This error typically leads to disastrous security issues.

To fix, we can make sure that the divisor can't be zero:

// dividend / divisor = quotient (in psedocode)
function safeDivide(dividend: Field, divisor: Field): Field {
  let quotient = witness { dividend / divisor };
  constraint(quotient * divisor == dividend);
  constraint(quotient != 0);
  return quotient;
}

Square Root

The square root operation is also interesting. Similarly to division, we compute via inverse. Though, this time, square root is well-defined at zero. Due to nuances we won't get into in this post, let's just assume that we've already ensured that square root is defined at the input presented.

// sqrt(x) = root (in pseudocode)
function unsafeSqrt(x: Field): Field {
  let root = witness { sqrt(x) };
  constraint(root * root == x);
  return root;
}

More generally any invertible operation with a single deterministic output can safely be represented in this way when working with ZK. But wait, square root doesn't have a deterministic output! Both positive and "negative" values are valid!

What does a "negative" value even mean in a field? The kinds of fields we use in practice are typically Z_p for some prime p. Essentially, positive integer values that wrap-around at p. So -root is equal to p - root. Since p is prime is must be odd, and this means that -root will have a different parity (odd vs even) than root. It suffices to check that the parity of input is the same as the root to find a reasonable canonical square root.

// sqrt(x) = root (in pseudocode)
function saferSqrt(x: Field): Field {
  let root = witness { sqrt(x) };
  constraint(root * root == x);
  constraint(parity(x) == parity(root));
  return root;
}

Review and Audit

In order to catch these under-constraining errors, it is important to have a strong review and audit process in place. For instance, at my day-to-day job working on Mina, I not only rely on third-party audits but also push my coworkers and myself to invest engineering time to audit our own ZK computations, looking for these kinds of under-constraining errors.

At zkSecurity, we have encountered many of these sorts of issues in real-world scenarios, and we are well-equipped to help you prevent such bugs from affecting your applications. Sometimes, having a fresh and experienced perspective can make all the difference in identifying potential issues. If you're interested in our expertise, feel free to reach out to us at hello@zksecurity.xyz.

Conclusion

As more and more developers venture into building ZK applications, it is crucial for them to develop a deep understanding of the prover and verifier worlds. By recognizing their distinctions and remembering that the "do" is in secret, while assertions are the only thing verifiers see, developers can avoid common pitfalls.

Even with this mindset, it is important to remain vigilant about how certain partial operations can taint future assertions. Accepting that even the most diligent individuals can make mistakes is key to maintaining a strong development process. Always conduct code reviews and seriously consider getting an audit to ensure the security and correctness of your ZK applications. Our team at zkSecurity would be more than happy to assist you in your journey to building secure and efficient ZK applications.

As the world of ZK programming continues to grow, developers must remain mindful of the potential security issues that can arise. By understanding the nuances of ZK programming, leveraging the right tools and libraries, and investing in a robust review and audit process, developers can create secure applications that maximize the potential of zero-knowledge proofs.

Keep reading
Latest

Optimizing Cryptography with AI

Many of us are using AI to generate code. Vibe coding cryptography is especially sensitive - you have to uphold strict mathematical correctness. This can lead to wrong security guarantees and soundness bugs. We will discuss what are some patterns to do it well.

Kobi Gurkan · August 11, 2026

Introducing zkvmBlast: Differential Fuzzing for Ethereum's zkVMs

zkVMs are moving to the center of Ethereum's roadmap, which means a bug in a zkVM is turning into a bug in Ethereum itself. We built zkvmBlast, a zkVM-agnostic differential fuzzer that runs the same program across SP1, RISC0, OpenVM, Pico, Zisk, and Airbender against a reference simulator and flags any disagreement. It hunts for both soundness and completeness bugs, with a deliberate focus on completeness, an under-explored class that can turn a single valid block into a liveness failure. We share the first batch of findings.

Stefanos Chaliasos, Martín Ochoa, Varun Thakore · August 10, 2026

Circom-Auditor: Open-Source Skills for Finding Vulnerabilities in Circom Code

We are releasing zk-skills, a set of open-source security skills for AI coding agents, starting with circom-auditor: a first line of defense against vulnerabilities in Circom circuits, compatible with both Claude Code and Codex. On the zkbugs benchmark it detects up to 66 of 70 known bugs when pointed at the vulnerable circuits, and up to 40 of 56 when let loose on the full original codebases, far ahead of existing Circom security tools.

Stefanos Chaliasos, Hao Pham, False Witness Team · August 05, 2026
More to explore

Circle STARKs: Part II, Circles

In this blog post, we dive into the fascinating world of Circle STARKs, exploring the algebra of complex numbers and how they can be extended to any field. We revisit the concept of the unit circle and its unique group structure, which allows for cool operations like squaring and doubling angles. You'll discover how these ideas apply to finite fields, creating intriguing structures like the twin-coset and standard position coset. The post leads us to understand vanishing polynomials, crucial in STARKs, and sets the stage for exploring the circle FFT in upcoming discussions. Perfect for anyone curious about cutting-edge cryptographic techniques!

Varun Thakore, Mathias Hall-Andersen · February 21, 2025

Introducing bugs.zksecurity.xyz a knowledge base for ZK bugs

We're thrilled to introduce our new site, [bugs.zksecurity.xyz](https://bugs.zksecurity.xyz/), a hub for exploring past vulnerabilities in ZK circuits. Dive into our growing catalog of documented bugs and learn how we've reproduced some with comprehensive scripts. Discover evaluations of prominent security tools like Circomspect and Picus, and see where they shine or stumble. We're calling on the community to join us in expanding this invaluable resource, whether by adding bugs, reproducing them, or improving our platform. Let's collaborate to elevate ZK security together!

Stefanos Chaliasos · February 17, 2025

Listen to us on the latest episode of zeroknowledge.fm

Join our cofounder David Wong on the latest zk podcast as he dives into his compelling journey through cryptography, from his early days as a security consultant to his pivotal roles in major projects like Facebook's crypto initiatives and Mina. Get an insider's view on how we approach auditing in a Zero Knowledge context, the common pitfalls in ZK code, and how these insights shape our work. It's an engaging and informative chat for anyone fascinated by the world of cryptography and ZK technology!

ZK/SEC · August 30, 2023