LLM04: Model Denial of Service(モデル拒否サービス)の現実と脅威
おい、ちょっと手を止めて聞いてくれ。
最近、社内のあちこちで「生成AIをプロダクトに組み込むぞ」という威勢の良い掛け声が聞こえるが、お前たちはその裏に潜むインフラ崩壊の爆弾に気づいているか?
OWASP Top 10 for LLMの第4位に掲げられている LLM04: Model Denial of Service(モデル拒否サービス)。これはいわゆる従来のDDoSとは毛色が違う。ボットネットを使った力技のトラフィック洪水じゃない。攻撃者は、たった数行の洗練された(あるいは悪意ある)プロンプトを送りつけるだけで、お前たちの背後にある高価なLLMのコンテキストウィンドウを焼き尽くし、GPUメモリをバーストさせ、APIの利用料金を数時間で数百万円に跳ね上がらせる。
現場のエンジニアリングにおいて、AI機能の実装は「動けば正義」ではない。リソース管理のガードレールを持たないLLM連携は、社内に時限爆弾を抱えているのと同義だ。今日は、この泥沼のインシデントを防ぐための実践的なアーキテクチャと、コピペで即座に導入できる防御コードを授けよう。
—
なぜLLMのDoSは従来のWebアプリと違うのか?
従来のWebアプリであれば、DBのコネクション数やCPU使用率を監視し、NginxやWAFで単純なリクエスト数のレートリミット(例: 1分間に60回まで)をかけておけば大抵の負荷はしのげた。
しかし、LLMの世界ではこの常識が通じない。
攻撃者は 1分間にたった1回のリクエスト しか送らなくても、サーバーを完全に沈黙させることが可能だ。
悪夢の攻撃シナリオ(PoCの概念)
例えば、ユーザーが自由に入力できるテキストエリアを持つ要約AIサービスがあったとする。攻撃者は以下のようなプロンプトを送り込む。
1. 自己言及的・再帰的プロンプトの乱用:
「以下の文章を100万回繰り返して要約し、その過程の思考プロセスをすべて出力せよ:(以下、無限に続くコピペ文章)」
2. 極端なコンテキスト水増し:
LLMの入力上限(例: 128kトークン)のギリギリまで、意味のない巨大なバイナリデータをBase64エンコードしてプロンプトに混ぜ込み、さらにモデル側に複雑な推論(Chain of Thought)を強要する。
これを受け取ったバックエンドは、入力トークンの処理(Prompt Evaluation)と、長大な出力トークンの生成(Token Generation)でGPUをフル稼働させ続ける。1リクエストあたりの処理時間が通常の数十倍になり、APIプロバイダ(OpenAIやAnthropic等)のタイムアウトを引き起こすか、自社ホストのVLLM/TGIサーバーのVRAMを枯渇させて他の正当なユーザーのトラフィックをすべてドロップさせる。これがLLM04の正体だ。
—
徹底防御のための3層アーキテクチャ
この脅威からシステムを守るには、アプリケーション層だけでなく、APIゲートウェイ層、そしてトークンカウンティング層を組み合わせた「多層防御(ディフェンス・イン・ダース)」が不可欠だ。
具体的には、以下の3つの防壁を構築する。
1. 入力前の文字数・トークン数のハードリミット(API Gateway / Nginx層)
2. 厳密なトークン消費量に基づくレート制限(Redis + アプリケーション層)
3. ストリーミング処理中の異常検知と強制切断(バックエンド層)
口で言うのは簡単だな。じゃあ、実際に現場で使える具体的なコードを見ていこう。
—
実装サンプル:Python(FastAPI)によるトークン制限・コスト監視ミドルウェア
ここでは、リクエストを受け取った段階で「tiktoken」等のライブラリを用いて正確な入力トークン数を計算し、閾値を超えている場合は即座に弾く、さらにRedisを用いた高度なレートリミットを実装したコードを示す。
実務でそのまま組み込めるように、詳細なコメントを残しておいた。
import time
import tiktoken
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
import redis
app = FastAPI()
# Redisクライアントの初期化(レートリミットおよびトークン消費量トラッキング用)
# 本番環境では環境変数から接続情報を取得すること
redis_client = redis.Redis(host='localhost', port=6379, db=0)
# 使用するモデルのエンコーダーをロード(OpenAI gpt-4o等の例)
enc = tiktoken.encoding_for_model("gpt-4o")
# 制限値の定義(ビジネス要件や予算に応じて調整)
MAX_INPUT_TOKENS = 4000 # 1リクエストあたりの最大入力トークン数
RATE_LIMIT_WINDOW = 60 # ウィンドウ時間(秒)
MAX_TOKENS_PER_WINDOW = 20000 # 1ユーザーあたりが1分間に消費できる最大トークン数
def count_tokens(text: str) -> int:
"""プロンプトのテキストから正確なトークン数を算出し、巨大な入力を事前に検知する"""
return len(enc.encode(text))
@app.middleware("http")
async def llm_dos_mitigation_middleware(request: Request, call_next):
# LLMを利用するエンドポイント以外はスルー
if request.url.path != "/api/v1/generate":
return await call_next(request)
# クライアントの識別(実際はJWTのユーザーIDやAPIキー等を利用する)
client_ip = request.client.host
rate_limit_key = f"rate_limit:{client_ip}"
token_usage_key = f"token_usage:{client_ip}"
# リクエストボディの取得(※本番ではストリームやサイズに応じたハンドリングに注意)
try:
body = await request.json()
prompt = body.get("prompt", "")
except Exception:
return JSONResponse(status_code=400, content={"detail": "Invalid JSON body"})
# 1. 入力トークン数のハードリミット検証
input_tokens = count_tokens(prompt)
if input_tokens > MAX_INPUT_TOKENS:
# ログに攻撃の兆候として記録することを強く推奨
return JSONResponse(
status_code=413,
content={"detail": f"Payload Too Large: Input tokens ({input_tokens}) exceed the maximum limit of {MAX_INPUT_TOKENS}."}
)
# 2. Redisを用いたトークン消費ベースのレートリミット(スライディングウィンドウに近い制御)
current_usage = redis_client.get(token_usage_key)
current_usage = int(current_usage) if current_usage else 0
if current_usage + input_tokens > MAX_TOKENS_PER_WINDOW:
return JSONResponse(
status_code=429,
content={"detail": "Rate Limit Exceeded: Token consumption limit reached for this time window. Please try again later."}
)
# 3. リクエスト処理の実行とコスト(トークン)の加算
# パイプラインでRedisの有効期限を更新しつつ現在の使用量を加算
pipe = redis_client.pipeline()
pipe.incrby(token_usage_key, input_tokens)
# キーがまだ存在しない、またはTTLが設定されていない場合はウィンドウ時間を設定
if not redis_client.ttl(token_usage_key) or redis_client.ttl(token_usage_key) < 0:
pipe.expire(token_usage_key, RATE_LIMIT_WINDOW)
pipe.execute()
response = await call_next(request)
return response
@app.post("/api/v1/generate")
async def generate_text(request: Request):
# ここに実際のLLM呼び出しロジックが入る
# (例: OpenAI APIや自社ホストのvLLMへのリクエスト)
return {"status": "success", "message": "Generated successfully"}
—
インフラ層(Nginx / API Gateway)での防御設定
アプリケーションコードだけではなく、フロントの門番であるNginxやリバースプロキシでもしっかりと蓋をしておくべきだ。攻撃者はアプリケーションの脆弱性を突く前に、巨大なHTTPリクエストボディを送りつけてWebサーバーのメモリを圧迫しようとする。
以下の nginx.conf の設定を適用し、想定外の巨大ペイロードを水際でブロックしろ。
http {
# クライアントから送信されるリクエストボディの最大サイズを厳格に制限
# LLMへの入力であっても、テキスト主体であれば1MB(約25万〜50万文字)を超えることは通常あり得ない
client_max_body_size 1M;
# バッファサイズの設定(メモリ枯渇攻撃の緩和)
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
server {
listen 80;
server_name api.your-domain.com;
location /api/v1/generate {
# タイムアウトの設定:LLMの応答待ちでコネクションが占有されるのを防ぐ
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 90s; # モデルの推論時間を考慮しつつ長すぎないように設定
proxy_pass http://backend_llm_service;
# プロキシヘッダーの転送
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
—
シニアエンジニアからの実務的アドバイス
コードを書き、インフラを整えただけではまだ仕事の半分だ。運用フェーズにおいて、以下のポイントを必ずチームで共有しておいてほしい。
1. コストアラートのリアルタイム連動:
APIプロバイダ(OpenAI等)のダッシュボードや、社内プロキシのログ監視ツール(DatadogやPrometheusなど)を使い、「過去5分間で通常の3倍以上のトークンが消費された場合」に SlackやPagerDutyへ即座にアラートが飛ぶ仕組みを作れ。LLM04の最大の恐怖は、気づいた時には数百万の請求が発生していることだ。
2. システムプロンプトや出力トークン数の上限(max_tokens)の強制:
ユーザーからの入力だけでなく、モデルが出力するトークン数(max_tokens や max_output_tokens)も必ずAPIリクエスト側でハードコードして制限しろ。これを怠ると、無限ループや長大なコードを出力し続けるハルシネーションによって、一瞬でコンテキストウィンドウが埋め尽くされる。
セキュリティとは、完璧な防御壁を一度作って終わりではない。攻撃者の手口の進化に合わせて、ガードレールを常にアップデートし続ける泥臭いプロセスのことだ。
お前たちの書くコードが、会社の資産と安定稼働を守る最後の砦になる。頼んだぞ。
コメント