WebSocketの中身を覗いてみる

この記事は、HTML5 Advent Calendar 2012の15日目のエントリーです。

WebSocketは、Webサーバ・ブラウザ間で双方向に通信するための仕様であり、APIプロトコルがそれぞれ以下の規格で定義されています。

Node.js + Socket.IO のようなライブラリを使うと割と簡単にWebSocketが使えますが、中で何が起こっているかもう少し追ってみたいという動機により、tcpdump+WireSharkによるパケットキャプチャを通してWebSocket通信の中身を調べてみました。

作業環境

サーバ側、クライアント側ともホストはWindows 7 (64ビット版) で、サーバはその上の仮想マシンとして動かしています。おそらくOSに依存する要素は特にないはずです。

Node.js用のWebSocketライブラリとしてはSocket.IOが有名ですが、生のWebSocket APIをラップする形で使うことを前提としているため、ここでは素のWebSocket APIとの組み合わせが容易なWebSocket-Nodeをチョイスしました。
WebSocket-Nodeは、npm install websocketでインストールできます。

サンプルの動作手順

ここではごく単純に、ブラウザからWebSocketサーバに接続後、Webサーバ→ブラウザ、ブラウザ→Webサーバと1回ずつメッセージを送る処理を実装することにします。

これを実装したサーバ側・クライアント側のコードはそれぞれ以下のようになります。

サーバ
var http = require('http');
var WebSocketServer = require('websocket').server;
var fs = require('fs');

var server = http.createServer(function(req, res) {
  fs.readFile(__dirname + '/client.html', function(err, data) {
    res.writeHead(200);
    res.end(data);
  });
});
server.listen(8080);

// WebSocketサーバの作成
var wsServer = new WebSocketServer({
    httpServer: server,
    autoAcceptConnections: true
});

// クライアントからのWebSocket接続時の処理
wsServer.on('connect', function(connection) {
  console.log('Connection accepted, protocol version ' + connection.webSocketVersion);
  connection.send('Hello, world');

  // クライアントからのメッセージ受信処理
  connection.on('message', function(message) {
    console.log('Received Message: ' + message.utf8Data);
  });
});
クライアント
<!DOCTYPE HTML>
<html><head></head>
<body>
<script type="text/javascript">
  // WebSocketサーバとの接続 (動作手順1)
  var ws = new WebSocket("ws://192.168.206.132:8080");
  ws.onopen = function() {
    console.log("connected.");
  }

  // サーバからのメッセージ受信処理
  ws.onmessage = function(evt) {
    console.log("Received: " + evt.data);
    ws.send('Good bye.');
  };
  </script>
</body>
</html>

実行結果

クライアント側では、サーバから受け取った "Hello, world" がJavaScriptコンソール上に出力されます。

サーバ側では、クライアントから受け取った "Good bye" がターミナル上に出力されます。

ひとまず期待通りに動くことが確認できました。

パケットキャプチャ

ようやくここからが本題。
先ほど動かしたWebSocketのサンプルについて、クライアント-サーバ間の通信をキャプチャして、WebSocketプロトコル (RFC 6455) の内容と比較してみます。

ここでは、パケットキャプチャのためにtcpdump、それをGUIベースで解析するためにWireSharkという組み合わせを使います。Ubuntuの場合は、いずれもapt-getで入ります。

tcpdumpに指定したオプションはこんな感じ:

$ sudo tcpdump -i eth0 -s 0 -w dump01.cap

出力されるキャプチャファイル (ここではdump01.cap) をWireSharkから読む込むことでプロトコル解析できます。WebSocketプロトコルにも対応しているので楽チンです。

単純にWireSharkからキャプチャファイルを読み込んだだけでは無関係なパケットも入り込むので、Filter: 欄に以下のように指定して、8080ポート (今回WebSoketサーバに指定したListenポート) への入出力かつHTTPだけを表示するようにします。

tcp.port==8080 && http

(httpを指定すると、WebSocketも自動的に含まれるようです)

以降、今回試したサンプルの動作手順に従って、キャプチャ結果とWebSocketプロトコル仕様の対応関係を見ていきます。

1. WebSocket接続 (ハンドシェイク)

RFC 6455の1.2節および4章によると、WebSocket接続はOpening Handshakeと呼ぶ手続きにより開始され、Opening Handshakeはクライアントからのハンドシェイクとサーバからのハンドシェイクにより構成されます。

まず、キャプチャ結果の以下の部分 (No.20) に注目します。

これはクライアント (192.168.206.1)→サーバ (192.168.206.132) のパケットで、以下のようなHTTPリクエストヘッダの形をしています。

GET / HTTP/1.1
Upgrade: websocket
Connection: upgrade
Host: 192.168.206.132:8080
Origin: http://192.168.206.132:8080
Sec-WebSocket-Key: ByrM/ZMQsliJ3ARpSzF6lg==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: x-webkit-deflate-frame

RFC 6455と突き合わせると、これは4.1節に記載されているクライアントのopening handshakeであることが分かります。

その直後の行 (No.22) は以下のようになっています。

これは先ほどとは逆にサーバ→クライアントのパケットで、以下のようなHTTPレスポンスヘッダの形をしています。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: j+6fEtOsvfgsycBMCfLxWNzPFxk=
Origin: http://192.168.206.132:8080

これは、RFC 6455に記載されているサーバのopening handshakeに相当します。

2. サーバ→クライアントのメッセージ

ここまででWebSocketのハンドシェイクは完了で、ここからはWebSocket接続を通したメッセージの送受信が行われることになります。

キャプチャ結果の No.23 の行は以下のようになっています。

これはRFC 6455の5章のデータフレーミング (data framing) に相当します。
フレームの各要素は以下のようになっていることが分かります。

1... .... = Fin: True          # メッセージ中の最後のfragment
.000 .... = Reserved: 0x00     # 予約済みビット (常に000)
.... 0001 = Opcode: Text (1)   # フレームの種類がテキストフレームであることを示す
0... .... = Mask: False        # ペイロードはマスクされない
.000 1100 = Payload length: 12 # ペイロード長は12

Payload
    Text: Hello, world

すなわち、これがサーバからクライアントに送った "Hello, world" に相当するパケットということになります。

3. クライアント→サーバのメッセージ

次にキャプチャ結果のNo.29の行を見てみます。

サーバ→クライアントのメッセージとほとんど変わりませんが、ペイロードがマスキングされた形で送られるという相違があります。

1... .... = Fin: True          # メッセージ中の最後のfragment
.000 .... = Reserved: 0x00     # 予約済みビット (常に000)
.... 0001 = Opcode: Text (1)   # フレームの種類がテキストフレームであることを示す
1... .... = Mask: True         # ペイロードはマスクされる
.000 1001 = Payload length: 9  # ペイロード長は9
Masking-Key: 9539ffdd          # マスキングキーは 9539ffdd

Payload
  Text: d25690b9b55b86b8bb
Unmask Payload
  [Text unmask: Good bye.]

RFC 6455の5.2〜5.3節には、クライアントからサーバへのメッセージでは、ペイロードのマスキングが必須であり、マスクされたペイロードとマスキングキーのXORを取ることで元の値が復元できる旨が書かれています。が、その理由については述べられていません。

少し調べた限りではある種の攻撃を防ぐための目的のようですが、正直なところあまり理解できていないので、今回はこういうものということでお茶を濁しておきます。

おわりに

この記事では、WebSocket APIプロトコルの関係を実装に即して理解するために、簡単なWebSocket通信のサンプルを動かして、WireSharkでパケットを分析しました。
今回は非常に単純なテキストの送受信しか試していませんが、断片化されるような長いペイロードやバイナリ形式など、これ以外のパターンについても同様の分析をすると面白いと思います。また、Socket.IOを使った場合にどのようにメッセージがエンコードされるかも興味のあるところです。