スマートコントラクトにおける「無限の罠」:ガス制限とDoS攻撃の深層
スマートコントラクトのセキュリティ監査において、私たちは日々、コードの「ロジックの美しさ」と「物理的な制約」の狭間で格闘している。Web2の世界であれば、無限ループやメモリ枯渇は単一プロセスのクラッシュか、せいぜいサーバーのオートスケーリングで吸収できる。しかし、EVM(Ethereum Virtual Machine)という分散型ステートマシン上では、リソースの枯渇はネットワーク全体の合意形成を揺るがす致命的な武器、すなわちDoS(Denial of Service)攻撃へと直結する。
今回は、スマートコントラクトにおけるDoSベクトルの核心、特に「ガス制限超過(Out of Gas)」と「肥大化するループ処理」が生む脆弱性に焦点を当て、攻撃者がどのようにコントラクトを凍結させるのか、そしてそれを防ぐためのアーキテクチャ設計はどうあるべきかを、実務の現場感覚で紐解いていく。
—
1. 根本原因:EVMの経済的・物理的制約が生む脆弱性
EVM上で実行されるすべてのオペコード(Opcode)には、厳格なガス(Gas)コストが割り当てられている。これは無限ループによるノードのハングアップを防ぐための防壁であると同時に、攻撃者にとっては「低コストでコントラクトを永久に麻痺させる」ためのトリガーにもなり得る。
典型的なアンチパターンとしてよく見られるのが、動的にサイズが変動する配列(address[], uint256[] など)を for ループで直接走査する設計だ。
脆弱な実装例(アンチパターン)
以下のコードを見てほしい。一見すると何の変哲もない、参加者への一斉配当(エアドロップ等)を行うコントラクトの断片だ。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableAirdrop {
address[] public recipients;
// 運営者がアドレスを追加していく関数
function addRecipient(address _recipient) external {
recipients.push(_recipient);
}
// 【危険】配列の長さが無制限に増えるため、将来的に必ずガス制限超過を起こす
function distributeTokens() external payable {
uint256 length = recipients.length;
for (uint256 i = 0; i < length; i++) {
// 外部コントラクトへの転送処理(これがループ内にあるのが最悪の悪手)
(bool success, ) = recipients[i].call{value: 1 wei}("");
require(success, "Transfer failed");
}
}
}
このコードの何が問題か。攻撃者、あるいは悪意のないユーザーによって recipients 配列に数千、数万件のアドレスが蓄積された瞬間、distributeTokens() の実行に必要なガス量はブロックのガスリミット(Block Gas Limit)を突破する。結果として、この関数を呼び出すトランザクションは永遠に失敗(Revert)し、コントラクト内の資金や機能が完全に凍結される。これがスマートコントラクトにおける古典的かつ強力なDoS攻撃のメカニズムだ。
さらに悪質なのは、recipients[i].call のような外部呼び出しがループ内に存在する場合だ。受信側のフォールバック関数(fallback / receive)で意図的に大量のガスを消費させたり、常に revert を返すようなスマートコントラクトを混ぜ込まれたりすると、ループ全体が巻き添え食って停止する(Gas Griefing)。
—
2. 防衛のパラダイムシフト:プッシュ型からプル型(Withdrawal Pattern)へ
前述したような脆弱性を根絶するためには、スマートコントラクト設計の哲学を根本から転換する必要がある。それが 「プッシュ型決済(Push)からプル型決済(Pull)への移行」 である。
- プッシュ型(危険): コントラクト側が主導権を握り、ループを回して強制的に各ユーザーへ資金やデータを送りつける。
- プル型(安全): ユーザー自身が主導権を握り、自分の分を後から引き出し(Withdraw)にくる。
セキュアな実装例(プル型決済の採用)
プッシュ型でループ処理を排除し、各ユーザーが個別に請求するパターンに書き換えたコードが以下だ。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SecureAirdrop {
// ユーザーごとのクレーム可能残高を管理
mapping(address => uint256) public claimableBalance;
mapping(address => bool) public hasClaimed;
// 管理者が一括で権利を付与(マッピングの更新はループ不要、またはオフチェーン署名検証を活用)
function setAllocation(address[] calldata _recipients, uint256[] calldata _amounts) external {
require(_recipients.length == _amounts.length, "Mismatched arrays");
for (uint256 i = 0; i < _recipients.length; i++) {
claimableBalance[_recipients[i]] = _amounts[i];
}
}
// 【安全】ユーザー自身がトランザクションを送信して引き出す(プル型)
function claim() external {
uint256 amount = claimableBalance[msg.sender];
require(amount > 0, "Nothing to claim");
require(!hasClaimed[msg.sender], "Already claimed");
// 再入攻撃(Reentrancy)を防ぐため、状態変数を先に更新
claimableBalance[msg.sender] = 0;
hasClaimed[msg.sender] = true;
// 最後に送金処理を実行(Checks-Effects-Interactionsパターン)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
この設計であれば、いくら参加者が増えようとも、ブロックチェーン全体のガス制限に引っかかることはない。トランザクションの負荷は「個々のユーザーのトランザクション」に分散されるため、コントラクト全体がDoS攻撃によって機能不全に陥るリスクを完全に排除できる。
—
3. チーフホワイトハッカーの視点:監査とインフラレイヤーのチェックリスト
スマートコントラクトのコードレビューやセキュリティ監査を遂行する際、私は常に以下のポイントを泥臭く検証している。開発現場のテックリードやエンジニアも、この基準をCI/CDパイプラインやコードレビューのチェックリストに組み込んでほしい。
1. 配列の境界チェックとサイズ制限:
コントラクト内で使用されるすべての動的配列([])について、外部からのプッシュ操作で無制限に要素が増加する構造になっていないか。上限値(Max Limit)がハードコードされているか、あるいは EnumerableSet などのライブラリで適切に管理されているかを確認する。
2. ループ内の外部コール(External Calls)の排除:
for や while ループの内部で transfer, send, call などの外部コントラクト呼び出しを行っていないか。行っている場合、ガス枯渇攻撃や意図的なリバートによるサービス停止耐性はあるか。
3. ページネーション(Pagination)の実装:
どうしてもオンチェーンで全データを取得・走査する必要がある場合(フロントエンドの表示用など)、一度に処理する件数を制限するオフセット・リミット機構(例: getRecipients(uint256 offset, uint256 limit)) がビュー関数に実装されているか。
結びにかえて
Web3のセキュリティにおいて、「動けばいい」というコードは、本番環境に出た瞬間に最大の攻撃サーフェス(攻撃対象領域)となる。EVMの制約を無視した実装は、自ら脆弱性をバラ撒いているようなものだ。
プロトコルの設計段階から物理的・経済的な限界を逆算し、プッシュ型からプル型へのアーキテクチャの転換を徹底すること。それこそが、巧妙化するサイバー攻撃から自社の資産とユーザーを守り抜くための、唯一にして最強の防衛策である。
コメント