【テクニカル・上級編】 モデル拒否サービス(DoS)攻撃の仕組みとレート制限の実装 – ガバナンス・リスク管理 & 生成AIセキュリティ防御ガイド

LLM推論基盤を窒息させる「非対称な暴力」の正体

従来のWebシステムに対するDoS攻撃は、ネットワーク帯域の飽和(L3/L4)や、Web/APサーバーのコネクションプール枯渇(Slowloris等のL7)が主戦場でした。しかし、生成AI推論インフラが標的となる「Model Denial of Service (Model-DoS)」あるいは「Financial Denial of Service (FDoS)」は、その破壊工作の力学が根本から異なります。

攻撃者が送り込むのは、数メガバイトのジャンクパケットではなく、わずか数キロバイトの極めて巧妙に細工されたプロンプトです。この極小のリクエストが、推論サーバー上のハイエンドGPU(NVIDIA H100やA100)のVRAMを瞬時に数ギガバイト単位でロックし、Tensorコアを限界まで酷使させます。

この「入力コスト」と「消費リソース」の圧倒的な非対称性(Asymmetry)こそが、LLM推論基盤のアーキテクチャが抱える構造的脆弱性です。本稿では、低レイヤのメモリ挙動から推論エンジンの内部スケジューリング、そして実務に耐えうるトークン・アウェアなレート制限の実装まで、防御側のアーキテクチャを完全に解剖します。

—

低レイヤから読み解く推論破綻のメカニズム

Model-DoSの本質を理解するには、TransformerのSelf-Attention機構と、推論エンジンがメモリをどのように管理しているか(KVキャッシュ)を物理レベルで追う必要があります。

[クライアント]
      │
      │ わずか数KBのプロンプト
      ▼
┌────────────────────────────────────────────────────────┐
│ 推論サーバー (例: vLLM, TGI)                           │
│                                                        │
│  [Prefill Phase] (計算集約的)                          │
│   ・入力トークン N に対し O(N^2) のAttention行列計算    │
│   ・FlashAttentionによる最適化があっても計算負荷大     │
│                                                        │
│  [Decode Phase] (メモリ帯域集約的)                     │
│   ・1トークン出力ごとに KVキャッシュ が伸長           │
│   ・VRAMコンテキスト領域を逼迫                         │
│   ・コンテキスト長 M に対し PagedAttentionのブロック枯渇│
└────────────────────────────────────────────────────────┘

1. Prefillフェーズの計算量爆発(Input-Heavy Attack)

Transformerアーキテクチャにおいて、入力シーケンス長 $N$ に対する計算量は標準で $O(N^2)$ です。FlashAttention等のメモリ効率化アルゴリズムが組み込まれている推論エンジンであっても、Prefill(プロンプトの初期評価)処理はGEMM(一般行列乗算)を激しく回すため、Tensorコアを占有します。数万トークンに及ぶ意味のないゴミデータ(ボイラープレートテキスト)を一斉に投下されると、Prefillワーカーのキューが即座に詰まり、Time To First Token (TTFT) が急激に劣化します。

2. DecodeフェーズとKVキャッシュのVRAM枯渇(Output-Heavy Attack)

さらに凶悪なのが、出力トークン数を極限まで引き延ばす攻撃です。推論エンジンは自己回帰的に1トークンずつ生成(Decode)しますが、過去のトークンの計算結果を再利用するために「KVキャッシュ(Key-Value Cache)」をGPU VRAM上に保持し続けます。

FP16で重みを保持するモデル(レイヤー数 $L$、隠れ層次元 $H$)において、1トークンあたりのKVキャッシュサイズはおよそ以下の式で表されます。

$$\text{Memory}_{\text{token}} = 2 \times 2 \times L \times H \quad (\text{bytes})$$

70Bクラスのモデル($L=80, H=8192$)を想定した場合、コンテキストが1トークン進むごとに約 $2.6\text{MB}$ のVRAMが消費されます。攻撃者がプロンプトインジェクション(例:「無限に素数を列挙し続けろ」「終了トークン(<|endoftext|>)を出力せず文字を埋め尽くせ」)を仕掛け、最大コンテキスト長(8k〜32kトークン)まで出力させ切るリクエストを並行して複数走らせた場合、PagedAttentionのような動的メモリ管理技術を採用していても、VRAMのブロック割り当てプールが完全に枯渇(OOM)し、後続リクエストのプリエンプション(強制中断や再計算)が多発、クラスター全体がクラッシュに追い込まれます。

—

脆弱な防御アーキテクチャの典型例

多くの現場で見られるアンチパターンは、「従来のAPI Gateway(Kong, NGINX等)のIPベースまたはURIベースのレート制限(例: 100 req/min)」でLLM推論基盤を守ろうとすることです。

  • 単位の不整合: 「10文字のプロンプトで10文字返すリクエスト」と「32,000トークンの入力を食わせ、4,096トークンをストリーミング生成させるリクエスト」が、従来のカウンターでは同じ「1リクエスト」としてカウントされます。
  • コネクションの長時間占有: 通常のWeb APIは数十ミリ秒で返りますが、LLMのストリーミングレスポンスは数秒〜数十秒間コネクションを維持します。リクエスト数制限をすり抜けた少数のロングコネクションにより、HTTP/2のストリーム上限やリバースプロキシのワーカープロセスが枯渇します。

防御設計には、「リクエスト頻度」ではなく「消費コンピュートリソース(入力トークン数+推定出力トークン数)」をメトリクスとしたトークン・アウェア・レートリミット(Token-Aware Rate Limiting)が不可欠となります。

—

防御の実装:Token-Aware Dynamic Rate Limiter

ここからは、推論基盤の前段に配置する高スループットな防御レイヤーの設計に入ります。
アーキテクチャの要件は以下の通りです。

1. CPUオフロードでのトークン事前計算: 推論エンジンにリクエストが到達する前に、リバースプロキシ/APIゲートウェイ層で入力トークン数を高速に算出(tiktokenやHuggingFace tokenizersを使用)。
2. Redis + Luaスクリプトによるアトミックなコスト追跡: スライディングウィンドウ形式で、「過去 $W$ 秒間に消費されたトークン数」および「現在推論中の推定予約トークン数」を合算して判定。
3. ペナルティ・キューイング: 制限超過時に即座に429を返すだけでなく、バーストトラフィックを平滑化するリーキーバケット型キューの併用。

1. Redis Luaスクリプトによるアトミックなトークン消費判定

分散環境下での競合状態(Race Condition)を防ぐため、トークンの計算と消費チェックはRedis内部でアトミックに実行します。

-- KEYS[1]: ユーザーのレート制限キー (例: ratelimit:user123:tokens)
-- ARGV[1]: 現在のUNIXタイムスタンプ (ミリ秒)
-- ARGV[2]: 判定ウィンドウ幅 (ミリ秒, 例: 60000)
-- ARGV[3]: ウィンドウ内の最大許容トークン数 (例: 100000)
-- ARGV[4]: 今回のリクエストで消費予定のトークン数 (入力トークン + 予約出力トークン)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max_tokens = tonumber(ARGV[3])
local requested_tokens = tonumber(ARGV[4])

local clear_before = now - window

-- 期限切れの古い消費レコードを削除
redis.call('ZREMRANGEBYSCORE', key, '-inf', clear_before)

-- 現在のウィンドウ内の合計消費トークン数を集計
local current_tokens = 0
local records = redis.call('ZRANGEBYSCORE', key, clear_before, '+inf')

for _, record in ipairs(records) do
    -- レコードは "uuid:token_count" 形式で格納されている前提
    local sep = string.find(record, ":")
    if sep then
        local tokens = tonumber(string.sub(record, sep + 1))
        current_tokens = current_tokens + tokens
    end
end

-- 制限チェック
if current_tokens + requested_tokens <= max_tokens then
    -- 許容: 一意のメンバーとしてタイムスタンプ付きで記録 (UUIDを付与して重複防止)
    local member_id = redis.call('INCR', key .. ':seq')
    local member = tostring(member_id) .. ":" .. tostring(requested_tokens)
    redis.call('ZADD', key, now, member)
    -- キーのTTLをウィンドウ幅に合わせて更新
    redis.call('PEXPIRE', key, window)
    return {1, max_tokens - (current_tokens + requested_tokens)} -- 1: 許可, 残トークン数
else
    -- 拒否: レート制限超過
    return {0, max_tokens - current_tokens} -- 0: 拒否, 現在の残トークン数
end

2. FastAPI/Pythonによるガードレイル・プロキシの実装

推論リクエストを受け、トークン数を先読みしてレート制限を適用、さらにバックエンド推論エンジン(vLLM等)のキュー詰まりを防止するリバースプロキシのコアロジックです。

import time
import uuid
from fastapi import FastAPI, HTTPException, Request, status
import redis.asyncio as aioredis
import tiktoken

app = FastAPI(title="LLM DoS Shield Gateway")

# トークナイザーの初期化 (ターゲットモデルの語彙と一致させる)
# CPU上で極めて高速に動作するモデルを選択
encoding = tiktoken.get_encoding("cl100k_base")

redis_client = aioredis.from_url("redis://localhost:6379", decode_responses=True)

# コンフィグレーション
MAX_TOKENS_PER_WINDOW = 50_000  # 1分あたりの最大許可トークン
WINDOW_SIZE_MS = 60_000         # 1分 (ミリ秒)
DEFAULT_RESERVED_OUTPUT = 512   # max_tokens未指定時の予約出力トークン
HARD_LIMIT_INPUT_TOKENS = 8192  # 単一リクエストの物理上限

# Luaスクリプトのロード用SHAキャッシュ
lua_sha = None

LUA_RATE_LIMIT_SCRIPT = """
-- 上述のLuaスクリプトをここに配置
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max_tokens = tonumber(ARGV[3])
local requested_tokens = tonumber(ARGV[4])
local clear_before = now - window
redis.call('ZREMRANGEBYSCORE', key, '-inf', clear_before)
local current_tokens = 0
local records = redis.call('ZRANGEBYSCORE', key, clear_before, '+inf')
for _, record in ipairs(records) do
    local sep = string.find(record, ":")
    if sep then
        current_tokens = current_tokens + tonumber(string.sub(record, sep + 1))
    end
end
if current_tokens + requested_tokens <= max_tokens then
    local seq = redis.call('INCR', key .. ':seq')
    redis.call('ZADD', key, now, tostring(seq) .. ":" .. tostring(requested_tokens))
    redis.call('PEXPIRE', key, window)
    return {1, max_tokens - (current_tokens + requested_tokens)}
else
    return {0, max_tokens - current_tokens}
end
"""

@app.on_event("startup")
async def startup_event():
    global lua_sha
    lua_sha = await redis_client.script_load(LUA_RATE_LIMIT_SCRIPT)

@app.post("/v1/chat/completions")
async def secure_chat_proxy(request: Request):
    user_id = request.headers.get("X-API-Key")
    if not user_id:
        raise HTTPException(status_code=401, detail="API Key missing")

    body = await request.json()
    messages = body.get("messages", [])
    
    # 1. 入力トークン数の高速事前検証
    total_input_tokens = 0
    for msg in messages:
        # ロールメタデータ等のオーバーヘッド(約4トークン/メッセージ)を加算
        total_input_tokens += len(encoding.encode(msg.get("content", ""))) + 4

    if total_input_tokens > HARD_LIMIT_INPUT_TOKENS:
        # 不当に肥大化したプロンプトは即時遮断 (Prefill DoSの防御)
        raise HTTPException(
            status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
            detail=f"Prompt exceeds maximum allowed context size of {HARD_LIMIT_INPUT_TOKENS} tokens."
        )

    # 2. 生成予定トークン(max_tokens)の評価
    # 攻撃者はここを省略してモデルの最大値まで浪費させようとするため、未指定時は厳格にバインド
    requested_max_tokens = body.get("max_tokens", DEFAULT_RESERVED_OUTPUT)
    
    # このリクエストが消費し得る最大計算コスト
    estimated_cost = total_input_tokens + requested_max_tokens

    # 3. Redis Luaスクリプトによるコストベースのレート制限判定
    current_time_ms = int(time.time() * 1000)
    rate_limit_key = f"ratelimit:{user_id}:token_bucket"

    result = await redis_client.evalsha(
        lua_sha,
        1,
        rate_limit_key,
        current_time_ms,
        WINDOW_SIZE_MS,
        MAX_TOKENS_PER_WINDOW,
        estimated_cost
    )

    allowed, remaining = result[0], result[1]

    if allowed == 0:
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail={
                "error": "Token quota exceeded",
                "remaining_tokens": remaining,
                "retry_after_seconds": 10
            }
        )

    # 4. バックエンド推論基盤へのリクエスト転送 (vLLM / TGI等)
    # 実際の実装では httpx 等を用いて内部クラスターへリバースプロキシする
    # response = await forward_to_backend(body)
    
    return {
        "status": "forwarded", 
        "estimated_tokens": estimated_cost,
        "remaining_window_tokens": remaining
    }

—

推論クラスターのスケジューリング戦略(vLLM / TGI)

ゲートウェイ層でのトラフィック遮断に加え、推論エンジン自体のコンフィグレーションにおいて「リソースの奪い合い」を数理的に防ぐ設定を施す必要があります。特にオープンソースの標準推論基盤である vLLM を本番運用する場合、以下のパラメータチューニングがサービス継続性を左右します。

python3 -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Meta-Llama-3-70B-Instruct \
    --tensor-parallel-size 4 \
    --max-model-len 8192 \
    --max-num-seqs 64 \
    --max-num-batched-tokens 4096 \
    --gpu-memory-utilization 0.90 \
    --swap-space 16 \
    --scheduling-policy priority
  • --max-model-len 8192: モデル本来のコンテキスト長が32kであっても、ビジネス要件上不要であれば強制的に切り詰めます。これにより1リクエストあたりのKVキャッシュ最大割り当て量を確定させます。
  • --max-num-batched-tokens 4096: 1回のイテレーション(フォワードパス)で処理する最大トークン数を固定します。巨大なPrefillリクエストが突入しても、チャンク化(Chunked Prefill)されて処理されるため、Decode中の他ユーザーのストリーミングが完全に停止(スタベーション)するのを防ぎます。
  • --swap-space 16: GPUメモリが枯渇した際、即座にプロセスをOOM Killerで死なせるのではなく、ホストCPUのRAMにKVキャッシュブロックを退避(スワップ)するバッファサイズ(GiB)を指定します。

—

ガバナンスとリスクマネジメントへの落とし込み

CISSPの観点から見れば、単にファイアウォールを置くことだけがセキュリティではありません。Model-DoSに対する耐性を組織のガバナンスフレームワークに統合し、監査可能な状態を確立することが極めて重要です。

1. メトリクスに基づくSLA/SLOの再定義

従来の「HTTPレイテンシ 99% < 200ms」といった指標はLLMでは形骸化します。以下の推論固有メトリクスをPrometheus/Grafanaで監視し、異常値を検知した際に動的スロットリングを発動させます。

  • TTFT (Time To First Token): Prefillフェーズの負荷をダイレクトに示す指標。この急上昇はInput-Heavy DoSの予兆です。
  • TPOT (Time Per Output Token): Decodeフェーズの滑らかさを示す指標。この悪化はGPUのVRAM帯域の飽和、または並行ストリーム数の限界を意味します。
  • KV Cache Usage %: 80%を超えた段階で新規セッションのキューイング(または縮退運転:出力長制限の強制短縮)を開始するトリガーとします。

2. リスクアセスメントシートへの反映

情報セキュリティ基本方針およびAIセキュリティガイドラインにおいて、以下の脅威シナリオと対策をアセスメントシートに明文化します。

| 脅威シナリオ | 影響度 | 発生可能性 | 技術的対策 | 統制評価基準 |
| :— | :— | :— | :— | :— |
| Recursive Output Attack
(意図的な無限出力ループ誘発) | High (GPU OOM停止) | High | Gatewayでの強制max_tokensバインド、Stopシーケンスの多重検証 | 監査ログ上で異常トークン消費ユーザーが特定・自動切断されているか |
| Prefill Flood Attack
(コンテキスト上限プロンプトの乱打) | Critical (推論基盤全断) | Medium | Chunked Prefillの強制、Token-Awareスライディングウィンドウ制限 | 定期的な負荷テスト(Locust/JMeter)でTTFTが閾値内に収まるか |
| Financial Drain (FDoS)
(従量課金APIの予算超過破滅) | High (財務損失) | High | 組織/ユーザー単位の累積コスト・サーキットブレーカー(日次/月次) | 予算消費率90%でのハードリミット遮断ロジックが機能しているか |

結び

生成AIのセキュリティといえば、プロンプトインジェクションによる機密情報の漏洩(データプライバシー)にばかり議論が集中しがちです。しかし、インフラが物理的に停止すれば、機密性を論じる前提そのものが消失します。

AIアプリケーションを設計するセキュリティアーキテクトは、モデルの論理的な「賢さ」に目を奪われてはなりません。その背後で蠢く数千のCUDAコア、逼迫するハイバンドワイヤメモリ(HBM)、そしてミリ秒単位で消費されるメモリブロックという「物理的な制約」を冷徹に捉え、低レイヤの現実に根ざした多層防御アーキテクチャを構築することこそが、次世代のシステムを守り抜く唯一の道です。

コメント

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