2021年度版、TSharkを使ってリアルタイムパケットモニタリング

2013年に以下の記事を書いた。この時はJSONではなく、XMLフォーマットを用いてリアルタイムパケットモニタリングを実現したが、最近では jsonが隆盛してきているため、jsonフォーマットでのリアルタイムパケットモニタリングを行いたい。また、Rubyのバージョンも 3.0 になり、Thread を使うより Ractor を使うほうがナウというもの。

qiita.com

JSON版パケットモニタリングコードサンプル

require 'open3'
require 'json'

def main
  cmd = [
    %q(C:\Program Files\Wireshark\tshark.exe),
    '-i', '5',
    '-Y', 'tcp',
    '-T', 'json'
  ]

  r1 = Ractor.new(cmd) do |command|
    _, stdout, stderr, _ = Open3.popen3(*command)
    puts stderr.gets
    json_str = ''
    while line = stdout.gets
      json_str += line
      next unless line =~ /^  },$/

      json_str = json_str&.chomp&.delete_suffix(',')&.delete_prefix('[')&.lstrip
      json_data = JSON.parse(json_str)

      process_json(json_data)

      json_str = ''
    end
  end

  r2 = Ractor.new do
    gets
  end

  Ractor.select(r1, r2)
end

def process_json(json_data)
  payload = json_data['_source']['layers']['tcp']['tcp.payload']
  p payload&.split(':')&.size.to_i
end

main

コード解説

main() メソッドが メイン部分、tsharkからJSONデータを受け取り、パースし、process_json() メソッド に jsonデータを渡すを繰り返す。 process_json() メソッドが jsonデータを加工して処理する部分

コマンド生成

5行目~10行目は tshark.exe のコマンドを作成している部分。

cmd = [
  %q(C:\Program Files\Wireshark\tshark.exe),
  '-i', '5',
  '-Y', 'tcp',
  '-T', 'json'
]

-iイーサネットインターフェースの番号を指定するオプション。数字を指定するが、この数字はどのように選べばいいか?というと、tshark.exe -D とするとインターフェースの一覧が表示されるので、その頭の番号を指定するとよい。私の環境では 5番目のインターフェースがインターネットにつながるイーサネットカードであったのでそれを選択している。

-Y は モニタリングしたパケットをフィルタするためのオプション。-f もフィルタするためのオプションであるが、こちらはキャプチャする前にフィルタするためのオプション(キャプチャフィルタ)で -Y はキャプチャした後表示をフィルタするためのオプション(ディスプレイフィルタ)になる。Wiresharkを使っていると馴染みがあるのはディスプレイフィルタとなると思うので、今回は -Y を使用している。 (キャプチャフィルタの構文がわからないわけじゃないんだからね!!)

-T は 出力フォーマットのオプションで、 json を指定すると JSONで出力される。

Ractor

Ractor の技術は今回の tshark のモニタリングの本筋からは逸れるので軽く説明する。(というかちゃんと理解できてない)

Ruby にはもともと Thread という並列処理機構があったが、GILの関係でCPUのコア数をフルに活用できていなかった。その制約を取っ払う目的で作られたのが Ractor だと思っている。Ruby で真の並列処理を行いたいのであれば Ractor を使おう。というのが最近のトレンドな気がする。

以下に Ractor のサンプルコードを示す。

a = "hello"
r1 = Ractor.new(a) { |a_|
  sleep 5
  a_
}

r2 = Ractor.new {
  gets
}

_, msg = Ractor.select(r1, r2)
puts msg

上記の例では、2つの Ractor を生成し、 Ractor.select でどちらかが終了するのを待ち受けている。 Ractor の最後の結果が msg に入る。 5秒何も入力しなかったら "hello" が表示され、5秒以内に何か入力するとその文字列が出力される。 Ractor.new(a) { |a_| ~ } のように new に変数を与えるとそれを Ractor 内部で使用することができる。基本的に Ractor のスコープを超えて変数は参照できないので、引数で渡す必要がある。(そうせずに値を渡す方法もあるようだが、ここでは割愛。)

JSON パース

13行目で tshark を実行し、14行目で tsharkから標準エラー出力に出力されるモニタ情報を一行だけ取得し表示してる。一応これで、tsharkが正常に起動していることを確認できる(かな?)

tsharkから出力されるJSONテキストの形式は 配列がルートとなり、その中にパケット情報がオブジェクトとしてパックされている。以下、パケット例

[
  {
    "_index": "packets-2021-11-04",
    "_type": "doc",
    "_score": null,
    "_source": {
      "layers": {
        "frame": {
          "frame.interface_id": "0",
          "frame.interface_id_tree": {
            "frame.interface_description": "イーサネット"
          },
          "frame.encap_type": "1",
          ...
        },
        "eth": {
          ...
          },
        },
        "ip": {
          "ip.version": "4",
          "ip.hdr_len": "20",
          ...
        },
        "tcp": {
          "tcp.srcport": "55291",
          "tcp.dstport": "443",
          "tcp.port": "55291",
          "tcp.port": "443",
          ...
        },
        "tls": {
          "tls.record": {
            "tls.record.content_type": "23",
            ...
          }
        }
      }
    }
  },
  {
    "_index": "packets-2021-11-04",
    "_type": "doc",
    "_score": null,
    "_source": {
      "layers": {
        "frame": {
        ...
        }
      }
    }
  },
  {
    ...
  }
  ...

抽象化すると [ { パケット1 }, { パケット2 }, { パケット3 }, ... { パケットn } ] という構造になる。tsharkが起動されモニタリングが開始されると [ から出力され、パケット1, パケット2, .. と出力されていき、tsharkがモニタリングを終了するときに ] が出力され JSONデータとして完成する。リアルタイムでモニタリングするには配列ノードは無視して、中の各パケットオブジェクトを逐次取得して処理しなければならない。戦略としては オブジェクトのお尻の } がくるまで gets で取得して頭の無駄な文字をトリミングしてJSONオブジェクトとする方法を考える。それが 16行目 ~ 20行目の部分になる。

    while line = stdout.gets
      json_str += line
      next unless line =~ /^  },$/

      json_str = json_str&.chomp&.delete_suffix(',')&.delete_prefix('[')&.lstrip

16行目の while line = stdout.gets で1行ずつ取得する。

17行目で取得した1行を json_str に結合する json_str が最終的に JSON文字列となり JSON.parse() に渡される

18行目で取得した1行がオブジェクトのお尻かどうかをチェックする。今のところ tshark はパケットオブジェクトのお尻として }, を1行で出力してくれるようなので、これを基準にお尻かどうか判断している。

20行目で取得した json_str を成形する。

  • まず、chomp で無駄な末尾改行を取り除く
  • 次に、delete_suffix(',') で無駄なカンマを取り除く
  • 続いて、delete_prefix('[') で頭の [ を取り除く
  • 最後に、頭の空白を取り除く

以下、成形の具体例となる。

もし、tsharkが起動して一発目のパケットであれば以下のような構造になっているはずである。

[\n
␣␣{\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣},\n

なので、①末尾の改行を取り除き

[\n
␣␣{\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣},

次に、②末尾のカンマを取り除き

[\n
␣␣{\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣}

③ 頭の [ を取り除き

\n
␣␣{\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣}

④ 頭の空白・改行を取り除くことで、正しいJSONデータが取得できる。

{\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣␣␣~なにかデータ~\n
␣␣}

2回目以降のパケットでは③の [ を取り除く部分が不要だがRubyでは無駄に処理が動くだけで入力文字列がそのまま出力されるので問題なく動作する。

JSONデータを処理

21行目で成形したJSONデータをパースした後、process_json() メソッドにそのデータを渡し処理する。

def process_json(json_data)
  payload = json_data['_source']['layers']['tcp']['tcp.payload']
  p payload&.split(':')&.size.to_i
end

今回は tcpペイロードを取得し、そのサイズを出力するようにした。JSONデータのどのフィールドにどのデータがあるかは 一度 JSONデータを出力してみないとわからないので、そのあたりは筋力が必要になるだろう。

JSONデータの調べ方

パケットにどのようなフィールドがあるかを調べるのに、tsharkを動かして、コマンドプロンプトでいちいちJSONを見ていたのでは埒が明かないときもあるだろう。そういう時は Wiresahrkに頼ろう。 Wiresharkの [ファイル(F)] → [エキスパートパケット解析] → [JSONとして] を選択すると取得したパケットをJSONで出力することが可能だ。出力方法には様々なオプションがあるので、ある程度フィルタリングした後出力し、好きなエディタでフィールドをチェックするといいだろう。

f:id:bamch0h:20211104231435p:plain
Wiresharkで取得したパケットをJSONで出力

参考資料

techlife.cookpad.com

www.wireshark.org