【実務・中級編】 コントラクトのガス最適化とDoS攻撃耐性 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

ガス代は「命綱」:スマートコントラクトを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;
    }
}

—

まとめ:泥臭い検証こそが最強の防御

スマートコントラクトの脆弱性は、華麗なハッキング手法よりも、「設計時の甘い見積もり」によって生まれることの方が多い。

「これくらいのデータ量なら大丈夫だろう」という慢心は捨てろ。常に「最大値が入力されたらどうなるか?」を想像し、最悪のケースでもシステムが停止しない設計を心がけること。それが、君たちの書くコードを堅牢なものにする唯一の道だ。

分からないことがあれば、いつでもコードを持って相談に来てくれ。一緒にデバッグしよう。

コメント

タイトルとURLをコピーしました