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

整数オーバーフローの悪夢:Solidity 0.8.0以前の亡霊と現代の防御哲学

現場で「古いコードを改修してくれ」と頼まれたとき、私が真っ先に確認するのは pragma solidity のバージョンだ。もしそれが 0.8.0 未満なら、そのプロジェクトは地雷原を裸足で歩いているようなものだ。

かつて我々を悩ませた「整数オーバーフロー・アンダーフロー」は、単なるバグではない。それは、計算結果が型の最大値・最小値を超えた際に、ラップアラウンド(0に戻る)してしまうという仕様の隙間を突いた、極めて致命的な攻撃ベクトルだ。

1. なぜ「ラップアラウンド」が凶器になるのか

例えば、uint8(0〜255)の変数に対して、255に1を加算するとどうなるか。直感的にはエラーになるべきだが、Solidity 0.8.0以前の環境では、平然と 0 になる。

攻撃者はこれを利用して、本来「残高が足りないはず」のユーザーが「アンダーフローを起こして極大値の残高を手に入れる」といった荒業をやってのける。

// 0.8.0以前の脆弱なコード例
function withdraw(uint256 _amount) public {
    require(balances[msg.sender] - _amount > 0); // ここでアンダーフローが発生し得る
    balances[msg.sender] -= _amount;
    // ...
}

この balances[msg.sender] - _amount が balances[msg.sender] より大きければ、結果は超巨大な整数となり、require のチェックをすり抜けてしまう。これが、数多のDeFiプロジェクトを破綻させた「古典にして最強」の攻撃手法だ。

2. 「SafeMath」という名の応急処置

0.8.0以前の環境では、OpenZeppelinの SafeMath ライブラリを使うのが鉄則だった。これは、計算の前後でオーバーフローが起きていないかを数学的に検証するラッパー関数だ。

// SafeMathを使った安全な実装(レガシーな環境での必須作法)
using SafeMath for uint256;

function withdraw(uint256 _amount) public {
    require(balances[msg.sender] >= _amount, "残高不足");
    balances[msg.sender] = balances[msg.sender].sub(_amount);
}

しかし、もう2024年だ。現在進行系で開発しているプロジェクトで SafeMath をわざわざ書く必要はない。コンパイラ自体に組み込まれたチェック機構を信じるべきだ。

3. 現代の防御:コンパイラによる「組み込みチェック」の活用

Solidity 0.8.0以降、算術演算はデフォルトでオーバーフロー・アンダーフロー時に revert(トランザクションの取り消し)を起こすようになった。もし、意図的にラップアラウンドを利用したい(例えばビット演算の高速化など)場合を除き、以下の設定を維持することが、現代のWeb3開発における「最低限の礼儀」だ。

hardhat.config.js での最適化設定

意図しない演算ミスを防ぐため、コンパイラのバージョンは固定し、optimizerを適切に設定する。

module.exports = {
  solidity: {
    version: "0.8.20", // 0.8.0以降を使用し、組み込みチェックを有効化
    settings: {
      optimizer: {
        enabled: true,
        runs: 200 // セキュリティとガス代のバランスを最適化
      }
    }
  }
};

4. 現場の教訓:フロントエンド側での「二重防御」

スマートコントラクト側で防ぐのは大前提だが、バックエンド(Node.js/Python等)やフロントエンドでユーザー入力を扱う際も、同じ思想を持つべきだ。特にJavaScriptの Number 型は 2^53 - 1 までしか安全に扱えない。これを超える数値(Wei単位の計算など)を扱う場合は、必ず BigInt を使うこと。

// JavaScriptにおける安全な計算例
const balance = BigInt("1000000000000000000"); // 1 ETH
const withdrawAmount = BigInt(userInput);

if (withdrawAmount > balance) {
    throw new Error("残高が足りません");
}

const newBalance = balance - withdrawAmount;
console.log(`新しい残高: ${newBalance.toString()}`);

最後に:セキュリティは「仕様」に頼らない

私たちがIoTやOTの現場で培った知見は、Web3の世界でも全く変わらない。「システムは必ず壊れる、あるいはバグる」という前提で設計すること。

オーバーフローは、コンパイラが自動で防いでくれる時代になった。しかし、ロジックの脆弱性はツールだけでは防げない。ユニットテストで max_uint を入力したときの挙動を確認する、Fuzzingテストを導入する、そして何より「コードを複雑にしすぎない」こと。

もし、チーム内で「0.8.0以前のコードをそのまま動かしている」プロジェクトを見つけたら、即座に修正の優先順位を上げろ。それが、次の大惨事を防ぐ唯一の道だ。

現場からは以上だ。また何かあればいつでも聞かせてくれ。

コメント

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