Goで基本的なTCPプロトコルパーサーをスクラッチから構築する
Min-jun Kim
Dev Intern · Leapcell

GoにおけるTCPプロトコル解析の紹介
ネットワークプロトコルを理解し、それらと対話することは、あらゆるソフトウェアエンジニアにとっての基本的なスキルです。これらのプロトコルの中でも、TCP(Transmission Control Protocol)はインターネットの基盤であり、アプリケーション間のデータストリームの信頼性、順序性、およびエラーチェックされた配信を可能にします。Goのnet
パッケージはネットワーク通信のための高レベルAPIを提供しますが、TCPパケットをより低レベルで解析する必要があるシナリオも存在します。これには、セキュリティ分析、ネットワーク問題のデバッグ、カスタムネットワークプロキシの実装、または単にデータがネットワークをどのように流れるかについての深い理解を得ることが含まれます。この記事では、Goで基本的なTCPプロトコルパーサーをゼロから構築するプロセスをガイドし、TCPセグメントの生バイトを覗き見ることができるようにします。
TCP解析の必須概念
コードに飛び込む前に、TCPとネットワークパケット解析に関連するいくつかのコアコンセプトを簡単に定義しましょう。これらは私たちの議論で関連性があります。
- Ethernet Frame: 私たちの旅の最も低いレイヤー。ローカルネットワーク上のデータパケットはEthernetフレーム内にカプセル化されます。これらのフレームには、送信元および宛先のMACアドレス、および次のプロトコル(例:IPv4)を示すタイプフィールドが含まれます。
- IP Packet (Internet Protocol): Ethernetフレーム内にカプセル化されたIPパケットは、ネットワークを越えたルーティングを処理します。これには送信元および宛先IPアドレス、および次のレイヤー(例:TCP)を示すプロトコルフィールドが含まれます。
- TCP Segment (Transmission Control Protocol): 私たちの主な焦点。TCPセグメントはIPパケット内にカプセル化されます。信頼性の高い、コネクション指向のデータ転送を提供します。主なフィールドは以下の通りです:
- Source Port / Destination Port: 送信および受信アプリケーションを識別します。
- Sequence Number: データストリーム内のバイトの順序を追跡します。
- Acknowledgement Number: 他方の端からのデータの受信を確認します。
- Data Offset (Header Length): 32ビットワードでのTCPヘッダーの長さを指定します。
- Flags: SYN(同期)、ACK(確認)、FIN(終了)、PSH(データプッシュ)、RST(リセット)、URG(緊急ポインタ有効)などの制御ビット。
- Window Size: 受信側が受け入れ可能なデータの量を示します。
- Checksum: エラー検出用。
- Urgent Pointer: 緊急データを示します。
- Options: ヘッダーを拡張できるオプションフィールド。
- Payload: 実際のアプリケーションデータ。
- Endianness: マルチバイトデータ型(例:ビッグエンディアン vs リトルエンディアン)にバイトが格納される順序。ネットワークプロトコルは通常、ビッグエンディアン(ネットワークバイトオーダー)を使用します。
- Byte Buffer: バイナリデータの解析によく使用される、バイトの読み書きの一般的な方法。Goの
bytes.Buffer
とencoding/binary
パッケージがここで非常に役立ちます。
シンプルなTCPパーサーの構築
私たちの目標は、生のTCPセグメントを表すバイトストリームを解析し、その主要なヘッダーフィールドを抽出することです。ここでは簡潔にするために、生のTCPセグメントを受信したと仮定します。ただし、現実の世界では、通常gopacket
のようなライブラリを使用してこれらをキャプチャします。
解析されたTCPヘッダー情報を保持する構造体を定義しましょう。
package main import ( "bytes" "encoding/binary" "fmt" "io" "net" ) // TCPHeaderはTCPヘッダーの構造を表します type TCPHeader struct { SourcePort uint16 DestinationPort uint16 SequenceNumber uint32 Acknowledgement uint32 DataOffset uint8 // 8ビットフィールドの上位4ビット、4を掛けるとヘッダー長になります Flags uint8 // 8ビットフィールドの下位6ビット、DataOffsetフィールドの2ビットと組み合わされます WindowSize uint16 Checksum uint16 UrgentPointer uint16 // Options and Payload follow } // ParseTCPHeaderはTCPセグメントを表すバイトスライスを取り、そのヘッダーを解析しようとします。 func ParseTCPHeader(data []byte) (*TCPHeader, []byte, error) { if len(data) < 20 { // 最小TCPヘッダー長は20バイトです return nil, nil, fmt.Errorf("tcp segment too short, expected at least 20 bytes, got %d", len(data)) } reader := bytes.NewReader(data) header := &TCPHeader{} // Source Port (2 bytes) if err := binary.Read(reader, binary.BigEndian, &header.SourcePort); err != nil { return nil, nil, fmt.Errorf("failed to read source port: %w", err) } // Destination Port (2 bytes) if err := binary.Read(reader, binary.BigEndian, &header.DestinationPort); err != nil { return nil, nil, fmt.Errorf("failed to read destination port: %w", err) } // Sequence Number (4 bytes) if err := binary.Read(reader, binary.BigEndian, &header.SequenceNumber); err != nil { return nil, nil, fmt.Errorf("failed to read sequence number: %w", err) } // Acknowledgment Number (4 bytes) if err := binary.Read(reader, binary.BigEndian, &header.Acknowledgement); err != nil { return nil, nil, fmt.Errorf("failed to read acknowledgment number: %w", err) } // Data Offset (4 bits) and Flags (6 bits) // これらは単一のバイトにパックされ、フラグ用の別のバイトが続きます。 var offsetFlags uint16 if err := binary.Read(reader, binary.BigEndian, &offsetFlags); err != nil { return nil, nil, fmt.Errorf("failed to read data offset and flags: %w", err) } header.DataOffset = uint8((offsetFlags >> 12) * 4) // 上位4ビットを取得し、4を掛けてバイト単位のヘッダー長を取得します header.Flags = uint8(offsetFlags & 0x1FF) // 下位9ビット(予約ビットを含む)を取得します // Window Size (2 bytes) if err := binary.Read(reader, binary.BigEndian, &header.WindowSize); err != nil { return nil, nil, fmt.Errorf("failed to read window size: %w", err) } // Checksum (2 bytes) if err := binary.Read(reader, binary.BigEndian, &header.Checksum); err != nil { return nil, nil, fmt.Errorf("failed to read checksum: %w", err) } // Urgent Pointer (2 bytes) if err := binary.Read(reader, binary.BigEndian, &header.UrgentPointer); err != nil { return nil, nil, fmt.Errorf("failed to read urgent pointer: %w", err) } // ヘッダー長を計算し、ペイロードを抽出します headerLength := int(header.DataOffset) if tcpHeaderLength > len(data) { return nil, nil, fmt.Errorf("data offset (%d) indicates a header longer than segment length (%d)", tcpHeaderLength, len(data)) } payload := data[tcpHeaderLength:] return header, payload, nil }
ヘッダー解析ロジックの説明
TCPHeader
構造体: TCPセグメントヘッダーの構造をミラーリングするためにTCPHeader
を定義します。適切にサイズ設定されたフィールドにはuint16
とuint32
を使用します。DataOffset
は32ビットワードで与えられるため、バイト単位の長さを取得するには4を掛ける必要があります。ParseTCPHeader
関数: - 生のTCPセグメントを表す[]byte
を入力として受け取ります。- 最小長チェック: TCPヘッダーは最低でも20バイトです。境界外エラーを防ぐためにこれをチェックします。
bytes.NewReader
: これはバイトスライスからio.Reader
を作成し、binary.Read
を使用して固定サイズのデータを簡単に読み取ることができます。binary.Read
: この重要な関数は、io.Reader
からバイナリデータを読み取り、構造体フィールドを populatします。binary.BigEndian
は、バイトをネットワークバイトオーダーで解釈していることを保証します。- Data OffsetとFlags: これは難しい部分です。Data Offset(4ビット)と6つのTCPフラグは、6つの予約ビットとともにパックされています。最初の4ビットが
DataOffset
を構成します。最後の6ビットがフラグです。offsetFlags
変数は2バイト(16ビット)を読み取り、そのうちDataOffset
は上位4ビット、フラグはその中の下位9ビットにあります。正しく抽出するためにマスクとシフトを行います。 - Payload抽出: ヘッダーの解析が完了したら、
header.DataOffset
(すでにバイトに変換済み)を使用して元のバイト配列をスライスし、残りのpayload
を取得します。
TCPセグメントのシミュレーションと利用
パーサーの使用法を示すために、main
関数を作成しましょう。例として、単純なTCPセグメントを手動で作成します。
func main() { // サンプルのTCPセグメント(20バイトヘッダー + 7バイトペイロード "HELLO\r\n") // これは、クライアントからのSYNの後にしばしば見られるSYN-ACKパケットです // Source Port: 12345 // Dest Port: 80 // Seq Num: 0x12345678 // Ack Num: 0x98765432 // Data Offset: 5 (20バイト) // Flags: SYN (0x02), ACK (0x10) -> (0x02 | 0x10) = 0x12 // Window Size: 0xFFFF (65535) // Checksum: 0xAAAA (この例ではプレースホルダー) // Urgent Pointer: 0x0000 // Payload: "HELLO\r\n" rawTCPSegment := []byte{ 0x30, 0x39, // Source Port: 12345 (0x3039) 0x00, 0x50, // Dest Port: 80 (0x0050) 0x12, 0x34, 0x56, 0x78, // Sequence Number 0x98, 0x76, 0x54, 0x32, // Acknowledgment Number 0x50, 0x12, // Data Offset (5*4 = 20 bytes), Flags (SYN, ACK) -> 0x5012 ここで0x5はデータオフセット、0x012はフラグです 0xFF, 0xFF, // Window Size 0xAA, 0xAA, // Checksum 0x00, 0x00, // Urgent Pointer // Payload starts here 'H', 'E', 'L', 'L', 'O', '\r', '\n', } header, payload, err := ParseTCPHeader(rawTCPSegment) if err != nil { fmt.Printf("Error parsing TCP header: %v\n", err) return } fmt.Println("---" TCP Header ---) fmt.Printf("Source Port: %d\n", header.SourcePort) fmt.Printf("Destination Port: %d\n", header.DestinationPort) fmt.Printf("Sequence Number: 0x%X\n", header.SequenceNumber) fmt.Printf("Acknowledgement Number: 0x%X\n", header.Acknowledgement) fmt.Printf("Header Length (bytes): %d\n", header.DataOffset) fmt.Printf("Flags: 0x%X\n", header.Flags) // 特定のフラグをデコードします fmt.Printf(" SYN Flag: %t\n", (header.Flags&0x02) != 0) fmt.Printf(" ACK Flag: %t\n", (header.Flags&0x10) != 0) fmt.Printf(" PSH Flag: %t\n", (header.Flags&0x08) != 0) fmt.Printf(" RST Flag: %t\n", (header.Flags&0x04) != 0) fmt.Printf(" FIN Flag: %t\n", (header.Flags&0x01) != 0) fmt.Printf(" URG Flag: %t\n", (header.Flags&0x20) != 0) fmt.Printf("Window Size: %d\n", header.WindowSize) fmt.Printf("Checksum: 0x%X\n", header.Checksum) fmt.Printf("Urgent Pointer: %d\n", header.UrgentPointer) fmt.Printf("Payload (%d bytes): %s\n", len(payload), string(payload)) // データオフセットが異なる場合の例(オプション付き) // オプションが4バイト追加されると仮定すると、Data Offsetは6(24バイト)になります rawTCPSegmentWithOptions := []byte{ 0xC0, 0x01, // Source Port: 49153 0x00, 0x50, // Dest Port: 80 0x00, 0x00, 0x00, 0x01, // Sequence Number 0x00, 0x00, 0x00, 0x01, // Acknowledgment Number 0x60, 0x12, // Data Offset (6*4 = 24 bytes), Flags (SYN, ACK) 0x04, 0x00, // Window Size 0x00, 0x00, // Checksum 0x00, 0x00, // Urgent Pointer 0x01, 0x01, 0x08, 0x0A, // 例のTCP Option (NOP, NOP, Timestamps) 'A', 'B', 'C', } fmt.Println("\n---" TCP Header with Options ---) headerWithOptions, payloadWithOptions, err := ParseTCPHeader(rawTCPSegmentWithOptions) if err != nil { fmt.Printf("Error parsing TCP header with options: %v\n", err) return } fmt.Printf("Header Length (bytes): %d\n", headerWithOptions.DataOffset) fmt.Printf("Flags: 0x%X\n", headerWithOptions.Flags) fmt.Printf("Payload (%d bytes): %s\n", len(payloadWithOptions), string(payloadWithOptions)) }
このmain
関数を実行すると、抽出されたTCPヘッダーフィールドとペイロードが表示され、パーサーが生バイトストリームを正しく解釈できることが実証されます。
アプリケーションとさらなる強化
この基本的なパーサーは出発点です。これを拡張するいくつかの方法と、その実世界のアプリケーションを以下に示します。
- 完全なパケット分析: このTCPパーサーをIPパーサーおよびEthernetパーサーと統合して、完全なネットワークパケット dissectorを形成します。
gopacket
のようなライブラリはすでに効率的にこれを行っています。 - パケットキャプチャと分析:
pcap
バインディング(例:github.com/google/gopacket/pcap
)とともにこれを使用して、ライブネットワークトラフィックをキャプチャし、デバッグ、セキュリティ監視、またはパフォーマンス分析のためにTCPセグメントを分析します。 - カスタムプロキシ/ファイアウォール: TCPポート番号、フラグ、またはペイロードコンテンツに基づいてルールを実装し、カスタムネットワークフィルタリングまたはルーティングを行います。
- ステートフルプロトコル分析: フラグを使用してTCP接続状態(SYN、SYN-ACK、ACK、FIN)を追跡し、接続ライフサイクルを理解します。
- エラーチェック: TCPチェックサム検証を実装してデータの整合性を確保しますが、これには疑似ヘッダーが含まれるため、より複雑です。
結論
GoでTCPプロトコルパーサーをゼロから構築することは、ネットワークプロトコルがバイトレベルでどのように機能するかを理解するための優れた練習です。TCPセグメントの構造をわかりやすくし、さまざまなネットワークタスクのための基本的なスキルを提供します。高レベルのライブラリはこれらの詳細を抽象化することが多いですが、生のバイトを解釈する方法を知っていると、複雑なネットワークの問題を診断し、高度に専門化されたネットワークアプリケーションを構築することができます。このシンプルなパーサーは、Goの標準ライブラリがバイナリデータを処理する能力を示しており、より高度なネットワークプログラミングの取り組みのための足がかりを提供します。