【実務・中級編】 ZK-Rollupの回路設計における算術演算のオーバーフロー脆弱性 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

ZK-Rollup回路の「静かなる崩壊」:算術オーバーフローが引き起こす証明の偽造と対策

おい、ちょっと手を止めてこっちを向いてくれ。
最近、「ZK-Rollupさえ導入しておけばL2の安全性は完璧だ」と、頭の中でお花畑を咲かせている開発者が多すぎる。数学的に証明されているから安全? ゼロ知識証明(ZKP)を使っているから改ざん不可能な?

甘い。あまりにも甘いと言わざるを得ない。

俺たちが日々向き合っているのは、綺麗なホワイトペーパーの中の数式ではなく、実装という名の泥水だ。特に、CircomやHalo2といった言語で書かれる制約システム(Constraint System)の設計ミス、中でも算術演算のオーバーフローは、L2全体の資金を丸ごとハッカーに明け渡す致命傷になり得る。

今日は、現場のセキュリティチーフとして、攻撃者がどこを突き、どうやって現実のシステムを守り抜くのか、その生々しいノウハウを叩き込んでやる。

—

1. 現場の盲点:なぜZK回路のオーバーフローは恐ろしいのか?

通常のWebアプリケーションやスマートコントラクト(Solidityなど)であれば、オーバーフローが発生すればトランザクションがリバートするか、あるいは言語仕様に応じた例外処理が走る。

しかし、ZK-Rollupの回路(Circuit)の世界はルールが全く違う。

回路内で行われる計算は、有限体(Finite Field)上の素数 $p$ を法とする演算だ。ここで変数が表現できるビット長を超えた値をとったとき、通常のプログラミング言語のように「エラーで止まる」わけではない。「ラップアラウンド(値の巻き込み)」が発生したまま、数学的な制約(Constraints)が無理やり満たされてしまうのだ。

攻撃者はこの挙動の隙をつく。
例えば、「残高が負になってはいけない」「トータルの発行枚数が一致していなければならない」というチェック(R1CSやPlonKの制約)を、意図的なオーバーフローによってバイパスし、「存在しないはずのトークンを無限に生成してL1に持ち出す」という離れ業をやってのける。

これが、証明システムそのものを欺く算術オーバーフローの脅威だ。

—

2. 攻撃シミュレーション:脆弱な回路設計の末路

実際に、よくある「残高の引き出し(Withdrawal)」を検証する脆弱な回路の断片を見てみよう。ここではCircomを例にする。

pragma circom 2.1.6;

include "../node_modules/circomlib/circuits/comparators.circom";

template VulnerableWithdraw() {
    // 隠し入力(プライベート):ユーザーの現在の残高
    signal input balance;
    // 公開入力(パブリック):引き出し額
    signal input amount;
    // 出力:引き出し後の残高
    signal output newBalance;

    // 【脆弱な実装】
    // 単に引き算をしているだけで、underflow(負の値へのラップアラウンド)をチェックしていない
    newBalance <== balance - amount;

    // 比較器で「newBalance >= 0」をチェックしているつもり……だが!
    // 有限体上では、負の値は「極端に大きな正の値(p - 差分)」として扱われる。
    component gte = GreaterEqThan(252);
    gte.in[0] <== newBalance;
    gte.in[1] <== 0;
    
    // 制約:newBalanceは0以上でなければならない
    gte.out === 1;
}

このコードの何がヤバいか分かるか?
有限体(Field)の性質上、balance(例: 100)から amount(例: 150)を引いたとき、newBalance は -50 になるのではなく、巨大な正の整数($p – 50$)に化ける。
GreaterEqThan(252) は単にその巨大な数を「0以上」と判定してしまうため、制約はあっさりクリアされ、検証者は「正当な引き出しだ」と誤認してしまうのだ。

結果、攻撃者は手元にわずか100しか残高がないのに、150を引き出し、差分をL1のブリッジコントラクトで不正にクレームできてしまう。これが現実のインシデントで起きたら、チーム全員クビでは済まない。

—

3. 完全防御:ビット長制限とレンジプルーフィングの実装

この脆弱性を叩き潰す唯一の道は、「すべての入力と中間変数が、想定されるビット長の中に収まっていることを数学的に強制する(Range Proof)」ことだ。

先ほどのコードを、実務で使えるレベルまで堅牢に書き換えたサンプルがこれだ。後輩共はこれをコピペして、自分のプロジェクトの回路を見直せ。

pragma circom 2.1.6;

include "../node_modules/circomlib/circuits/comparators.circom5";
include "../node_modules/circomlib/circuits/bitify.circom";

template SecureWithdraw() {
    // 隠し入力:ユーザーの現在の残高(64ビットに収まるべき値)
    signal input balance;
    // 公開入力:引き出し額(64ビットに収まるべき値)
    signal input amount;
    // 出力:引き出し後の残高
    signal output newBalance;

    // 1. 入力値自体が予期せぬ巨大な値(有限体のラップアラウンドを利用したもの)でないか
    // それぞれの変数が確実に64ビット以内であることを強制する(レンジプルーフ)
    component balanceCheck = Num2Bits(64);
    balanceCheck.in <== balance;

    component amountCheck = Num2Bits(64);
    amountCheck.in <== amount;

    // 2. 引き算を行う前に、balance >= amount であることを厳密に検証する
    // これにより、アンダーフロー(負の数への突入)を物理的(数学的)に阻止する
    component fits = GreaterEqThan(64);
    fits.in[0] <== balance;
    fits.in[1] <== amount;
    fits.out === 1; // 満たさなければここで即座に証明生成・検証が失敗する

    // 3. 安全な引き算
    newBalance <== balance - amount;

    // 4. 出力に対しても念のためレンジプルーフをかけ、あふれを防止
    component newBalanceCheck = Num2Bits(64);
    newBalanceCheck.in <== newBalance;
}

component main {public [amount]} = SecureWithdraw();

このセキュアコードのポイント

1. Num2Bits(64) の強制: 入力された値が本当に64ビットの範囲内にあるかを検証している。これにより、攻撃者が有限体のモジュロ演算を利用して不正な巨大数を送り込むのを防ぐ。
2. 事前の大小比較 (GreaterEqThan): 計算を実行する*前*に大小関係を検証することで、ロジック上の矛盾を防ぐ。
3. パブリック入力の明確化: 何を公開し、何を隠すのか(public [amount])の境界線を厳格に定義する。

—

4. インフラ・コントラクト側の二重防御(ディフェンス・イン・ディープ)

回路の修正だけで安心してはいけない。万が一、天才ハッカーが回路の隙間を抜けて不正なゼロ知識証明書を生成してきたり、L1側のスマートコントラクト検証ロジックにバグがあった場合の「保険」をかけておく必要がある。

以下は、L1側(EthereumなどのEVM互換チェーン)でZKプルーフを受け取る際に、Solidity側でも必ず実装すべきサニタイズ・バリデーションのコードだ。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IVerifier {
    function verifyProof(
        uint[2] calldata a,
        uint[2][2] calldata b,
        uint[2] calldata c,
        uint[1] calldata publicInputs
    ) external view returns (bool);
}

contract SecureL1Bridge {
    IVerifier public immutable verifier;
    mapping(address => uint256) public balances;

    // 最大引き出し額のハードリミット(L1側での二重チェック)
    uint256 public constant MAX_WITHDRAWAL_LIMIT = 10_000_000 * 10**18;

    constructor(address _verifier) {
        verifier = IVerifier(_verifier);
    }

    // ユーザーがL2からの引き出しを請求する関数
    function withdraw(
        uint256 amount,
        uint[2] calldata a,
        uint[2][2] calldata b,
        uint[2] calldata c
    ) external {
        // 【インフラ・コントラクト層での防御】
        // 1. パラメータ自体の健全性チェック(L1側でのオーバーフロー・異常値検知)
        require(amount > 0, "Zero withdrawal");
        require(amount <= MAX_WITHDRAWAL_LIMIT, "Exceeds hard limit");

        // パブリックインプットの構築(回路側の定義と1バイトたりともズレてはならない)
        uint[1] memory publicInputs = [amount];

        // 2. ゼロ知識証明の検証
        bool isValid = verifier.verifyProof(a, b, c, publicInputs);
        require(isValid, "Invalid zero-knowledge proof");

        // 3. 状態更新(アンダーフロー対策としてSolidity 0.8+の組み込みチェックに依存しつつ、明示的にガード)
        require(balances[msg.sender] >= amount, "Insufficient L1 balance for claim");
        balances[msg.sender] -= amount;

        // 実際の送金処理...
    }
}

—

5. シーフエンジニアからの最後の忠告

セキュリティにおいて「ここまでやれば絶対安全」というゴールはない。特にZK-Rollupやブロックチェーンの領域では、一度バグが突かれれば、スマートコントラクトのように一時停止(Pausable)機能がうまく機能する保証すらないケースが多い。

回路を書くときは、常に「すべての信号は悪意ある攻撃者によって最大値あるいは最小値に操作されている」という前提から出発しろ。

1. すべての数値変数にビット長制限(Range Proof)をかけること。
2. 計算の「前」に条件分岐や大小比較の制約を入れること。
3. L1のスマートコントラクト側でも、信頼せずに必ず生データのバリデーションを行うこと。

この3つを徹底するだけで、お前たちのチームが世間のハッキングニュースの主役に選ばれる確率は劇的に下がる。さて、理論はこのへんにして、さっそく手元のコードベースの Num2Bits の抜け漏れをチェックしに行こうぜ。

コメント

タイトルとURLをコピーしました