ZK/SEC Research notes from zkSecurity
All posts
announcement · MPC

Detecting boomerang values in zero-knowledge circuits using tag analysis

zkApps (zero-knowledge applications) are meant to be provable translations of computer programs. That is, they should behave like the computer programs they are intending to represent, while also providing proofs of correct execution that can accompany program outputs. But as we've talked about before, translating programs into provable programs is not a straight forward process. To prove executions, zkApps attempt to "witness" all the important parts of an execution trace. They seek to assert every nook and corner specified (explicitly or implicitly) in the original application. The important keywords here are "all" and "every", as bugs can occur if even the tiniest part of the original program is missing in the assertions of the zkApp.

boomerang

In this article, we want to talk about boomerang values, which are one example of gotchas that occur when writing zk apps. What are boomerang values? What kind of bugs do they produce? And what can we do with them? Read on!

Mixing in-circuit and out-of-circuit logic

Developers write zkApps to encode the logic they want to witness in a higher-level language. These higher-level languages can take different shapes and forms (as we described in this post) and a number of them will commonly mix non-circuit (or out-of-circuit) logic with circuit (or in-circuit) logic.

The typical example of why one would even want something like that (or what does that even mean to mix out-of-circuit with in-circuit logic) is the division example, in which it's much easier to just compute the result of a division out-of-circuit, and then provide it like it fell from the sky.

Here's how the division function is typically written in zkApps, in some python pseudo-code:

def divide(a_var, b_var):
    # compute the division out-of-circuit
    c = a_var.value() / b_var.value()

    # witness / insert the out-of-circuit value in the circuit
    c_var = new_var(c)

    # constrain it to be the correct result of the division
    assert(c_var * b_var == a_var)

    return c

If this doesn't immediately make sense to you, think about it this way: the zkApp encodes (at compile time) some assertions on variables. Here it will encode the fact that there's a variable c_var which can take any value as long as it satisfies c_var * b_var == a_var.

At runtime (or proving time), when I (or you) want to execute the program and create a proof for it, we'll give actual values to a_var and b_var and create a proof based on that. Similarly, a value will be given to c_var, which will be computed based on the out-of-circuit logic above.

The important part, is that the divide function defines both the compile-time logic, and the runtime logic. While compile time is something that is done by both the prover and verifier, the runtime logic is something that is ran only by the prover, and thus a malicious prover can decide to do whatever they want. Especially, they do not have to compute c as a / b as described in the program!

Lifetime of a variable and linking issues

In our above program, a malicious prover trying to compute c as anything else but a / b will fail because the circuit checks that the division is correct. Remove the assertion and you have an underconstrained circuit (it's a bug). In this article, we're interested by a type of issue that belongs to this class of underconstrained circuit bug.

A variable in a circuit has a life. It is first created (and given a value at runtime), and it will then be mutated and used in all sorts of ways. Yes, like a variable in a normal program. In a sense, variables are wired to different parts of a circuit logic.

But that wire can be cut. For example, imagine the following pseudo-code:

# create a new circuit variable
a_var = new_var(some_value);

# do something with it...

# get the value out-of-circuit
a_val = a_var.value()

# potentially do something important with the value...

# reinsert the value in the circuit
b_var = new_var(a_val)

This is quite the generic and vague example, but it should be enough to illustrate my point. Here, you should be able to tell that something is wrong. If not, maybe take a few minutes to think about it.

The problem is that the above program gives the following instruction to the prover: "observe the runtime value of a_var, then reinsert it somewhere else in the program (under b_var)". If you're a malicious prover, what you're probably hearing is "add whatever you want as b_var".

The wire was cut, the link was broken. We call these boomerang values, as they leave the circuit and get reinserted later. They're almost always a bad thing.

It's interesting because intuitively, it seems like b_var is the only thing underconstrained now, but in reality it goes both way! Perhaps you want to keep the correct value as b_var, but now you can use a different value as a_var because it is not correlated to the correct value of b_var anymore.

In other words, if the correct values of a_var and b_var were the number 5, then a malicious prover could decide to keep b_var.value() = 5 and change the value of a_var to something else, or they could decide to keep the value of a_var.value() = 5 and change b_var to something else.

For an example of the impact this type of bug can have, you can check our public audit of Penumbra where we found a boomerang value that led to a double-spend attack. Today, what we'll be looking at is how we can detect such bugs in a Rust stack, specifically one making use of the Arkworks' R1CS library r1cs-std.

The ABCs of R1CS-std

When writing circuits with r1cs-std, the above pseudo-functions that I used to create new variables as well as obtain the runtime value of a variable are implemented through the use of the two functions new_variable() and value(). These two functions are located in two different traits that developers can implement on their own custom types.

Here's new_variable:

pub trait AllocVar<V, F: Field>
where
    Self: Sized,
    V: ?Sized,
{
    /// Allocates a new variable of type `Self` in the `ConstraintSystem` `cs`.
    /// The mode of allocation is decided by `mode`.
    fn new_variable<T: Borrow<V>>(
        cs: impl Into<Namespace<F>>,
        f: impl FnOnce() -> Result<T, SynthesisError>,
        mode: AllocationMode,
    ) -> Result<Self, SynthesisError>;

As you can see, it does not take a value but a function/closure f which produces the value (but this is just a detail).

And here's value():

/// This trait describes some core functionality that is common to high-level
/// variables, such as `Boolean`s, `FieldVar`s, `GroupVar`s, etc.
pub trait R1CSVar<F: Field> {
    /// The type of the "native" value that `Self` represents in the constraint
    /// system.
    type Value: core::fmt::Debug + Eq + Clone;

    /// Returns the value that is assigned to `self` in the underlying
    /// `ConstraintSystem`.
    fn value(&self) -> Result<Self::Value, ark_relations::r1cs::SynthesisError>;

So the boomerang example would look like this if we were to write it using arkworks' r1cs-std library:

// create a new circuit variable
let a_var = MyType::new_variable(cs, || Ok(some_value), AllocationMode::Witness);

// do something with it...

// get the value out-of-circuit
let a_val = a_var.value().unwrap_or_default();

// potentially do something important with the value...

// reinsert the value in the circuit
let b_var = OtherType::new_variable(cs, || Ok(a_val), AllocationMode::Witness);

Tag analysis to the rescue

It is well known that formal verification methods are usually too involved to be useful for the majority of projects. Thus, their development mostly serve as marketing tools for companies to attract new customers, get government grants, and attract researchers to work for them.

Yet, there's been a movement to make security tools actually useful to developers. The idea is to meet the developers where they are at. You can read more about that in Towards making formal methods normal: meeting developers where they are. Examples of this are easier-to-use formal verification tools (like Verifpal) or integrated analysis tools (like how fuzzing is directly integrated in Golang, or good libraries for property testing in Rust).

MIRAI is one more example of that, which initially started as a tool (by Herman Venter) to enforce invariants in the libra/diem blockchain. While MIRAI provides various features, what I'm interested in today is its tag analysis feature.

Let me explain what tag analysis is about with the following example: imagine that your program accepts user inputs, which it wants to deem dangerous until they get sanitized or properly validated. Using tag analysis, we could tag user inputs as dirty and then verify in important places of the program that values they operate on don't have the dirty tag. Inversely, we could verify that important functions operate only on values that have a clean tag, which would be added in the sanitization/validation functions.

(One could argue that in typed languages, you could do the same by creating different types for dirty and clean values, but this still is not enough. Changes in the code, or human error, could still allow valid programs to execute on dirty values. Tag analysis, if implemented correctly, will propagate tags in exhaustive ways. If you copy, clone, or even do something based on a value the tag will be propagated to the new value.)

Using MIRAI, we can add an "out-of-circuit" tag to any values that comes out of circuit, by placing the tag in the R1CSVar::value() function:

#![cfg_attr(mirai, allow(incomplete_features), feature(generic_const_exprs))]

#[macro_use]
extern crate mirai_annotations;

use mirai_annotations::{TagPropagation, TagPropagationSet, TAG_PROPAGATION_ALL};

struct OutOfCircuitTaintKind<const MASK: TagPropagationSet> {}

/// our tag with all propagation set
type OutOfCircuitTaint = OutOfCircuitTaintKind<TAG_PROPAGATION_ALL>;

impl R1CSVar<Fq> for MyVar {
    type Value = MyValue;

    fn value(&self) -> Result<Self::Value, SynthesisError> {
        // TRUNCATED...

        add_tag!(&res, OutOfCircuitTaint);
        Ok(res)
    }
}

On the other hand, we can detect when such a value enters back in the circuit by detecting the tag in the AllocVar::new_variable() function:

impl AllocVar<MyValue, Fq> for MyVar {
    fn new_variable<T: std::borrow::Borrow<MyValue>>(
        cs: impl Into<ark_relations::r1cs::Namespace<Fq>>,
        f: impl FnOnce() -> Result<T, SynthesisError>,
        mode: ark_r1cs_std::prelude::AllocationMode,
    ) -> Result<Self, SynthesisError> {
        verify!(does_not_have_tag!(&f, OutOfCircuitTaint));

        // TRUNCATED...

Fuzzing for the rest

For completeness, I should mention fuzzing which is also a great tool to find bugs in circuits. Fuzzing is the process of generating weird inputs, feeding them to a function, and seeing if we can crash the function with it. Rinse and repeat. As such, fuzzing is a dynamic analysis tool--it runs the code to find bugs--and is quite different from the static analysis tools we've been talking about. You never get a guarantee that your code is correct, and you can run a fuzzer for as long as you want and still not find a bug eventhough you know it exists. Surprisingly though, fuzzers have had amazing results at finding bugs in real-world code.

For us though, it seems like fuzzing will be more useful to find completeness issues (where some inputs should work but don't) but not necessarily useful at finding soundness issues (some inputs work when they shouldn't).

I won't talk much more about fuzzing as it is not the main topic of this post, but I'll give you some pointers if you're interested in running some yourself. In Rust, it is relatively easy to write fuzzers using cargo fuzz. I recommend not using libfuzzer and using instead AFL++ as fuzzer in its backend. As proving is a relatively slow process, one should try to fuzz by checking circuit satisfiability instead. One can do this with the following code (given a circuit variable):

let cs = ark_relations::r1cs::ConstraintSystem::<Fq>::new_ref();
cs.set_optimization_goal(ark_relations::r1cs::OptimizationGoal::Constraints);

let satisfied1 = circuit.generate_constraints(cs.clone()).is_ok();
let satisfied2 = cs.is_satisfied().is_ok();
if should_work {
    assert!(satisfied1 && satisfied2);
} else {
    assert!(!satified1 || !satisfied2);
}

In most cases you will have to use structured fuzzing in order to guide cargo fuzz: as it only understands how to produce "random" bytestrings, it will need some guidance on how to convert that into private and public inputs to properly fuzz the circuit.

Of course, these are not the only tools at our disposal. Stay tuned to learn more about our in-house toolings in the next blog post!

Keep reading
Recommended

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

Curious about diving into zero-knowledge (ZK) application development? Our blog post is here to guide you through the common security pitfalls and mindset shifts essential for tackling ZK programming. We reflect on our journey from the early days of Mina Protocol to the dynamic space today, filled with tools like SnarkyJS and newer approaches to split the prover and verifier roles effectively. Discover the errors to avoid, like under-constraining data and letting divisors slip through unchecked, and learn how a solid review and audit process can save your ZK applications from disastrous security issues. Dive in to get the insights you need to navigate the ZK landscape confidently!

Brandon Kase · June 01, 2023

You like Circom but you find it confusing? Introducing Circomscribe

Dive into our exploration of Circomscribe, a nifty tool designed to illuminate the mysterious process of how your Circom code gets translated into constraints. We share insights from our experience with Circom circuit audits, highlighting common pitfalls developers face when their high-level intentions meet low-level reality. By showcasing how Circomscribe can help visualize this transition, we aim to empower developers to craft more bug-free, secure ZK applications. If you're keen on understanding the inner workings of Circom and enhancing your coding prowess, this post is your guide.

ZK/SEC · August 26, 2023

Introducing clean, a formal verification DSL for ZK circuits in Lean4

We're diving into our new project called **clean**, aimed at creating an embedded DSL and formal verification framework for Zero Knowledge (ZK) circuits using Lean4. Imagine being able to not only define ZK circuits but also formally prove their correctness. Sounds like a game-changer, right? We'll walk you through our process of building a robust library of reusable, verified circuit gadgets, focusing on the importance of soundness and completeness. Plus, you'll get a peek at some cool examples like 8-bit addition and how we're tackling ZKVM design with techniques borrowed from Fibonacci sequences. It's exciting stuff, and if you're curious about how we're paving the way for bug-free ZK circuits, this is a read you won't want to miss!

Giorgio Dell'Immagine · March 27, 2025
More to explore

zk.golf: Fearless and Collaborative Optimization of Circuits

zk.golf is a platform where people can compete on creating the most efficient zk circuits for specific problems. It is enabled by what we call "fearless optimization", which is achieved by combining formal verification and frontier AI models. By the end, you will be convinced that nobody should look at constraints ever again in their life.

Giorgio Dell'Immagine, Mathias Hall-Andersen · July 02, 2026

Watch what we have to say about ZK security in the Node Guardians season 2 episode 1

Catch our cofounder David Wong on the latest episode of Node Guardians, where he chats about ZK security with Sam. Dive into the intriguing world of blockchain auditing, uncover the role math plays, and explore how auditors tackle challenges and rate vulnerabilities. Plus, get insights into the usefulness of formal verification. It's a must-watch for anyone curious about the future of ZK and the nitty-gritty of blockchain security!

ZK/SEC · August 31, 2023

noname 3.0: Native Hints, Standard Library, Compiler Visualizer, And More!

We're super excited to introduce noname 3.0, our zk programming language inspired by Rust and Golang, now achieving full feature parity with Circom. This update brings native hints, a standard library, debugging features, and a lot more to enhance developer experience. Dive into how hint functions work with an 'unsafe' keyword to balance innovation and security, explore our new stdlib modules, and see how the compiler pipeline visualizer can help you understand the compiling process. Plus, check out our next steps and how you can contribute to shaping noname's future.

ZK/SEC · November 13, 2024