Rubyで簡単なパケットキャプチャを実装してみた

ジョブカン事業部のアドベントカレンダー5日目です！！

https://qiita.com/advent-calendar/2024/jobcan

DONUTSの札幌オフィスでジョブカンの開発インターンとしてお世話になっています。

普段の業務でも使用しているRubyで簡易ネットワークルータを実装しようとしています。
今回は、簡易パケットキャプチャの実装が(ほぼ)できたので紹介してみます。

# パケットキャプチャ概要
## 環境
- OS
    - Ubuntu 22.04.2 LTS on WSL(Windows 10)
- 言語
    - Ruby 3.3.6

## ソースコード
githubに置いてあります(2024年12月現在、絶賛実装中なので後からいろいろ変わるかもしれません)

https://github.com/618knot/ruby-router

## 解析できるプロトコル
- Ethernet
- ARP
- IPv4
- ICMP
- TCP
- UDP

以上のプロトコルに対応しています。IPv6には非対応です。(あとから追加するかも)

## 動作の様子
現状こんな感じでインターフェースを指定してやるとパケットキャプチャが動きます。
```ruby
RubyRouter::Capture.new("eth0").run
```
![example](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2972347/d74dbebc-2f12-a18e-3eab-7f44507ad7ac.gif)

```
I, [2024-11-27T12:17:56.004345 #1964]  INFO -- : ■■■■■ Ether Header ■■■■■
D, [2024-11-27T12:17:56.004477 #1964] DEBUG -- : dst_mac_address=> 00:15:5d:34:93:0e
D, [2024-11-27T12:17:56.004508 #1964] DEBUG -- : src_mac_address=> 00:15:5d:48:a3:47
D, [2024-11-27T12:17:56.004575 #1964] DEBUG -- : type=> IPv4
I, [2024-11-27T12:17:56.004787 #1964]  INFO -- : ■■■■■ IP Header ■■■■■
D, [2024-11-27T12:17:56.005012 #1964] DEBUG -- : Version => 4
D, [2024-11-27T12:17:56.005142 #1964] DEBUG -- : Header Length => 5 (20 Byte)
D, [2024-11-27T12:17:56.005278 #1964] DEBUG -- : Type of Service => 0
D, [2024-11-27T12:17:56.005408 #1964] DEBUG -- : Total Length => 60 Byte
D, [2024-11-27T12:17:56.005541 #1964] DEBUG -- : Identifier => 1170
D, [2024-11-27T12:17:56.005641 #1964] DEBUG -- : Flags => 0b100
D, [2024-11-27T12:17:56.005721 #1964] DEBUG -- : Fragment offset => 0x0
D, [2024-11-27T12:17:56.005794 #1964] DEBUG -- : Time to Live => 64
D, [2024-11-27T12:17:56.005870 #1964] DEBUG -- : Protocol => TCP
D, [2024-11-27T12:17:56.005944 #1964] DEBUG -- : Checksum => 0x6924
D, [2024-11-27T12:17:56.006025 #1964] DEBUG -- : Source Address => 192.168.74.180
D, [2024-11-27T12:17:56.006101 #1964] DEBUG -- : Destination Address => 192.168.1.1
D, [2024-11-27T12:17:56.006176 #1964] DEBUG -- : Option => []
D, [2024-11-27T12:17:56.006244 #1964] DEBUG -- : Valid Checksum ? => true
I, [2024-11-27T12:17:56.006461 #1964]  INFO -- : ■■■■■ TCP Header ■■■■■
D, [2024-11-27T12:17:56.006563 #1964] DEBUG -- : Source Port => 41408
D, [2024-11-27T12:17:56.006588 #1964] DEBUG -- : Destination Port => 80
D, [2024-11-27T12:17:56.006600 #1964] DEBUG -- : Sequence Number => 0x761d6069
D, [2024-11-27T12:17:56.006610 #1964] DEBUG -- : ACK Number => 0x00000000
D, [2024-11-27T12:17:56.006620 #1964] DEBUG -- : Data Offset => 10 (40 Byte)
D, [2024-11-27T12:17:56.006675 #1964] DEBUG -- : Flags => SYN
D, [2024-11-27T12:17:56.006752 #1964] DEBUG -- : WIndow Size => 64240 Byte
D, [2024-11-27T12:17:56.006784 #1964] DEBUG -- : Checksum => 0xcd34
D, [2024-11-27T12:17:56.006801 #1964] DEBUG -- : Emergency Pointer => 0
D, [2024-11-27T12:17:56.006858 #1964] DEBUG -- : --------------------------------------------
```
(インターネットから切断した状態で`nc 192.168.1.1 80`しています)

# 実装の説明
ある程度掻い摘んで説明します。

## ソケット通信部分
```ruby
# frozen_string_literal: true

# @note ref: https://github.com/kuredev/simple_capture/blob/fe1b043774045d677e7aca3321ceb12a40989558/lib/simple_capture/capture.rb

require "socket"
require_relative "analyzer/packet_analyzer"

module PacketCapture
  class Capture
    ETH_P_ALL = 768.freeze # htons(ETH_P_ALL) netinet/if_ethre.h Every packet
    SIOCGIFINDEX = 0x8933.freeze # bits/ioctls.h

    #
    # @param [String] interface interface_name
    #
    def initialize(interface)
      @interface = interface
    end

    def run
      socket = generate_raw_socekt
      bind_interface(socket, @interface)
      capture_loop(socket)
    end

    private

    #
    # Socketの生成
    #
    # @param [String] interface interface_name
    #
    # @return [Socket] socket raw_socket
    #
    def generate_raw_socekt
      Socket.new(Socket::AF_PACKET, Socket::SOCK_RAW, ETH_P_ALL)
    end

    
    #
    # interfaceのバインド
    # @note ref: https://github.com/prolaag/socket2/blob/master/lib/socket2.rb
    #       ref: https://github.com/kuredev/simple_capture/blob/fe1b043774045d677e7aca3321ceb12a40989558/lib/simple_capture/capture.rb#L35
    #
    # @param [<Type>] socket
    # @param [<Type>] interface interface_name
    #
    def bind_interface(socket, interface)
      interface_idx = interface_idx_str(socket, interface)
      eth_p_all_hbo = [ ETH_P_ALL ].pack("S").unpack('S>').first # ホストバイトオーダーでパックしたものをビッグエンディアンに変換して整数にする
      sockaddr_ll = [ Socket::AF_PACKET, eth_p_all_hbo, interface_idx ].pack("SS>a16") # [ホストバイトオーダー, ネットワークバイトオーダー, 16Byte固定長文字列]

      socket.bind(sockaddr_ll)
    end

    #
    # interfaceのindexを文字列で返す
    #
    # @param [String] interface interface_name
    #
    # @return [String] interface index
    #
    def interface_idx_str(socket, interface)
      ifreq = [interface, ""].pack("a16a16") # 16 Byte string * 2
      socket.ioctl(SIOCGIFINDEX, ifreq) # get ifreq struct
      ifreq.slice!(16, 4)
    end

    #
    # caputureのmain loop
    #
    # @param [Socket] socket
    #
    def capture_loop(socket)
      logger = CustomLogger.new
      logger.info("PacketCapture is running")
      loop do
        # @note https://www.cloudflare.com/ja-jp/learning/network-layer/what-is-mtu/
        msg, _ = socket.recvfrom(1514) # MTU + MAC_ADDRESS * 2 + TYPE = 1514
        PacketAnalyzer.new(msg.bytes).analyze
      end
    end
  end
end
```

ソケット通信部分はこのようになっています。コメントも書いているので細かい説明は省略します。

### socketライブラリ
Rubyでは[socketライブラリ](https://docs.ruby-lang.org/ja/latest/library/socket.html)が提供されています。[汎用ソケットクラス](https://docs.ruby-lang.org/ja/latest/class/Socket.html)を使用して、`Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)`をすることで`sys/socket.h`の`socket()`と同等のことができるようです。Socketクラスは`IO#ioctl`も使用できるので、今回は`ioctl(SIOCGIFINDEX, ifreq)`でinterface indexを取得しています。

:::note info
TCPのソケットなども準備されていました。
> - TCP のクライアントソケット Socket.tcp TCPSocket.open
> - TCP のサーバソケット Socket.tcp_server_loop, Socket.tcp_server_sockets, TCPServer.open
> - UNIX socket のクライアントソケット Socket.unix UNIXSocket.open
> - UNIX socket のサーバソケット Socket.unix_server_loop, Socket.unix_server_socket, UNIXServer.open
:::

### データの受け取り
```ruby
    def capture_loop(socket)
      logger = CustomLogger.new
      logger.info("PacketCapture is running")
      loop do
        # @note https://www.cloudflare.com/ja-jp/learning/network-layer/what-is-mtu/
        msg, _ = socket.recvfrom(1514) # MTU + MAC_ADDRESS * 2 + TYPE = 1514
        PacketAnalyzer.new(msg.bytes).analyze
      end
    end
```

`Socket#recvfrom`でデータを受け取ります。(いまのところ`Socket#recv`で十分)
msgにはstringが入るので`String#bytes`でIntegerのArrayに直したうえで、`PacketAnalyzer`に渡して解析してもらいます。

## パケットの解析
パケットの解析はすべて`PacketAnalyzer`以下で行います。
```ruby
class PacketAnalyzer < BaseAnalyzer
  def analyze
    ether_header = HeaderAnalyzer::Ether.new(@msg_bytes.clone)
    ether_header.analyze

    @msg_bytes.slice!(...14)

    case ether_header.int_hex_type
    when Constants::EtherTypes::ARP
      HeaderAnalyzer::Arp.new(@msg_bytes.clone).analyze
    when Constants::EtherTypes::IP
      ip = HeaderAnalyzer::Ip.new(@msg_bytes.clone)
      ip.analyze

      @msg_bytes.slice!((ip.ihl * 4)..)

      case ip.protocol        
      when "ICMP"
        HeaderAnalyzer::Icmp.new(@msg_bytes.clone).analyze
      when "TCP"
        HeaderAnalyzer::Tcp.new(@msg_bytes.clone).analyze
      when "UDP"
        HeaderAnalyzer::Udp.new(@msg_bytes.clone).analyze
      else
      end

    else
      return
    end

    @logger.debug("--------------------------------------------")
  end
end
```
見ての通り構造はそれなりにシンプルです。(2重case文だけちょっと良くない)
IPパケットだった場合は、プロトコルに応じて別のAnalyzerが呼ばれます。

### BaseAnalyzer
Analyzer系は基本的に`BaseAnalyzer`もしくは`BaseAnalyzer`を継承した別のクラスを継承しています。

```ruby
class BaseAnalyzer

  #
  # @param [Array] msg_bytes
  #
  def initialize(msg_bytes)
    @msg_bytes = msg_bytes
    @logger ||= CustomLogger.new
  end

  def analyze
    # 子クラスでoverrideする
  end

  protected

  #
  # Int Arrayを16進数文字列にする
  #
  # @param [Array] array
  #
  # @return [String]
  #
  def to_hex_string(array, is_formated: false)
    str = is_formated ? "0x" : ""
    array.map{ |e| str << e.to_s(16).rjust(2, "0") }
    str
  end

  #
  # Int Arrayを16進数値(10進数)に直す
  #
  # @param [Array] array
  #
  # @return [Integer]
  #
  def to_hex_int(array)
    str = ""
    array.map{ |e| str << e.to_s(16).rjust(2, "0") }
    str.to_i(16)
  end

  #
  # MACアドレスを整形済み文字列にする
  #
  # @param [Array] mac_addr MACアドレスのbyte array
  #
  # @return [String] 整形済みMACアドレス
  #
  def macaddr_to_s(mac_addr)
    mac_addr.map { |addr| addr.to_s(16).rjust(2, "0") }.join(":")
  end

  #
  # 配列に格納されたメッセージをデバッガに出力する
  #
  # @param [Array] msg 出力する文字列を持つ配列
  #
  def out_msg_array(msg)
    msg.map { |m| @logger.debug(m) }
  end

  #
  # checksumを計算する
  #
  # @param [Array] data header
  #
  # @return [Array] bytes
  #
  def checksum(data)
    sum = 0

    data.each_slice(2) do |b|
      sum += (b.first << 8) + b.last
    end

    while sum > 0xffff
      sum = (sum & 0xffff) + (sum >> 16)
    end

    ~sum & 0xffff
  end

  def valid_checksum?(c)
    c == 0 || c == 0xffff
  end
end
```
解析に必要そうなものをいろいろここで定義しています。

```ruby
class Header < BaseAnalyzer
end
```
念のためヘッダ解析用に`Header`クラスを用意していますが、今のところ`BaseAnalyzer`をただ継承しているだけです。
`Header`クラス(BaseAnalyzer)を用意していたおかげで、新たに別のプロトコルの解析がしたい場合でもそこそこ楽に進めることができました。

### IPパケットの解析
一例としてIPパケット解析のコードを見てみます。

```ruby
module HeaderAnalyzer
  class Ip < Header
    attr_reader(
      :version,
      :ihl,
      :tos,
      :tot_len,
      :id,
      :frag_off,
      :ttl,
      :protocol,
      :check,
      :saddr,
      :daddr,
      :option
      )

    def analyze
      @version = @msg_bytes.slice(0)[4..7]                     # IPv4 version:        4bit
      @ihl = @msg_bytes.slice(0)[0..3]                         # Header length:       4bit
      @tos = @msg_bytes.slice(1)                               # Type of Service:     1Byte
      @tot_len = @msg_bytes.slice(2..3)                        # Total Length:        2Byte
      @id = @msg_bytes.slice(4..5)                             # Identifier:          2Byte
      @frag_off = @msg_bytes.slice(6..7)                       # Fragment Offset:     2Byte
      @ttl = @msg_bytes.slice(8)                               # Time to Live:        1Byte
      @protocol = @msg_bytes.slice(9)                          # Protocol:            1Byte
      @check = @msg_bytes.slice(10..11)                        # Checksum:            2Byte
      @saddr = @msg_bytes.slice(12..15)                        # Source Address:      4Byte
      @daddr = @msg_bytes.slice(16..19)                        # Destination Address: 4Byte
      @option = @ihl > 5 ? @msg_bytes.slice(20..@ihl * 4) : [] # Option

      @tot_len = self.to_hex_int(@tot_len)
      @id = self.to_hex_int(@id)
      @frag_off = self.to_hex_int(@frag_off)
      @protocol = Constants::Ip::PROTO[@protocol]
      @check = self.to_hex_string(@check, is_formated: true)

      print_ip
    end

    private
    
    def print_ip
      @logger.info("■■■■■ IP Header ■■■■■")

      msg = [
        "Version => #{@version}",
        "Header Length => #{@ihl} (#{@ihl * 4} Byte)",
        "Type of Service => #{@tos}",
        "Total Length => #{@tot_len} Byte",
        "Identifier => #{@id}",
        "Flags => 0b#{(@frag_off & 0xe000).to_s(2).slice(0..2).rjust(3, "0")}",
        "Fragment offset => 0x#{(@frag_off & 0x1fff).to_s(16)}",
        "Time to Live => #{@ttl}",
        "Protocol => #{@protocol}",
        "Checksum => #{@check}",
        "Source Address => #{@saddr.join(".")}",
        "Destination Address => #{@daddr.join(".")}",
        "Option => #{@option}",
        "Valid Checksum ? => #{ip_checksum}"
      ]

      out_msg_array(msg)
    end

    def ip_checksum
      c = checksum(@msg_bytes.slice(...(@ihl * 4)))
      valid_checksum?(c)
    end
  end
end
```
基本的にオクテット単位で情報を取れればよいので、フレームフォーマットに合わせて`slice`しておけばよいです。`@version`や`@ihl`のように4bit単位などで情報をとる必要がある場合は、[`Integer#[]`](https://docs.ruby-lang.org/ja/latest/class/Integer.html#I_--5B--5D)をうまく使えば楽に値をとってこれます。
表示については、用意しておいたメソッドなどで少し手を加えて表示しているだけです。

# まとめ
ソケット通信部分さえできてしまえば、後はフレームフォーマットに従うだけなので単純作業でした。ソースコードを貼りつけてばかりの記事になってしまいましたが、Rubyのネットワークプログラミング系の情報をあまり見かけないので、何かの役に立てば良いなと思います。
また、ネットワークルータを完成させることができたときは別途記事にしたいです。

# 参考
Rubyでルータを自作するにあたって、「ルーター自作でわかるパケットの流れ～ソースコードで体感するネットワークのしくみ」という書籍を主に参考にしています。

https://gihyo.jp/book/2011/978-4-7741-4745-1

また、Rubyでのパケットキャプチャ実装については前例があったので、こちらも参考にさせていただきました。

https://github.com/kuredev/simple_capture

さらに、Rubyでのソケットの扱い方については以下も参考にさせていただいています。

https://github.com/prolaag/socket2/blob/master/lib/socket2.rb

# さいごに
DONUTSでは新卒中途問わず積極的に採用活動を行っています。
札幌での新卒やインターンの募集もあるので、ご興味あればぜひご覧ください :bow:

https://recruit.jobcan.jp/donuts/job_offers/2051685?hide_breadcrumb=false&hide_search=false

https://recruit.jobcan.jp/donuts/job_offers/1435812?hide_breadcrumb=false&hide_search=false