LoginSignup
26
25

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-12-12

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

チャットアプリからのチャットを他端末から送れたりしないかなー、からの流れでチャットの仕組みが気になり、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 (Node.jsモジュール)
  • ruby 2.0.0p645

クライアント側

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>

サーバー側

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);
  });
});

サーバーを立ち上げ、

$ node app.js

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

image

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

パケット取得

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

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

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

image

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

TCPデータ取得

dump結果はそのままでは読めない(バイナリ)、各通信プロトコルを解読していくのは面倒、とにかくTCPデータ本体だけ見たい、ということでWiresharkの力を借ります。

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

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

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

バイトごとの取り出し

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

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 の 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を見てください。

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

(tcp_data.get_char(0) & 0b10000000) >> 7
=> 1

今回1。

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

(tcp_data.get_char(0) & 0b01110000) >> 4
=> 0

全て0になっています。

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

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

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

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

(tcp_data.get_char(1) & 0b10000000) >> 7
=> 1

クライアントから送信してるため1。

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

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すると、アンマスクすることができます。

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

実行すると、

$ ruby parse_packet.rb
ma2saka

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

後日談

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

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

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

26
25
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
25