Websocketデータ転送パケットの解析


今年も登録してしまったアドベントカレンダー。。
リブセンス @eri です。転職サイト[ジョブセンスリンク](http://job.j-sen.jp/)の開発をしています。一昨日の晩ごはんは築地でふぐ刺しを食べました。

チャットアプリからのチャットを他端末から送れたりしないかなー、からの流れでチャットの仕組みが気になり、Websocketについて調べていました。

HTTP通信と異なるWebsocketの特徴として、1コネクションでの双方向通信、転送データが軽量、非同期などなどありますが、今回はデータ転送パケットの構造をメインに説明します。

# Websocket概要

Websocketの全容は、分かりやすいスライドがあったのでそちらを見ていただくとして。
http://www.slideshare.net/mawarimichi/websocketwebrtc

Websocket通信が確立するまでに、サーバー・クライアント間で色々なやり取り(Websocket opening ハンドシェイクと呼ばれる)が行われます。

通信(実はTCP)が確立した後、低コストでのデータ転送が行われます。ヘッダー長が長くなりがちなHTTP通信よりもかなり転送量が抑えられています。

しかし、転送内容にはクライアントが送りたい素のデータ入っているのではなく、Websocketのプロトコルで組み立てられたデータが転送されます。勉強がてら、パケットからクライアントデータを取り出すスクリプトを書いていきます。

# 準備
まず、Websocketでテキストをやり取りするだけの仕組みを作っておきます。

**OS/クライアント環境/利用言語・モジュール**

- Mac OS 10.11.1
- Google Chrome 47
- Node.js v4.2.2
- [websocket](https://www.npmjs.com/package/websocket) (Node.jsモジュール)
- ruby 2.0.0p645


**クライアント側**

```html:index.html

<!DOCTYPE html>
<html>
<head>
  <title>websocket sample</title>
</head>

<body>
  <input type="text" id="message"> <input type="button" id="send" value="submit">

  <script>
    (function() {
        var ws = new WebSocket("ws://localhost:8008");
        var output = document.getElementById('output');
        var send = document.getElementById('send');
        send.addEventListener('click', function() {
          var msg = document.getElementById('message').value;
          ws.send(msg);
        });
      }());
  </script>
</body>
</html>

```

**サーバー側**

```js:app.js
var http = require('http');
var clientHtml = require('fs').readFileSync('index.html');

var httpServer = http.createServer(function(req, res) {
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.end(clientHtml);
});

httpServer.listen(8008);

var WebSocketServer = require('websocket').server;
var wsServer = new WebSocketServer({ httpServer: httpServer });

wsServer.on('request', function (req) {
  var connection = req.accept(null, req.origin);
  connection.on('message', function(msg) {
    console.log(msg.utf8Data);
  });
});
```

サーバーを立ち上げ、

```shell-session
$ node app.js
```

http://localhost:8008 にアクセス

![image](https://qiita-image-store.s3.amazonaws.com/0/54562/8175ead2-4f7f-fb1c-b238-298687eba421.png)

ここクライアント側でテキストをsubmitすると、サーバー側でechoされます。

#パケット取得
パケットを取得するために、テキストsubmit直後の通信をdumpします。

```shell-session
$ tcpdump dst port 8008 -i lo0 -s0 -w send.pcap
```

サーバー側でListenしてるポート番号で絞り込み、
`-i lo0`でlocalhost間の通信のみを絞り込んでます。



![image](https://qiita-image-store.s3.amazonaws.com/0/54562/7194ae8b-0d04-ec7e-80d5-0e34e2d0729d.png)


今回は7バイトのASCIIテキストを送信。
send.pcapに保存されました。

#TCPデータ取得
dump結果はそのままでは読めない(バイナリ)、各通信プロトコルを解読していくのは面倒、とにかくTCPデータ本体だけ見たい、ということで[Wireshark](https://www.wireshark.org/download.html)の力を借ります。

WiresharkはGUIだけでなくCLIも用意されてあり、インストール後`tshark`コマンドが使えるようになります。
データだけ欲しい、というオプションを指定してあげると、

```shell-session
$ tshark -r send.pcap -T fields -e data
8187e1a4c61d8cc5f46e80cfa7
```

16進数でTCPのデータが取れました。13バイトしかありません。

#バイトごとの取り出し
取れたデータを操作するために、16進文字列のn+1バイト目を、符号なし整数で取り出すメソッドもここで作っておきます。(Ruby)

```rb:
tcp_data = `tshark -r send.pcap -T fields -e data`.chomp

def tcp_data.get_char(n)
  [self].pack("H*")[n].unpack("C*")[0]
end
```
16進文字列をバイナリ変換して1バイト取り出し、それをchar型に変換しています。

#Websocketデータ解読
Websocketでのデータはフレームと呼ばれており、[RFC6455](http://tools.ietf.org/html/rfc6455#section-5.2) の Base Framing Protocolにフレームの読み方が記載されています。



      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-------+-+-------------+-------------------------------+
     |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
     |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
     |N|V|V|V|       |S|             |   (if payload len==126/127)   |
     | |1|2|3|       |K|             |                               |
     +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
     |     Extended payload length continued, if payload len == 127  |
     + - - - - - - - - - - - - - - - +-------------------------------+
     |                               |Masking-key, if MASK set to 1  |
     +-------------------------------+-------------------------------+
     | Masking-key (continued)       |          Payload Data         |
     +-------------------------------- - - - - - - - - - - - - - - - +
     :                     Payload Data continued ...                :
     + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
     |                     Payload Data continued ...                |
     +---------------------------------------------------------------+


横幅4バイト分の表になっています。
フレームプロトコルの各要素を見ていきます。端折っているので、詳しくは[RFC6455](http://tools.ietf.org/html/rfc6455#section-5.2)を見てください。

**FIN**
送られたフレームが最後のフラグメントだった場合に1。
(Websocketではデータをフラグメントという単位に分割して送ることも出来ます。)

```rb
(tcp_data.get_char(0) & 0b10000000) >> 7
=> 1
```
今回1。

**RSV1, RSV2, RSV3**
0以外の値にて意味を定義する拡張がネゴシエーションされていない限り、0でなければならない。通常0。

```rb
(tcp_data.get_char(0) & 0b01110000) >> 4
=> 0
```
全て0になっています。

**opcode**
Payload dataの形式を指定。先程フォームから送信したデータにあたる。テキストの場合1。バイナリデータもWebsocketで送信可能で、その場合2となる。その他、接続closeの際の値や、今後の拡張のための予約済みの値など様々決められている

```rb
tcp_data.get_char(0) & 0b00001111
=> 1
```

テキストを送信したので1。

**Mask**
1の場合、Payload dataがMasking-keyでマスクされている。
クライアントからサーバーへの送信の場合は1。

```rb
(tcp_data.get_char(1) & 0b10000000) >> 7
=> 1
```
クライアントから送信してるため1。

**Payload len**
Payload dataの長さ(バイト数)

```rb
tcp_data.get_char(1) & 0b01111111
=> 7
```

7バイトのテキストであることがわかる。

**Extended payload length**
Payload lenが126か127だった場合に指定されるので、今回はなし。

**Masking-key**
Maskが1の場合、Masking-keyが存在する。Payload Dataをマスキングしている。
TCPデータの3バイト目〜6バイト目。

**Payload Data**
Websocketのデータ部。先程フォームから送信したテキストにあたる。
TCPデータの7バイト目〜13バイト目。

#クライアントデータのアンマスク
Maskが適用されているのでアンマスクしてあげます。
Payload Dataに対し、Masking-keyを1バイトずつXORすると、アンマスクすることができます。

```rb
masking_key_start_index = 2
payload_start_index = 6
payload_length = tcp_data.get_char(1) & 0b01111111

data = ''
payload_length.times do |i|
  # Masking-keyは4バイトしかないため、それよりもPayload Dataが長い場合は繰り返し適用
  masking_key_index = masking_key_start_index + (i % 4)
  masking_key_char = tcp_data.get_char(masking_key_index)

  masked_char = tcp_data.get_char(payload_start_index + i)

  # Masking-keyとPayload Dataを1バイトずつXOR
  data << (masking_key_char ^ masked_char)
end
```

#パケット解析スクリプトまとめ
パケットを渡すとクライアントデータが返るスクリプトとして、以上をまとめます。

```parse_packet.rb
tcp_data = `tshark -r send.pcap -T fields -e data`.chomp

def tcp_data.get_char(n)
  [self].pack("H*")[n].unpack("C*")[0]
end

masking_key_start_index = 2
payload_start_index = 6
payload_length = tcp_data.get_char(1) & 0b01111111

data = ''
payload_length.times do |i|
  masking_key_index = masking_key_start_index + (i % 4)
  masking_key_char = tcp_data.get_char(masking_key_index)

  masked_char = tcp_data.get_char(payload_start_index + i)

  data << (masking_key_char ^ masked_char)
end

puts data
```

実行すると、

```shell-session
$ ruby parse_packet.rb
ma2saka
```

できました！
エラー処理無し、テキストデータのみ、125バイト以下のデータのみ、別途パケットキャプチャが必要、など課題はありますが一例を出せたのでよしとします。


#後日談
Websocketといえば、socket.ioが有名なモジュールのうちの一つです。
当初、Websocketを動かすためにsocket.io使ってたのですが、データフレームを解析してみると、アンマスクしてもテキストが謎の記号の羅列だったり、**RSV1が1**だったり、結局解読出来ず今回のwebsocketモジュールを使いました。socket.ioでは「意味を定義する拡張がネゴシエーション」されてるのでしょうか。

またsocket.ioでの通信パケットを眺めていると、同じテキストを複数回送信すると、TCPデータ長が短い値に収まっていく傾向にありました。更にデータ量を削減するために、内部で工夫してるのかもしれません。


明日はオフィスで席が右隣の新卒同期 @ktmg に続きます!!