#6 Circom: signal output

`signal output`: values that the component will produce as output. using <== with components and output signals.

signal output

Let's begin by reviewing the example from the previous article:

template Main() {
	// input signals
  signal input x1;
  signal input x2;
  signal input x3;
  signal input x4;
  signal output out;

	// intermediate signals
  signal y1;
  signal y2;

  // calc. intermediate values 
  y2 <-- y1 / x3;

  // constraints can only have quadratic expressions
  // can only use + or * or -
  y1 <== x1 + x2;
  y1 === y2 * x3;
  out <== y2 - x4;
  
}

component main = Main();

The reader would have two questions:

  1. if we denote out as signal out, why did we group it with the other input signals?

  2. does signal output output anything if at all?

signal output is an input signal

Despite the "output" keyword, from the circuit's perspective, out is actually an input signal. The circuit creates a set of R1CS constraints, and out is simply part of this system.

The prover receives values of the witness vector, which must satisfy the circuit's constraints. In this case, the witness vector looks like: [1, out, x1, x2, x3, x4, y1, y2].

Remember, we are not computing anything - only verifying if a computation is correct. Hence, the outcome of the computation must be provided

This raises a question: if out is an input signal, shouldn't we be using === instead of <==?

Why use <== and not === for signal output

The reason for constraining and assigning out is similar to why we use <== with intermediate signals, as explained previously. Although considered an input from the prover's perspective, out is still a value computed by the circuit.

Think of it this way: when defining the circuit, we're establishing relationships between inputs. From the circuit's viewpoint, out is simply another value calculated from the inputs.

The assignment operation (<==) is syntactic sugar—it instructs the compiler to include the necessary calculations in the witness generation artifacts for our convenience.

So why bother with the output designation at all?

Does signal output output anything if at all?

Yes, it does serve a purpose.

  • out is the output of this circuit

  • A circuit can pass values to another circuit, to be used as input

  • However, a circuit doesn't "output" data during execution in the traditional sense of programming

Therefore, a signal labelled signal output can be viewed as an input signal that we intend to pass along to another circuit. Circuits do not output anything to the world.

Subcomponents

As circuits get more complex, Circom lets you break them into smaller, manageable pieces (subcomponents).

Input signals pass data to these subcomponents, while output signals relay data from one circuit to another.

Example

Two templates are defined in this example, Mul and Main. Mul serves to verify that of the supplied inputs, a*b=c. Main does the same repeatedly as seen below:

template Mul() {
	signal input a;
	signal input b;
	signal input c;
	
	c === a * b;
}

template Main() {
	signal input in[6];

	// instantiate subcomponents
	component mul1 = Mul();
	component mul2 = Mul();
	
	// check: in[0] * in[1] = in[2]
	mul1.a <== in[0];
	mul1.b <== in[1];
	mul1.c <== in[2];
	
	// check: in[3] * in[4] = in[5]
	mul2.a <== in[3];
	mul2.b <== in[4];
	mul2.c <== in[5];
}

component main = Main();
  • Instantiations of sub-components are done using the keyword component; not unlike main.

  • Instances of components cannot be reused - to use Mul twice, it must be instantiated twice. mul1 cannot be reused with a different set if inputs.

  • Inputs are passed into sub-components via dot notation. E.g., mul1.a

Why use <== to pass inputs?

To explain this we examine the artifacts produced by the Circom compiler.

For simplicity we will focus on sub-component mul1 only and ignore mul2; rest assured the same logic applies.

Scenario 1: Inputs are assigned and constrained:

  • Witness generation artifacts assign the circuit’s input signals to be inputs to mul1

  • Low-level representation of constraints ensure that mul1 inputs are constrained to the circuit’s input signals.

  • Additionally, there is the mul1 constraint that verifies that a*b=c.

When we feed this system of constraints to a zkSNARK generator (snarkJs), the generated prover and verifier will ensure that all these constraints are adhered to.

Scenario 2: Inputs are assigned, but not constrained

  • Witness generation remains unchanged.

  • But there are is only a single constraint that verifies that a*b=c.

When we feed this system of constraints to a zkSNARK generator, the generated prover and verifier will only look to verify that a*b=c.

The input signals to the Main circuit, in[6], are disregarded.

The prover will not check and generate a proof that is based on in[6] - an attacker could pass arbitrary values (differing from in[6]) to the subcomponent to achieve verification.

We will explore practical methods of achieving this in the exercise section.

In the next article we will see how outputs are used in foundational circuits found within Circomlib (primarily in comparators.circom).


Exercise

This exercise will serve to illustrate the importance of using the <== operator when passing inputs to subcomponents.

We begin by illustrating what happens under the hood when we use <==.

Positive Example

pragma circom 2.1.8;

template Mul() {
	signal input a;
	signal input b;
	signal input c;
	
	c === a * b;
}

template Main() {
    
	signal input in[3];

	// instantiate subcomponents
	component mul = Mul();
	
	// assign & constraints inputs
	mul.a <== in[0];
	mul.b <== in[1];
	mul.c <== in[2];
	
}

component main = Main();

Compile the circuit

  • circom positive.circom --r1cs --sym --wasm --O0

  • Sanity check on terminal output:

    • non-linear constraints: 1

    • linear constraints: 3

This checks out.

  • one non-linear constraint accounting for a*b===c.

  • three linear constraints cover the subcomponent inputs’ constraints

Examine the R1CS file

  • snarkjs r1cs print positive.r1cs

  • Terminal prints all 3 constraints

Generate the witness

  • create inputs.json file in ./positive_js directory

  • values passed will be as below

inputs.json
{"in": ["1", "2", "2"]}
  • run: node generate_witness.js positive.wasm inputs.json witness.wtns

  • export to json: snarkjs wtns export json witness.wtns

  • cat witness.json should output the following:

[
 "1",     // 1
 "1",     // in[0]
 "2",     // in[1]
 "2",     // in[2]
 "1",     // a
 "2",     // b
 "2"      // c
]

The witness produced follows the expected layout.

Next, we check if a malicious witness would be accepted in this scenario.


Informational: --O0 flag

If we had compiled without the --O0 flag, in the terminal output linear constraints would list linear constraints as 0 and this would subsequently affect the structure of the witness later on.

The reader is welcome to repeat the entire process with and without the --O0 flag as a learning exercise.


Exploiting the circuit

We will attempt to exploit the circuit by creating a malicious witness and verifying it. The malicious witness will be created by modifying the valid witness.wtns file.

Create view.js in the same directory as witness.wtns with the following code:

view.js
const fs = require('fs');
const path = require('path');

const witnessPath = path.join(__dirname, 'witness.wtns');

try {
    const witness = fs.readFileSync(witnessPath);
    
    let data_arr = new Uint8Array(witness);
    console.dir(data_arr, {'maxArrayLength': null});
  
} catch (error) {

    console.error(`Error reading witness file: ${error.message}`);
}

Running view.js will give us the following output in terminal:

Observe that the values of our witness are laid out in the same sequential order as witness.json.

Modify witness values

  • Overwrite witness.wtns where the values for signals a, b, c are stored.

  • Create edit.js file in the same directory:

edit.js
const fs = require('fs');
const path = require('path');

// Use path.join to create a full path to the witness.wtns file
const witnessPath = path.join(__dirname, 'witness.wtns');

try {
    const witness = fs.readFileSync(witnessPath);
    
    let data_arr = new Uint8Array(witness);

    console.log("Before");
    console.dir(data_arr, {'maxArrayLength': null});

    data_arr[204] = 4;  // `a`
    data_arr[236] = 5;  // `b`
    data_arr[268] = 20; // `c`

    console.log("After");
    console.dir(data_arr, {'maxArrayLength': null});
		
    // create exploit witness 
    const exploitWitnessPath = path.join(__dirname, 'exploit_witness.wtns');
    fs.writeFileSync(exploitWitnessPath, data_arr);

} catch (error) {

    console.error(`Error reading witness file: ${error.message}`);
}

The After output should be like so:

Observe that only signals a, b, c are modified; the rest remain unchanged.

Verify exploit_witness.wtns against the circuit:

  • snarkjs wtns check positive.r1cs exploit_witness.wtns

This indicates that the malicious witness will not be accepted, as there input constraints to ensure that the circuit’s top-level signals match the inputs to Mul().

Observe that the unmodified witness is accepted: snarkjs wtns check positive.r1cs witness.wtns

Next, the negative example of using <--.

Negative Example

pragma circom 2.1.8;

template Mul() {
	signal input a;
	signal input b;
	signal input c;
	
	c === a * b;
}

template Main() {
    
	signal input in[3];

	// instantiate subcomponents
	component mul = Mul();
	
	// assign inputs
	mul.a <-- in[0];
	mul.b <-- in[1];
	mul.c <-- in[2];
	
}

component main = Main();

Compile

  • circom negative.circom --r1cs --sym --wasm --O0

  • Sanity check on terminal output:

    • non-linear constraints: 1

    • linear constraints: 0

    • private inputs: 3 (none belong to witness)

The above comment is a warning that there are 3 private inputs as per in[3], however none of them are in the witness vector. This will be made apparent as we go along.

The other point of note, is that we no longer have 3 linear constraints, as we do not have input constraints in this scenario.

Examine the R1CS file

  • snarkjs r1cs print negative.r1cs

  • Terminal prints a single constraint: main.add1.a + main.add1.b = main.add1.c

Generate the witness

  • create inputs.json file in ./negative_js directory:

inputs.json
{"in": ["1", "2", "2"]}
  • run: node generate_witness.js negative.wasm inputs.json witness.wtns

  • export to json: snarkjs wtns export json witness.wtns

  • cat witness.json should output the following:

  • run: node generate_witness.js negative.wasm inputs.json witness.wtns

  • export to json: snarkjs wtns export json witness.wtns

  • cat witness.json should output the following:

[
 "1",     // 1
 "1",     // in[0]
 "2",     // in[1]
 "2",     // in[2]
 "1",     // a
 "2",     // b
 "2"      // c
]

The witness produced follows the expected layout. Let’s check if a malicious witness would be accepted in this scenario.

Exploiting the circuit

  • create and run edit.js file in the same directory as witness.wtns

  • layout of the witness variables are the same, and so the same modifications will work

  • verify exploit_witness.wtns against the circuit:snarkjs wtns check negative.r1cs exploit_witness.wtns

The exploit works because we passed values [a=4, b=5, c=20] to the subcomponent, satisfying its constraint, while in[3] held different values.

We successfully exploited the circuit by passing inconsistent values, demonstrating that it's under-constrained. This vulnerability allows an attacker to craft a fake witness using unrelated data, highlighting the importance of constraining inputs when passing them to subcomponents.

Last updated

Was this helpful?