ジョブカン事業部のアドベントカレンダー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
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.h
のsocket()
と同等のことができるようです。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では新卒中途問わず積極的に採用活動を行っています。
札幌での新卒やインターンの募集もあるので、ご興味あればぜひご覧ください