ガス代は「命綱」:スマートコントラクトをDoSから守るための現実的アプローチ
現場で数々のスマートコントラクトの監査をしてきて痛感するのは、「ガス最適化」と「DoS耐性」は単なるコスト削減の話ではなく、システムを生存させるためのセキュリティの根幹だということだ。
特に、Web3のコントラクトは一度デプロイしたら修正が困難だ。ループ処理を適当に書いた結果、ストレージの肥大化とともに「ガス代がブロック制限を超えてしまい、二度と関数が実行できない」という、いわゆる「ガス枯渇による恒久的なDoS状態」に陥るケースを何度も見てきた。
今日は、後輩エンジニア諸君が陥りやすい罠と、それを回避するための実務的な設計パターンを伝授する。
—
1. 攻撃者が狙う「ループの罠」
まず、最も危険なのは「配列の長さが不明なループ」をコントラクト内に書くことだ。
悪い実装例:なぜこれが危険なのか
// 警告:非常に危険な実装
function distributeTokens(address[] memory recipients) public {
for (uint i = 0; i < recipients.length; i++) {
token.transfer(recipients[i], amount);
}
}
このコードは、recipientsの要素数が100件なら動くかもしれない。だが、もし誰かが悪意を持って数千件の配列を送り込んだらどうなるか?
実行に必要なガス代がブロックのガスリミットを超えた瞬間、そのトランザクションは永久に成功しなくなる。これがスマートコントラクトにおける「DoS攻撃」の典型だ。
—
2. 解決策:Pull over Push(プッシュ型からプル型へ)
「全ユーザーに一斉に送金する」という考え方を捨てるのが鉄則だ。代わりに、ユーザーが自分で報酬を引き出しに来る「プル型(Withdraw Pattern)」を採用する。
推奨される実装例(Solidity)
// 報酬の引き出しパターン
mapping(address => uint256) public pendingWithdrawals;
// ユーザーが自分で引き出すことで、ループによるリスクを回避
function withdraw() public {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "引き出し可能な残高がありません");
pendingWithdrawals[msg.sender] = 0; // 再入攻撃対策のため先に更新
payable(msg.sender).transfer(amount);
}
この実装なら、ループ処理そのものが存在しないため、配列の長さに依存したDoSは発生しない。
—
3. どうしてもループが必要な時の「ページネーション」
どうしても一括処理が必要な場合は、処理を分割する設計にしなければならない。以下は、JavaScript(ethers.js)側から制御するための、処理分割可能なコントラクトの雛形だ。
ページネーション対応の実装例
// 処理をバッチ化して実行する
function processBatch(address[] calldata recipients, uint256 startIndex, uint256 batchSize) external {
uint256 endIndex = startIndex + batchSize;
require(endIndex <= recipients.length, "範囲外のインデックスです");
for (uint i = startIndex; i < endIndex; i++) {
// 実際の処理ロジック
_performTransfer(recipients[i]);
}
}
これをフロントエンドから制御する際は、以下のようにバッチサイズを固定してループを回す。
フロントエンド側の実装(JavaScript/ethers.js)
const batchSize = 50; // 一度のトランザクションで処理する数
const recipients = [...]; // 大量の宛先リスト
async function executeBatchProcess(contract) {
for (let i = 0; i < recipients.length; i += batchSize) {
// 50件ずつトランザクションを送信する
const tx = await contract.processBatch(recipients, i, batchSize);
await tx.wait(); // 前の処理が完了するまで待機
console.log(`処理完了: ${i + batchSize} 件目まで`);
}
}
—
4. 現場の教訓:セキュリティ運用の鉄則
コードの書き方以外にも、インフラ側で守るべきポイントがある。
- 入力値の妥当性チェック:
bytesやarrayを受け取る際は、必ず現実的な最大サイズ(例:require(list.length <= 100, "配列が長すぎます");)をバリデーションすること。 - オフチェーン計算の活用: 高コストな計算はスマートコントラクトで行わず、オフチェーン(サーバーサイド)で計算し、結果だけをコントラクトに渡す(Merkle Tree等の活用)。
- 監視体制:
Gas Usedが常にブロックリミットの80%を超えているようなコントラクトがあれば、即座に設計の見直しが必要だ。
NginxでのDoS緩和設定(参考)
Web3 APIを公開する場合、Nginxでレートリミットをかけておくことも防御の一つだ。
# nginx.conf
# 1IPあたり毎秒10リクエストに制限
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
server {
location /api/v1/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://your_backend;
}
}
—
まとめ:泥臭い検証こそが最強の防御
スマートコントラクトの脆弱性は、華麗なハッキング手法よりも、「設計時の甘い見積もり」によって生まれることの方が多い。
「これくらいのデータ量なら大丈夫だろう」という慢心は捨てろ。常に「最大値が入力されたらどうなるか?」を想像し、最悪のケースでもシステムが停止しない設計を心がけること。それが、君たちの書くコードを堅牢なものにする唯一の道だ。
分からないことがあれば、いつでもコードを持って相談に来てくれ。一緒にデバッグしよう。
コメント