1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ジョブカンAdvent Calendar 2024

Day 5

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

Last updated at Posted at 2024-12-04

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

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

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

パケットキャプチャ概要

環境

  • OS
    • Ubuntu 22.04.2 LTS on WSL(Windows 10)
  • 言語
    • Ruby 3.3.6

ソースコード

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

解析できるプロトコル

  • Ethernet
  • ARP
  • IPv4
  • ICMP
  • TCP
  • UDP

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

動作の様子

現状こんな感じでインターフェースを指定してやるとパケットキャプチャが動きます。

RubyRouter::Capture.new("eth0").run

example

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しています)

実装の説明

ある程度掻い摘んで説明します。

ソケット通信部分

# 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ライブラリが提供されています。汎用ソケットクラスを使用して、Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)をすることでsys/socket.hsocket()と同等のことができるようです。SocketクラスはIO#ioctlも使用できるので、今回はioctl(SIOCGIFINDEX, ifreq)でinterface indexを取得しています。

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

データの受け取り

    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以下で行います。

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を継承した別のクラスを継承しています。

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

解析に必要そうなものをいろいろここで定義しています。

class Header < BaseAnalyzer
end

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

IPパケットの解析

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

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#[]をうまく使えば楽に値をとってこれます。
表示については、用意しておいたメソッドなどで少し手を加えて表示しているだけです。

まとめ

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

参考

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

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

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

さいごに

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

1
0
0

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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?