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

こんにちは!Webアプリケーションの開発やインフラの管理に携わる中で、セキュリティの確保に頭を悩ませていませんか?

「セキュリティの専門用語は難しそう…」「何から手をつければいいのかわからない…」そんな不安を抱えている新人のIT担当者や開発者の方に向けて、今回はリアルタイム通信の主役である「WebSocket(ウエブソケット)」のセキュリティについて、分かりやすく紐解いていきたいと思います。

一歩ずつ、身近な例えを交えながら安全なWebアプリの作り方を学んでいきましょう!

—

1. 家の鍵に例えるWebSocketの仕組みと「Origin(オリジン)」の正体

まずは、WebSocketという技術がどんなものか、そしてなぜセキュリティ対策が必要なのかを身近な例えで考えてみましょう。

皆さんのご自宅を想像してください。従来の通常のWeb通信(HTTP通信)は、いわば「インターホンを押して、用件を伝えて、ドアを閉める」というやり取りの繰り返しです。何か情報を知りたいときは、その都度インターホンを押さなければなりません。

これに対してWebSocketは、一度玄関のドアを開けたら、「お互いにドアを開けっぱなしにして、いつでもリアルタイムに会話ができる専用の直通電話」をつなぐようなものです。チャットアプリや株価のリアルタイム更新などにはなくてはならない便利な技術ですが、この「ドアを開けっぱなしにする」という性質上、大きなリスクを孕んでいます。

知らない泥棒を家に入れてはいけません(Origin検証)

もし、見知らぬ怪しい人物が「やあ!」と勝手にその直通電話の回線に入り込んできたとしたら…ゾッとしますよね。

Webの世界でも同じです。悪意ある攻撃者が作った偽物のウェブサイト(例:https://evil.com)から、あなたのサービスのWebSocketサーバーに対して、勝手に「ドアを開けて!」と接続を要求してくることがあります。これを防ぐために必要なのが、「Origin(オリジン)の検証」です。

Originとは、いわば「相手がどこからやってきたのかを示す身分証明書」のようなものです。サーバー側は、この身分証明書を確認して「うちの信頼できるサイト(例:https://example.com)から来たお客さんだな」と確認できた時だけ、ドアを開けなければなりません。

—

2. 攻撃のメカニズム:CSWSH(クロスサイト・ウェーブソケット・ハイジャック)の恐怖

攻撃者は、私たちが油断している隙にどのような手口を使ってくるのでしょうか。ここで、実務でも狙われるCSWSH(Cross-Site WebSocket Hijacking:クロスサイト・ウェーブソケット・ハイジャック)という攻撃のメカニズムを見てみましょう。

攻撃の流れ

1. 被害者(一般ユーザー)が、悪意のあるサイト(https://evil.com)にうっかりアクセスしてしまいます。
2. その悪意あるサイトの裏側で、JavaScriptがこっそり実行され、あなたの運営するサービスのWebSocketサーバー(wss://chat.example.com/ws)へ接続を試みます。
3. この時、ブラウザは自動的にユーザーの「ログインCookie」などの認証情報を一緒に送信してしまいます。
4. もし、サーバー側で「誰から来たか(Origin)」をしっかりチェックしていなかった場合、サーバーは「おっ、ログイン中のユーザーだね!」と勘違いし、悪意あるサイトからの接続を許可(ハンドシェイク成功)してしまいます。
5. その結果、攻撃者はユーザーになりすまして、WebSocket経由で勝手にメッセージを送信したり、機密情報を盗み出したりすることができるようになってしまいます。

家で例えるなら、留守番中の子供が「親の知り合いだ」と嘘をつく見知らぬ怪しい人を、うっかりリビングに入れてしまうような状態です。

—

3. 現場で使える!具体的な防御コードと設定のポイント

それでは、このような脅威からシステムを守るために、私たちはどのような実装をすればよいのでしょうか?
ここからは、実務でそのまま参考にできる具体的なコードと設定のポイントを見ていきましょう。

今回は、Node.jsの代表的なWebSocketライブラリであるwsを例に挙げて解説します。

実装例:サーバー側でのOrigin検証と認証トークンのハンドシェイク時の取り扱い

サーバーが接続を受け付ける際、「本当にうちのサイトから来たのか?」を厳密にチェックし、さらにURLのクエリパラメータや初期メッセージで認証トークンを安全に検証するコードの例です。

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

// HTTPサーバーを作成
const server = http.createServer((req, res) => {
    res.writeHead(404);
    res.end();
});

// WebSocketサーバーを作成(直接ポートを開かず、HTTPサーバーにアタッチします)
const wss = new WebSocket.Server({ noServer: true });

// 許可する正規のオリジン(あなたのWebサイトのドメイン)
const ALLOWED_ORIGIN = "https://example.com";

server.on('upgrade', (request, socket, head) => {
    // 1. Origin(オリジン)の検証
    const origin = request.headers['origin'];
    
    if (origin !== ALLOWED_ORIGIN) {
        // 身分証明書(Origin)が一致しない、または存在しない場合は接続を即座に拒否!
        console.log(`[警告] 不正なオリジンからの接続試行をブロックしました: ${origin}`);
        socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
        socket.destroy();
        return;
    }

    // 2. 認証トークンのハンドシェイク時の取り扱い(URLクエリからの取得例)
    const parsedUrl = url.parse(request.url, true);
    const authToken = parsedUrl.query.token;

    // ここでトークンの有効性を検証する(データベースやJWTの検証など)
    if (!isValidToken(authToken)) {
        console.log(`[警告] 無効な認証トークンでの接続試行を拒否しました`);
        socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
        socket.destroy();
        return;
    }

    // 検証がすべてOKなら、WebSocketの接続(ハンドシェイク)を許可する
    wss.handleUpgrade(request, socket, head, (ws) => {
        wss.emit('connection', ws, request);
    });
});

// トークン検証のダミー関数
function isValidToken(token) {
    // 実務ではここでJWTの署名検証やDBでのセッション確認を行います
    return token === "secret-valid-token-123";
}

wss.on('connection', (ws, req) => {
    console.log('安全なWebSocket接続が確立されました!');

    // 3. メッセージ内容のバリデーション(受信データの検証)
    ws.on('message', (message) => {
        try {
            // 受信したデータが正しいJSON形式か、想定内の構造かを必ずチェックする
            const data = JSON.parse(message);

            if (typeof data.action !== 'string' || typeof data.payload !== 'string') {
                console.log('[エラー] 不正なデータ構造のメッセージを検知しました');
                ws.send(JSON.stringify({ error: 'Invalid message format' }));
                return;
            }

            // 正常な処理をここに記述
            console.log(`メッセージ受信: ${data.action}`);

        } catch (e) {
            // JSONのパースエラーなどをキャッチ
            console.log('[エラー] JSONの解析に失敗しました');
        }
    });

    ws.on('close', () => {
        console.log('WebSocket接続が閉じられました。');
    });
});

server.listen(8080, () => {
    console.log('セキュアなWebSocketサーバーがポート8080で起動しました。');
});

コードの重要ポイント解説

1. request.headers['origin'] のチェック
ブラウザからの接続には必ずOriginヘッダーが付与されます。これを信頼できるドメイン名と厳密に比較することで、先ほど解説したCSWSH(クロスサイト・ウェーブソケット・ハイジャック)を綺麗に防ぐことができます。
2. URLパラメータやヘッダーによる認証
WebSocketの接続開始時(ハンドシェイク時)に、?token=xxx のような形で認証トークンを渡し、サーバー側でセッションが有効であるかを必ず確認しましょう。認証されていないユーザーをドアの外でしっかり追い返すことが大切です。
3. メッセージ内容のバリデーション(入力値検証)
「接続できたから一安心」ではありません。WebSocketでやり取りするメッセージの中身に対しても、JSON.parse が失敗しないか、想定外の巨大なデータや悪意あるスクリプトが含まれていないか(インジェクション対策)を、受け取るたびに必ずチェックする癖をつけましょう。

—

4. まとめ:一歩ずつ、確実なセキュリティ対策を

今回は、WebSocketのセキュリティについて、Origin検証の重要性や、認証・バリデーションの仕組みを分かりやすく解説しました。

  • Originの検証で、知らない場所からの接続をシャットアウトする。
  • 認証トークンをハンドシェイク時にしっかり確認し、身元を証明させる。
  • メッセージのバリデーションを怠らず、受け取った中身を疑ってかかる。

セキュリティ対策は、一度にすべてを完璧にやろうとすると難しく感じてしまいますが、基本の仕組みを理解し、今回ご紹介したようなコードのポイントを一つずつ丁寧に取り入れていけば、必ず強固なアプリケーションを作ることができます。

日々の開発の中にセキュリティの視点を少しずつ取り入れて、安全で信頼されるWebサービスを一緒に育てていきましょう!

コメント

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