Erstellen eines grundlegenden TCP-Protokoll-Parsers in Go von Grund auf
Min-jun Kim
Dev Intern · Leapcell

Einführung in die TCP-Protokoll-Analyse in Go
Das Verstehen und Interagieren mit Netzwerkprotokollen ist eine grundlegende Fähigkeit für jeden Softwareingenieur. Unter diesen ist TCP (Transmission Control Protocol) ein Eckpfeiler des Internets und ermöglicht eine zuverlässige, geordnete und fehlergeprüfte Zustellung von Datenströmen zwischen Anwendungen. Während Go's net
-Paket High-Level-APIs für die Netzwerkkommunikation bietet, gibt es Szenarien, in denen die Zerlegung von TCP-Paketen auf einer niedrigeren Ebene entscheidend wird. Dies kann für Sicherheitsanalysen, das Debugging von Netzwerkproblemen, die Implementierung benutzerdefinierter Netzwerk-Proxies oder einfach zum tieferen Verständnis des Datenflusses über das Netzwerk erfolgen. Dieser Artikel führt Sie durch den Prozess des Erstellens eines grundlegenden TCP-Protokoll-Parsers von Grund auf in Go, mit dem Sie die rohen Bytes von TCP-Segmenten untersuchen können.
Wesentliche Konzepte für die TCP-Analyse
Bevor wir uns mit dem Code befassen, definieren wir kurz einige Kernkonzepte im Zusammenhang mit TCP und der Analyse von Netzwerkpaketen, die für unsere Diskussion relevant sind:
- Ethernet-Frame: Die unterste Ebene unserer Reise. Datenpakete in einem lokalen Netzwerk sind in Ethernet-Frames gekapselt. Diese Frames enthalten Quell- und Ziel-MAC-Adressen sowie ein Typfeld, das das nächste Protokoll angibt (z. B. IPv4).
- IP-Paket (Internet Protocol): Eingekapselt in einem Ethernet-Frame, kümmert sich ein IP-Paket um das Routing über Netzwerke hinweg. Es enthält Quell- und Ziel-IP-Adressen sowie ein Protokollfeld, das die nächste Ebene angibt (z. B. TCP).
- TCP-Segment (Transmission Control Protocol): Unser Hauptaugenmerk. Ein TCP-Segment ist in einem IP-Paket gekapselt. Es bietet eine zuverlässige, verbindungsorientierte Datenübertragung. Wichtige Felder sind:
- Quell-Port / Ziel-Port: Identifizieren die sendende und empfangende Anwendung.
- Sequenznummer: Verfolgt die Reihenfolge der Bytes im Datenstrom.
- Bestätigungsnummer: Bestätigt den Empfang von Daten vom anderen Ende.
- Daten-Offset (Header-Länge): Gibt die Länge des TCP-Headers in 32-Bit-Wörtern an.
- Flags: Steuerbits wie SYN (Synchronize), ACK (Acknowledge), FIN (Finish), PSH (Push Daten), RST (Reset), URG (Urgent Pointer significant).
- Fenstergröße: Gibt die Datenmenge an, die der Empfänger zu akzeptieren bereit ist.
- Checksumme: Zur Fehlererkennung.
- Urgent Pointer: Gibt dringend benötigte Daten an.
- Optionen: Optionale Felder, die den Header erweitern können.
- Payload: Die eigentlichen Anwendungsdaten.
- Endinaness: Die Reihenfolge, in der Bytes in mehrByte-Datentypen gespeichert werden (z. B. Big-Endian vs. Little-Endian). Netzwerkprotokolle verwenden typischerweise Big-Endian (Netzwerk-Byte-Reihenfolge).
- Byte-Puffer: Eine gängige Methode zum Lesen und Schreiben von Bytes, die oft zum Parsen von Binärdaten verwendet wird. Go's
bytes.Buffer
und dasencoding/binary
-Paket sind hier von unschätzbarem Wert.
Erstellen eines einfachen TCP-Parsers
Unser Ziel ist es, einen eingehenden Byte-Strom, der ein rohes TCP-Segment darstellt, zu parsen und seine wichtigsten Header-Felder zu extrahieren. Wir simulieren der Einfachheit halber den Empfang eines rohen TCP-Segments, obwohl Sie in einem realen Szenario diese typischerweise mit Bibliotheken wie gopacket
erfassen würden.
Definieren wir zunächst eine Struktur, die die analysierten TCP-Header-Informationen speichert:
package main import ( "bytes" "encoding/binary" "fmt" "io" "net" ) // TCPHeader repräsentiert die Struktur eines TCP-Headers type TCPHeader struct { SourcePort uint16 DestinationPort uint16 SequenceNumber uint32 Acknowledgement uint32 DataOffset uint8 // Oberste 4 Bits des 8-Bit-Feldes multipliziert mit 4 ergibt die Header-Länge Flags uint8 // Unterste 6 Bits des 8-Bit-Feldes, kombiniert mit 2 Bits aus dem DataOffset-Feld WindowSize uint16 Checksum uint16 UrgentPointer uint16 // Optionen und Payload folgen } // ParseTCPHeader nimmt einen Byte-Slice, der ein TCP-Segment darstellt, und versucht, seinen Header zu parsen. func ParseTCPHeader(data []byte) (*TCPHeader, []byte, error) { if len(data) < 20 { // Mindestlänge des TCP-Headers beträgt 20 Bytes return nil, nil, fmt.Errorf("tcp-segment zu kurz, erwartet mindestens 20 Bytes, erhalten %d", len(data)) } reader := bytes.NewReader(data) header := &TCPHeader{} // Quell-Port (2 Bytes) if err := binary.Read(reader, binary.BigEndian, &header.SourcePort); err != nil { return nil, nil, fmt.Errorf("fehler beim Lesen des Quell-Ports: %w", err) } // Ziel-Port (2 Bytes) if err := binary.Read(reader, binary.BigEndian, &header.DestinationPort); err != nil { return nil, nil, fmt.Errorf("fehler beim Lesen des Ziel-Ports: %w", err) } // Sequenznummer (4 Bytes) if err := binary.Read(reader, binary.BigEndian, &header.SequenceNumber); err != nil { return nil, nil, fmt.Errorf("fehler beim Lesen der Sequenznummer: %w", err) } // Bestätigungsnummer (4 Bytes) if err := binary.Read(reader, binary.BigEndian, &header.Acknowledgement); err != nil { return nil, nil, fmt.Errorf("fehler beim Lesen der Bestätigungsnummer: %w", err) } // Daten-Offset (4 Bits) und Flags (6 Bits) // Diese sind in einem einzelnen Byte zusammen mit weiteren Bits gepackt. var offsetFlags uint16 if err := binary.Read(reader, binary.BigEndian, &offsetFlags); err != nil { return nil, nil, fmt.Errorf("fehler beim Lesen von Daten-Offset und Flags: %w", err) } header.DataOffset = uint8((offsetFlags >> 12) * 4) // Holt die oberen 4 Bits und multipliziert mit 4 für die Header-Länge in Bytes header.Flags = uint8(offsetFlags & 0x1FF) // Holt die unteren 9 Bits (einschließlich reservierter Bits) // Fenstergröße (2 Bytes) if err := binary.Read(reader, binary.BigEndian, &header.WindowSize); err != nil { return nil, nil, fmt.Errorf("fehler beim Lesen der Fenstergröße: %w", err) } // Checksumme (2 Bytes) if err := binary.Read(reader, binary.BigEndian, &header.Checksum); err != nil { return nil, nil, fmt.Errorf("fehler beim Lesen der Checksumme: %w", err) } // Urgent Pointer (2 Bytes) if err := binary.Read(reader, binary.BigEndian, &header.UrgentPointer); err != nil { return nil, nil, fmt.Errorf("fehler beim Lesen des Urgent Pointers: %w", err) } // Berechne die Header-Länge und extrahiere die Payload headerLength := int(header.DataOffset) if tcpHeaderLength > len(data) { return nil, nil, fmt.Errorf("Daten-Offset (%d) zeigt einen Header an, der länger als die Segmentlänge (%d) ist", tcpHeaderLength, len(data)) } payload := data[tcpHeaderLength:] return header, payload, nil }
Erklärung der Header-Parsing-Logik
TCPHeader
-Struktur: Wir definierenTCPHeader
, um die Struktur eines TCP-Segment-Headers zu spiegeln, und verwendenuint16
unduint32
für entsprechend dimensionierte Felder.DataOffset
wird in 32-Bit-Wörtern angegeben, daher müssen wir es mit 4 multiplizieren, um die Länge in Bytes zu erhalten.ParseTCPHeader
-Funktion:- Sie nimmt einen
[]byte
als Eingabe entgegen, der das rohe TCP-Segment repräsentiert. - Mindestlängenprüfung: Ein TCP-Header ist mindestens 20 Bytes lang. Wir prüfen dies, um Out-of-Bounds-Fehler zu vermeiden.
bytes.NewReader
: Dies erstellt einenio.Reader
aus unserem Byte-Slice, was das einfache Lesen von Daten fester Größe mitbinary.Read
erleichtert.binary.Read
: Diese entscheidende Funktion liest Binärdaten aus demio.Reader
und füllt unsere Strukturfelder.binary.BigEndian
stellt sicher, dass wir die Bytes in Netzwerk-Byte-Reihenfolge interpretieren.- Daten-Offset und Flags: Dies ist ein kniffliger Teil. Der Daten-Offset (4 Bits) und 6 TCP-Flags sind zusammen mit 6 reservierten Bits gepackt. Die ersten 4 Bits bilden den
DataOffset
. Die letzten 6 Bits sind Flags. Die VariableoffsetFlags
liest 2 Bytes (16 Bits), wobei derDataOffset
die oberen 4 Bits sind und die Flags sich innerhalb der unteren 9 Bits befinden. Wir maskieren und verschieben, um sie korrekt zu extrahieren. - Payload-Extraktion: Sobald der Header geparast wurde, verwenden wir
header.DataOffset
(das wir bereits in Bytes umgerechnet haben), um das ursprüngliche Byte-Array zu slicen und die verbleibendePayload
zu erhalten.
- Sie nimmt einen
Simulieren eines TCP-Segments und Verwendung
Lassen Sie uns eine main
-Funktion erstellen, um die Verwendung unseres Parsers zu demonstrieren. Wir werden ein einfaches TCP-Segment von Hand erstellen, um es zu veranschaulichen.
func main() { // Ein Beispiel-TCP-Segment (20 Bytes Header + 7 Bytes Payload "HELLO\r\n") // Dies ist ein SYN-ACK-Paket, das oft nach einem SYN eines Clients gesehen wird // Quell-Port: 12345 // Ziel-Port: 80 // Seq Num: 0x12345678 // Ack Num: 0x98765432 // Data Offset: 5 (20 Bytes) // Flags: SYN (0x02), ACK (0x10) -> (0x02 | 0x10) = 0x12 // Fenstergröße: 0xFFFF (65535) // Checksumme: 0xAAAA (Platzhalter für dieses Beispiel) // Urgent Pointer: 0x0000 // Payload: "HELLO\r\n" rawTCPSegment := []byte{ 0x30, 0x39, // Quell-Port: 12345 (0x3039) 0x00, 0x50, // Ziel-Port: 80 (0x0050) 0x12, 0x34, 0x56, 0x78, // Sequenznummer 0x98, 0x76, 0x54, 0x32, // Bestätigungsnummer 0x50, 0x12, // Daten-Offset (5*4 = 20 Bytes), Flags (SYN, ACK) -> 0x5012 wobei 0x5 der Daten-Offset und 0x012 die Flags sind 0xFF, 0xFF, // Fenstergröße 0xAA, 0xAA, // Checksumme 0x00, 0x00, // Urgent Pointer // Payload beginnt hier 'H', 'E', 'L', 'L', 'O', '\r', '\n', } header, payload, err := ParseTCPHeader(rawTCPSegment) if err != nil { fmt.Printf("Fehler beim Parsen des TCP-Headers: %v\n", err) return } fmt.Println("-- TCP Header --") fmt.Printf("Quell-Port: %d\n", header.SourcePort) fmt.Printf("Ziel-Port: %d\n", header.DestinationPort) fmt.Printf("Sequenznummer: 0x%X\n", header.SequenceNumber) fmt.Printf("Bestätigungsnummer: 0x%X\n", header.Acknowledgement) fmt.Printf("Header-Länge (Bytes): %d\n", header.DataOffset) fmt.Printf("Flags: 0x%X\n", header.Flags) // Spezifische Flags dekodieren 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("Fenstergröße: %d\n", header.WindowSize) fmt.Printf("Checksumme: 0x%X\n", header.Checksum) fmt.Printf("Urgent Pointer: %d\n", header.UrgentPointer) fmt.Printf("Payload (%d Bytes): %s\n", len(payload), string(payload)) // Beispiel mit einem anderen Daten-Offset (mit Optionen) // Nehmen wir an, Optionen fügen 4 Bytes hinzu, dann wird der Daten-Offset zu 6 (24 Bytes) rawTCPSegmentWithOptions := []byte{ 0xC0, 0x01, // Quell-Port: 49153 0x00, 0x50, // Ziel-Port: 80 0x00, 0x00, 0x00, 0x01, // Sequenznummer 0x00, 0x00, 0x00, 0x01, // Bestätigungsnummer 0x60, 0x12, // Daten-Offset (6*4 = 24 Bytes), Flags (SYN, ACK) 0x04, 0x00, // Fenstergröße 0x00, 0x00, // Checksumme 0x00, 0x00, // Urgent Pointer 0x01, 0x01, 0x08, 0x0A, // Beispiel TCP-Option (NOP, NOP, Timestamps) 'A', 'B', 'C', } fmt.Println("\n-- TCP Header mit Optionen --") headerWithOptions, payloadWithOptions, err := ParseTCPHeader(rawTCPSegmentWithOptions) if err != nil { fmt.Printf("Fehler beim Parsen des TCP-Headers mit Optionen: %v\n", err) return } fmt.Printf("Header-Länge (Bytes): %d\n", headerWithOptions.DataOffset) fmt.Printf("Flags: 0x%X\n", headerWithOptions.Flags) fmt.Printf("Payload (%d Bytes): %s\n", len(payloadWithOptions), string(payloadWithOptions)) }
Wenn Sie diese main
-Funktion ausführen, sehen Sie die extrahierten TCP-Header-Felder und die Payload, was zeigt, dass unser Parser den rohen Byte-Strom korrekt interpretieren kann.
Anwendungen und weitere Verbesserungen
Dieser grundlegende Parser ist ein Ausgangspunkt. Hier sind einige Möglichkeiten, wie er erweitert werden und seine realen Anwendungen:
- Vollständige Paketzerlegung: Integrieren Sie diesen TCP-Parser mit einem IP-Parser und einem Ethernet-Parser, um einen vollständigen Netzwerk-Paket-Dissektor zu bilden. Bibliotheken wie
gopacket
tun dies bereits effizient. - Paketerfassung und -analyse: Verwenden Sie dies mit
pcap
-Bindings (z. B.github.com/google/gopacket/pcap
), um Live-Netzwerkverkehr zu erfassen und TCP-Segmente zur Fehlerbehebung, Sicherheitsüberwachung oder Leistungsanalyse zu analysieren. - Benutzerdefinierte Proxys/Firewalls: Implementieren Sie Regeln basierend auf TCP-Portnummern, Flags oder sogar Payload-Inhalten für benutzerdefinierte Netzwerkfilterung oder -weiterleitung.
- Zustandsorientierte Protokollanalyse: Verfolgen Sie TCP-Verbindungszustände (SYN, SYN-ACK, ACK, FIN) mithilfe der Flags, um den Verbindungslebenszyklus zu verstehen.
- Fehlerprüfung: Implementieren Sie die TCP-Checksummenprüfung, um die Datenintegrität zu gewährleisten, obwohl dies komplexer ist, da es einen Pseudo-Header beinhaltet.
Fazit
Das Erstellen eines TCP-Protokoll-Parsers von Grund auf in Go, auch eines grundlegenden, ist eine ausgezeichnete Übung, um zu verstehen, wie Netzwerkprotokolle auf Byte-Ebene funktionieren. Sie entmystifiziert die Struktur von TCP-Segmenten und vermittelt eine grundlegende Fähigkeit für verschiedene Netzwerkaufgaben. Obwohl höherstufige Bibliotheken diese Details oft abstrahieren, befähigt Sie das Verständnis roher Bytes, komplexe Netzwerkprobleme zu diagnostizieren und hochspezialisierte Netzwerkanwendungen zu erstellen. Dieser einfache Parser demonstriert die Leistungsfähigkeit der Go-Standardbibliothek im Umgang mit Binärdaten und bietet einen Sprungbrett für fortgeschrittenere Netzwerkprogrammierungsbemühungen.