お疲れ。最近、DeFiプロトコルやNFTマーケットプレイスのスマートコントラクト監査が続いているが、相変わらず「ガス制限攻撃(Gas Limit DoS)」の仕掛けられたコードが見つかる。
「まさか、たかがループ処理ごときでシステム全体が沈没するわけがない」なんて甘い考えを持っている後輩がいたら、今すぐその頭を切り替えてくれ。ブロックチェーンの世界では、無限ループや肥大化した配列の操作は、そのまま「資金凍結」や「サービス停止(DoS)」という致命傷に直結する。
今回は、攻撃者がどのようにガスリミットを枯渇させ、スマートコントラクトを機能不全に追い込むのか、その生々しい手口と、現場で使える具体的な防御コードについて徹底的に解説しよう。
—
1. なぜ「ガス制限攻撃」はスマートコントラクトの急所なのか?
Web2のシステムであれば、数万件のデータを一度にループ処理してタイムアウトしたとしても、サーバーを再起動すれば事足りる。しかし、EVM(Ethereum Virtual Machine)の世界ではルールが違う。
すべてのトランザクションには実行上限である gasLimit が設定されており、ブロック自体のガス上限(Block Gas Limit)というハードリミットも存在する。攻撃者は、この仕組みを逆手に取る。
攻撃シナリオのリアル
例えば、ホワイトリストに登録されたアドレスを一括で処理するコントラクトがあったとする。
// 【危険な実装例】絶対に真似してはいけないアンチパターン
address[] public whitelist;
function distributeRewards() external {
// ユーザーが自由にエントリーできる仕様だと、配列は際限なく肥大化する
for (uint i = 0; i < whitelist.length; i++) {
// 各アドレスへのトークン転送処理(外部呼び出しを含む)
payable(whitelist[i]).transfer(100 wei);
}
}
このコントラクトの何がヤバいか分かるか? whitelist の長さが 10,000 件を超えたあたりから、1回のトランザクションで消費されるガス代がブロックのガス上限を突破するようになる。
こうなると、誰も distributeRewards() を正常に実行できなくなる。結果として、コントラクト内にロックされた報酬は永遠に引き出せなくなり、プロジェクトは機能不全(DoS)に陥る。攻撃者はほんの少額のガス代を払って「誰でも追加できるアドレス登録」を繰り返すだけで、プロジェクト全体を人質に取れるというわけだ。
—
2. 攻撃者を完封する防御デザイン:ページネーションとプッシュ・プル分離
この脆弱性を叩き潰すためには、設計思想の転換が必要だ。基本方針は以下の2つ。
1. ループ処理の分割(ページネーション / Pagination)
2. 「プッシュ型」から「プル型」への移行
特に、一括処理(一斉送信など)をコントラクト側で無理やりやろうとするのが諸悪の根源だ。「ユーザーに自分で引きに来させる(プル型)」か、「管理者が分割して実行する(ページネーション)」のどちらかを強制しなければならない。
—
3. 【コピペで使える】セキュアな実装サンプルコード
百聞は一見に如かず。実際に実務で使える、ガス最適化とDoS防止を考慮した Solidity のセキュアな実装コードを提示する。今回は、安全な「ページネーションによるバッチ処理」の実装だ。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title ガス制限攻撃(DoS)を防止するセキュアなバッチ処理コントラクト
* @notice メンテナンス担当: セキュリティチーム
*/
contract SecureBatchProcessor {
address public owner;
address[] private whitelist;
mapping(address => bool) public isWhitelisted;
// イベント定義(オフチェーンでのインデックス追跡用)
event AddedToWhitelist(address indexed account);
event BatchProcessed(uint256 startIndex, uint256 endIndex);
modifier onlyOwner() {
require(msg.sender == owner, "Caller is not the owner");
_;
}
constructor() {
owner = msg.sender;
}
/**
* @notice ホワイトリストにアドレスを追加する
* @param _account 追加するアドレス
*/
function addToWhitelist(address _account) external onlyOwner {
require(!isWhitelisted[_account], "Already whitelisted");
isWhitelisted[_account] = true;
whitelist.push(_account);
emit AddedToWhitelist(_account);
}
/**
* @notice 現在のホワイトリストの総数を取得する
* @return 登録アドレス数
*/
function getWhitelistLength() external view returns (uint256) {
return whitelist.length;
}
/**
* @dev 【重要】ページネーションを用いた安全なループ処理
* @param startIndex 処理を開始する配列のインデックス
* @param count 処理する件数(バッチサイズ)
*/
function processRewardsBatch(uint256 startIndex, uint256 count) external onlyOwner {
uint256 totalLength = whitelist.length;
// 配列の範囲外アクセスを防ぐ
require(startIndex < totalLength, "Start index out of bounds");
// 終了インデックスを計算(配列の端を超えないようにする)
uint256 endIndex = startIndex + count;
if (endIndex > totalLength) {
endIndex = totalLength;
}
// ガスリミット内に収まるように適切に小さめのバッチサイズ(例: 50〜100件)を指定させる
for (uint256 i = startIndex; i < endIndex; i++) {
address recipient = whitelist[i];
// 実際の送金または報酬処理ロジック
// ※ 外部コントラクト呼び出しを行う場合はReentrancy(リエントラシー)にも注意すること
(bool success, ) = recipient.call{value: 100 wei}("");
require(success, "Transfer failed");
}
emit BatchProcessed(startIndex, endIndex);
}
}
この実装のポイント
- バッチサイズの外部制御: 管理者(またはフロントエンド)側から
startIndexとcountを指定させることで、1回のトランザクションで消費するガス量を完全にコントロールできる。 - 境界値チェック:
require(startIndex < totalLength, ...)やendIndexのクランプ処理により、予期せぬrevertを防ぎつつ、安全に処理を分割・継続できる。
—
4. フロントエンド・オフチェーン側での実装アプローチ
コントラクト側をページネーション対応させたら、それを呼び出すフロントエンド(JavaScript / TypeScript)やバックエンドのインデクサー側も連動させる必要がある。以下は、安全にバッチ処理をループ実行するJavaScriptのサンプルだ。
const { ethers } = require("ethers");
/**
* 巨大な配列を安全に分割してコントラクトのバッチ処理を呼び出す関数
* @param {ethers.Contract} contract ターゲットのスマートコントラクト
* @param {number} batchSize 1回あたりに処理する安全な件数(例: 50)
*/
async function executeSafeBatchProcessing(contract, batchSize = 50) {
try {
// コントラクトから現在の総登録者数を取得
const totalLength = await contract.getWhitelistLength();
console.log(`総処理対象数: ${totalLength.toString()}`);
let currentIndex = 0;
while (currentIndex < Number(totalLength)) {
console.log(`バッチ実行中: インデックス ${currentIndex} から ${batchSize} 件...`);
// トランザクション送信(ガスリミットを明示的に指定することも可能)
const tx = await contract.processRewardsBatch(currentIndex, batchSize, {
gasLimit: 3000000 // 必要に応じて適切なガスリミットを設定
});
console.log(`トランザクション送信完了: ${tx.hash}`);
// マイニング(ブロックに取り込まれる)のを待つ
const receipt = await tx.wait();
console.log(`ブロック #${receipt.blockNumber} にて確定`);
// 次のインデックスへ進む
currentIndex += batchSize;
}
console.log("すべてのバッチ処理が正常に完了しました。");
} catch (error) {
console.error("バッチ処理中にエラーが発生しました:", error);
// ここで失敗した場合、現在の currentIndex からリトライできる仕組みを作っておくと堅牢
}
}
—
5. セキュリティチーフからの現場の教訓
スマートコントラクトの開発において、「あとで配列のサイズが増えたらどうなるか」を想像できないエンジニアは、プロトコルを崩壊させるリスクを常に抱えていることになる。
1. 配列のプッシュし放題を絶対に許すな: アクセス制御のない配列への push は、それ自体がDoSの引き金になる。
2. オンチェーンでの無限ループは悪: 「データ数が動的に変動するもの」に対して、終了条件の定められていない for や while を書いた時点でセキュリティレビューは不合格だ。
3. プル型(Withdrawパターン)の検討: 管理者が一斉送信するのではなく、ユーザーが個別に withdraw() を呼んで自分の報酬を回収する設計にすれば、そもそもガス制限攻撃の余地すらなくなる。
コードを書くときは常に最悪のシナリオ(悪意あるユーザーが限界までデータを詰め込んできた状態)をシミュレーションしろ。頼むぞ、同じチームのエンジニアとして、後手に回るような脆弱性を二度とリリースしないようにな。
コメント