【実務・中級編】 整数オーバーフロー・アンダーフローの回避とSafeMathの役割 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

スマートコントラクトにおける「数値を過信した者たちの末路」

おい、手を休めてこっちを向いてくれ。
先週、競合他社のDeFiプロトコルでまた数百万ドルの流出インシデントが起きたのは知っているな? 犯行の手口は目新しいものじゃない。基礎の基礎、整数オーバーフロー(Integer Overflow)だ。

「いまどきSolidity 0.8.x使ってるんだから大丈夫だろ」と思ったそこの君、非常に危うい。
確かに近年のコンパイラはデフォルトでオーバーフローを検知してトランザクションを即座にロールバック(revert)してくれる。だがな、レガシーなコントラクトを保守する現場や、ガス代(Gas)を極限までケチるために意図的にチェックをバイパスする unchecked ブロックを乱用した瞬間、君の書いたコードはハッカーにとって格好のATMに変わるんだ。

OT(制御システム)の現場でPLCのレジスタ溢れが物理的なプラントの爆発を引き起こすのと同様に、ブロックチェーンの世界では、変数の型が持つビット数の限界を突破された瞬間に、デジタル資産が消え去る。今日は、この整数オーバーフロー・アンダーフローの根本原因と、Solidityのバージョンに応じた鉄壁の防御策を、現場の泥臭い知見とともに叩き込んでおく。

—

なぜ整数オーバーフローは起きるのか?

コンピュータの世界では、数値は有限のビット数(Solidityなら uint256 なら256ビット)で表現される。
例えば、8ビットの符号なし整数(uint8)を考えてみよう。表現できる最大値は $2^8 – 1 = 255$ だ。この変数に 1 を足すとどうなるか? 256という数値は8ビットに収まらないため、上位ビットが切り捨てられ、値は 0 に巻き戻る(ラップアラウンド)。これがオーバーフローだ。

逆に、0 から 1 を引くと最大値である 255 に跳ね上がる。これがアンダーフローだ。

攻撃者はこの挙動を利用し、計算結果を意図的にバグらせる。
「保有残高を超えるトークンを引き出す」「本来より圧倒的に少ない金額で大量のアイテムを購入する」といった不整合をスマートコントラクトに引き起こさせ、一瞬でプールを空にする。これがコントラクトのロジックの隙を突くサイバー犯罪の常套手段だ。

—

脆弱な実装と、それがもたらす悪夢(PoCの概念)

まずは、Solidity 0.8.x未満(または unchecked を誤用した)のコードに潜む危険なパターンを見てみよう。

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

// 【危険な実装例】レガシーなコードやuncheckedの乱用
contract VulnerableVault {
    mapping(address => uint256) public balances;

    // 預金機能
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // 脆弱な引き出し機能
    function withdraw(uint256 _amount) public {
        // 残高チェックをしているが...
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        // 【致命傷】ここで計算上の不整合を突かれる可能性がある
        // もし別の箇所でアンダーフローを引き起こせれば、実質無制限に引き出せる
        (bool sent, ) = msg.sender.call{value: _amount}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] -= _amount;
    }
}

このコード自体には一見大きな穴がないように見えるかもしれないが、例えば「報酬計算ロジック」や「totalSupplyの計算」などでアンダーフローが発生し、balances の値が極端に大きな数値に書き換わったとしたらどうなるか? require のチェックをいとも簡単にすり抜け、プロトコルの資金を根こそぎ奪われることになる。

—

防御策①:Solidity 0.8.x以降の組み込みチェックを正しく理解する

現代のSolidity(0.8.0以降)では、算術演算子(+, -, *, / など)にデフォルトでオーバーフロー・アンダーフローのチェックが組み込まれている。エラーが検出された場合、自動的に revert が走り、ガス代の消費を除いてステート変更はすべてロールバックされる。

しかし、ここでエンジニアが陥りがちな罠がある。「ガス最適化(Gas Optimization)」のために unchecked を無闇に使うことだ。

ループカウンタのインクリメントなど、オーバーフローが絶対に起きないことが数学的に証明されている箇所以外で unchecked を使ってはならない。もし使う場合は、以下のように厳密な境界値チェックを自前で実装するか、リスクを完全にコントロールできる者だけが触るべきだ。

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

contract ModernSecureVault {
    mapping(address => uint256) public balances;

    function deposit() public payable {
        // 0.8.x以降のデフォルト動作により、ここで万が一オーバーフローすれば即座にrevertされる
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 _amount) public {
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        // 0.8.xの標準機能により、マイナスになる計算は自動的にrevertされるため安全
        balances[msg.sender] -= _amount;

        (bool sent, ) = msg.sender.call{value: _amount}("");
        require(sent, "Failed to send Ether");
    }
}

—

防御策②:レガシー環境における OpenZeppelin SafeMath の利用

もし、どうしてもSolidity 0.8.x未満のバージョン(監査済みの既存コントラクト等)を触らざるを得ないプロジェクトに参画した場合は、自前で計算処理を書くのではなく、業界標準である OpenZeppelinの SafeMath ライブラリ を必ずインポートして適用しろ。

以下に、レガシー環境で SafeMath を安全に組み込んだ実装サンプルを示す。

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

// OpenZeppelinのSafeMathをインポート(実際の開発ではnpm経由で取得)
library SafeMath {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");
        return c;
    }

    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a, "SafeMath: subtraction overflow");
        uint256 c = a - b;
        return c;
    }

    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        if (a == 0) {
            return 0;
        }
        uint256 c = a * b;
        require(c / a == b, "SafeMath: multiplication overflow");
        return c;
    }
}

contract LegacySafeVault {
    using SafeMath for uint256; // uint256型に対してSafeMathの関数を紐付け

    mapping(address => uint256) public balances;

    function deposit() public payable {
        // SafeMathのadd関数を呼び出し、オーバーフローを完全に阻止
        balances[msg.sender] = balances[msg.sender].add(msg.value);
    }

    function withdraw(uint256 _amount) public {
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        // SafeMathのsub関数を呼び出し、アンダーフローを完全に阻止
        balances[msg.sender] = balances[msg.sender].sub(_amount);

        (bool sent, ) = msg.sender.call{value: _amount}("");
        require(sent, "Failed to send Ether");
    }
}

—

セキュリティチーフからの実務的アドバイス

脆弱性診断の現場で数々のソースコードを見てきたが、「動けばいいや」の精神で書かれたコントラクトほど、後から致命的なバグが発覚する。スマートコントラクトは一度デプロイしたら原則として修正が効かない(イミュータブルな)世界だ。パッチ当てで済むWebアプリの感覚でデプロイしては絶対にダメだ。

以下のルールをチーム全体の開発ガイドラインとして厳守してほしい。

1. コンパイラバージョンは常に最新の安定版(例: 0.8.20以降)を使用する。 レガシーなバージョン(0.4.xや0.6.xなど)を新規プロジェクトで採用する理由は一切存在しない。
2. unchecked ブロックの使用は最小限に抑える。 ガス代の最適化は魅力的だが、セキュリティ担保のほうが圧倒的に優先度が高い。どうしても使う場合は、Fuzzingテスト(EchidnaやFoundry等)を用いて境界値テストを徹底的に回すこと。
3. 静的解析ツールをCI/CDパイプラインに組み込む。 SlitherやMythrilなどのツールをGitHub Actions等に連携させ、プルリクエストの段階で整数オーバーフローやその他の脆弱性が自動検知される仕組みを強制しろ。

妥協のないコードだけが、君のプロジェクトとユーザーの資産を守り抜く唯一の盾となる。次のデプロイ前には、もう一度自分のコードの型と計算処理を見直すんだな。質問があればいつでも俺のところに聞きに来い。

コメント

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