Hatena::ブログ(Diary)

m-kawato@hatena_diary

2012-12-15

WebSocketの中身を覗いてみる

| 00:36 | 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回ずつメッセージを送る処理を実装することにします。

f:id:m-kawato:20121215192630p:image

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

サーバ
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コンソール上に出力されます。

f:id:m-kawato:20121215192749p:image:w400

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

f:id:m-kawato:20121215192942p:image:w400

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

パケットキャプチャ

ようやくここからが本題。

先ほど動かした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) に注目します。

f:id:m-kawato:20121215193129p:image:w400

これはクライアント (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) は以下のようになっています。

f:id:m-kawato:20121215193257p:image:w400

これは先ほどとは逆にサーバクライアントパケットで、以下のような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 の行は以下のようになっています。

f:id:m-kawato:20121215193424p:image:w400

これは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の行を見てみます。

f:id:m-kawato:20121215193454p:image:w400

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

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を使った場合にどのようにメッセージがエンコードされるかも興味のあるところです。

トラックバック - http://d.hatena.ne.jp/m-kawato/20121215/1355585805