整数オーバーフローの悪夢: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以前のコードをそのまま動かしている」プロジェクトを見つけたら、即座に修正の優先順位を上げろ。それが、次の大惨事を防ぐ唯一の道だ。
現場からは以上だ。また何かあればいつでも聞かせてくれ。
コメント