ガス枯渇(DoS)の悪夢:スマートコントラクトを「止める」技術と「守る」実装
現場でインシデント対応をしていると、よく耳にするセリフがある。「テスト環境では動いていた。本番にデプロイしたら動かなくなった」——。
スマートコントラクトの世界において、この「動かない」は単なるバグではない。それは「ガス制限(Gas Limit)を突かれたDoS攻撃」の被害者である可能性が高い。今回は、ストレージ操作やループ処理の甘さが、なぜプロジェクトを沈没させるのか、そしてそれを回避する泥臭い実務テクニックを伝授する。
—
1. なぜ「ループ」は悪魔の誘惑なのか
Web開発の感覚で for や while をコントラクト内に書くと、高確率で死ぬ。EVM(Ethereum Virtual Machine)は、「計算量=ガス代」という極めてシビアな経済原則で動いているからだ。
悪意ある攻撃シナリオ(PoCの視点)
攻撃者が狙うのは、コントラクト内の「可変長配列のイテレーション」だ。
// 【脆弱なコード:絶対にやってはいけない】
function distributeTokens(address[] memory recipients) public {
for (uint256 i = 0; i < recipients.length; i++) {
// 大量の送金処理がループする
token.transfer(recipients[i], amount);
}
}
このコントラクトに対し、攻撃者が1万件の宛先リストを投げつけたとしよう。各イテレーションでガスを消費し、ブロックのガス上限(Block Gas Limit)に達した瞬間、トランザクションは永遠に失敗する。もしこの関数が重要な管理機能であれば、その瞬間にスマートコントラクトは「機能停止」という名のDoS状態に陥る。
—
2. 実践的防御策:ページネーションとバッチ処理
我々が取るべき戦術は「1回のトランザクションで全てを完結させようとしないこと」だ。
推奨:ページネーションの実装
処理を小さなチャンク(塊)に分割し、UI側で制御させる。これが最も安全で、運用上のリカバリーもしやすい。
/**
* クライアント側(JavaScript/Ethers.js)でのバッチ処理実装例
* 一気に投げず、50件ずつ小分けにしてトランザクションを送る
*/
async function batchDistribute(allRecipients) {
const BATCH_SIZE = 50;
for (let i = 0; i < allRecipients.length; i += BATCH_SIZE) {
const chunk = allRecipients.slice(i, i + BATCH_SIZE);
// ユーザーにトランザクションを承認させる
const tx = await contract.distributeTokens(chunk);
// 処理が完了するまで待機(インデックスの管理を忘れずに)
await tx.wait();
console.log(`Batch ${i / BATCH_SIZE + 1} completed.`);
}
}
コントラクト側の「Pull型」アプローチ
可能であれば、push型(管理者が一括で配る)ではなく、pull型(ユーザーが自分の分を請求する)に設計を変更すべきだ。
// 【セキュアな設計:Pull型】
mapping(address => uint256) public pendingWithdrawals;
function claim() public {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to claim");
pendingWithdrawals[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
これなら、どれだけユーザー数が増えようと、コントラクトのガス消費量は一定であり、DoS攻撃の余地は消滅する。
—
3. インフラレベルでの防御(Web層の補強)
スマートコントラクトを叩くWebサーバー(API)側でも、攻撃の芽を摘んでおく必要がある。
Nginxでのリクエストレート制限
大量のAPI呼び出しによるバックエンドの疲弊を防ぐため、nginx.conf でIPごとの制限を厳格に設定する。
# /etc/nginx/nginx.conf
# ユーザーごとのリクエストレート制限(DoS対策)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s;
server {
location /api/v1/distribute {
limit_req zone=api_limit burst=10 nodelay;
proxy_pass http://backend_cluster;
}
}
—
4. セキュリティチーフからの「現場の心得」
最後に、一つだけ覚えて帰ってほしい。「ガス制限を気にするのは開発の最後ではない」ということだ。
1. 配列の長さを制限せよ: 関数の引数に配列をとる場合、require(recipients.length <= 100, "Too many recipients"); のようなチェックは必須だ。
2. イベントログを活用せよ: オンチェーンで複雑な検索をするのではなく、オフチェーンのインデクサー(The Graph等)に処理をオフロードする設計を検討せよ。
3. ガス見積もりをテストせよ: Hardhat 等の環境で、限界値に近いデータ量を投入し、ガス代の推移をプロファイリングする癖をつけろ。
セキュリティとは、完璧な防御を築くことではない。「攻撃された時に、システムがどこまで耐えられ、どう復旧できるか」を設計の中に組み込むことだ。
君たちが書くコードが、次の「ガス枯渇」の被害に遭わないことを祈っている。実装で迷ったら、いつでもまた聞きに来るといい。
コメント