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): 우리 여정의 가장 낮은 계층입니다. 로컬 네트워크의 데이터 패킷은 이더넷 프레임으로 캡슐화됩니다. 이러한 프레임에는 소스 및 대상 MAC 주소와 다음 프로토콜(예: IPv4)을 나타내는 타입 필드가 포함됩니다.
- IP 패킷 (Internet Protocol): 이더넷 프레임 내에 캡슐화된 IP 패킷은 네트워크 전반에 걸친 라우팅을 처리합니다. 여기에는 소스 및 대상 IP 주소와 다음 계층(예: TCP)을 나타내는 프로토콜 필드가 포함됩니다.
- TCP 세그먼트 (Transmission Control Protocol): 우리의 주요 초점입니다. TCP 세그먼트는 IP 패킷 내에 캡슐화됩니다. 안정적이고 연결 지향적인 데이터 전송을 제공합니다. 주요 필드는 다음과 같습니다.
- 소스 포트 / 대상 포트: 송신 및 수신 애플리케이션을 식별합니다.
- 시퀀스 번호: 데이터 스트림의 바이트 순서를 추적합니다.
- 승인 번호: 상대방으로부터 데이터 수신을 확인합니다.
- 데이터 오프셋 (헤더 길이): 32비트 워드 단위의 TCP 헤더 길이를 지정합니다.
- 플래그: SYN(동기화), ACK(승인), FIN(종료), PSH(데이터 푸시), RST(재설정), URG(긴급 포인터 중요)와 같은 제어 비트입니다.
- 윈도우 크기: 수신자가 수락할 의향이 있는 데이터 양을 나타냅니다.
- 체크섬: 오류 감지를 위해 사용됩니다.
- 긴급 포인터: 긴급 데이터를 나타냅니다.
- 옵션: 헤더를 확장할 수 있는 선택적 필드입니다.
- 페이로드: 실제 애플리케이션 데이터입니다.
- 엔디안 (Endianness): 멀티바이트 데이터 유형에서 바이트가 저장되는 순서(예: 빅엔디안 대 리틀엔디안)입니다. 네트워크 프로토콜은 일반적으로 빅엔디안(네트워크 바이트 순서)을 사용합니다.
- 바이트 버퍼 (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비트는 헤더 길이를 바이트 단위로 나타냅니다. Flags uint8 // 8비트 필드의 하위 6비트는 DataOffset 필드의 2비트와 결합됩니다. WindowSize uint16 Checksum uint16 UrgentPointer uint16 // 옵션 및 페이로드가 뒤따릅니다. } // 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{} // 소스 포트 (2바이트) if err := binary.Read(reader, binary.BigEndian, &header.SourcePort); err != nil { return nil, nil, fmt.Errorf("failed to read source port: %w", err) } // 대상 포트 (2바이트) if err := binary.Read(reader, binary.BigEndian, &header.DestinationPort); err != nil { return nil, nil, fmt.Errorf("failed to read destination port: %w", err) } // 시퀀스 번호 (4바이트) if err := binary.Read(reader, binary.BigEndian, &header.SequenceNumber); err != nil { return nil, nil, fmt.Errorf("failed to read sequence number: %w", err) } // 승인 번호 (4바이트) if err := binary.Read(reader, binary.BigEndian, &header.Acknowledgement); err != nil { return nil, nil, fmt.Errorf("failed to read acknowledgment number: %w", err) } // 데이터 오프셋 (4비트) 및 플래그 (6비트) // 이들은 단일 바이트에 패킹되고, 플래그는 다른 바이트에 들어갑니다. 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비트(예비 비트 포함)를 가져옵니다. // 윈도우 크기 (2바이트) if err := binary.Read(reader, binary.BigEndian, &header.WindowSize); err != nil { return nil, nil, fmt.Errorf("failed to read window size: %w", err) } // 체크섬 (2바이트) if err := binary.Read(reader, binary.BigEndian, &header.Checksum); err != nil { return nil, nil, fmt.Errorf("failed to read checksum: %w", err) } // 긴급 포인터 (2바이트) if err := binary.Read(reader, binary.BigEndian, &header.UrgentPointer); err != nil { return nil, nil, fmt.Errorf("failed to read urgent pointer: %w", err) } // 헤더 길이 계산 및 페이로드 추출 header.DataOffset = uint8(header.DataOffset / 4 * 4) // DataOffset은 32비트 워드 단위이므로 4를 곱하여 바이트 단위로 변환합니다. headerLength := int(header.DataOffset) if headerLength > len(data) { return nil, nil, fmt.Errorf("data offset (%d) indicates a header longer than segment length (%d)", headerLength, len(data)) } payload := data[headerLength:] return header, payload, nil }
헤더 파싱 로직 설명
TCPHeader
구조체: TCP 세그먼트 헤더의 구조를 반복하는TCPHeader
를 정의합니다. 적절한 크기의 필드에uint16
및uint32
를 사용합니다.DataOffset
은 32비트 워드 단위이므로 바이트 단위 길이를 얻으려면 4를 곱해야 합니다.ParseTCPHeader
함수:- 입력으로
[]byte
를 받으며, 이는 원시 TCP 세그먼트를 나타냅니다. - 최소 길이 확인: TCP 헤더는 최소 20바이트입니다. 범위 초과 오류를 방지하기 위해 이를 확인합니다.
bytes.NewReader
: 바이트 슬라이스에서io.Reader
를 생성하여binary.Read
를 사용하여 고정 크기 데이터를 쉽게 읽을 수 있습니다.binary.Read
: 이 중요한 함수는io.Reader
에서 바이너리 데이터를 읽고 구조체 필드를 채웁니다.binary.BigEndian
은 바이트를 네트워크 바이트 순서로 해석하도록 합니다.- 데이터 오프셋 및 플래그: 이것은 까다로운 부분입니다. 데이터 오프셋(4비트)과 6개의 TCP 플래그가 6개의 예약 비트와 함께 패킹됩니다. 첫 4비트는
DataOffset
을 구성합니다. 마지막 6비트는 플래그입니다.offsetFlags
변수는DataOffset
이 상위 4비트이고 플래그가 하위 9비트 내에 있는 2바이트(16비트)를 읽습니다. 마스킹하고 이동하여 올바르게 추출합니다. - 페이로드 추출: 헤더가 파싱되면
header.DataOffset
(이미 바이트로 변환됨)을 사용하여 원본 바이트 배열을 슬라이싱하여 나머지payload
를 얻습니다.
- 입력으로
TCP 세그먼트 시뮬레이션 및 사용법
파서 사용법을 시연하기 위해 main
함수를 만들어 보겠습니다. 설명을 위해 간단한 TCP 세그먼트를 수작업으로 만들 것입니다.
func main() { // 샘플 TCP 세그먼트 (20바이트 헤더 + 7바이트 페이로드 "HELLO\r\n") // 이것은 클라이언트의 SYN 후 자주 보이는 SYN-ACK 패킷입니다. // 소스 포트: 12345 // 대상 포트: 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, // 소스 포트: 12345 (0x3039) 0x00, 0x50, // 대상 포트: 80 (0x0050) 0x12, 0x34, 0x56, 0x78, // 시퀀스 번호 0x98, 0x76, 0x54, 0x32, // 승인 번호 0x50, 0x12, // 데이터 오프셋 (5*4 = 20바이트), 플래그 (SYN, ACK) -> 0x5012 (0x5가 데이터 오프셋이고 0x012가 플래그) 0xFF, 0xFF, // 윈도우 크기 0xAA, 0xAA, // 체크섬 0x00, 0x00, // 긴급 포인터 // 페이로드가 여기서 시작됩니다. '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바이트를 추가한다고 가정하면 데이터 오프셋은 6(24바이트)이 됩니다. rawTCPSegmentWithOptions := []byte{ 0xC0, 0x01, // 소스 포트: 49153 0x00, 0x50, // 대상 포트: 80 0x00, 0x00, 0x00, 0x01, // 시퀀스 번호 0x00, 0x00, 0x00, 0x01, // 승인 번호 0x60, 0x12, // 데이터 오프셋 (6*4 = 24바이트), 플래그 (SYN, ACK) 0x04, 0x00, // 윈도우 크기 0x00, 0x00, // 체크섬 0x00, 0x00, // 긴급 포인터 0x01, 0x01, 0x08, 0x0A, // 샘플 TCP 옵션 (NOP, NOP, 타임스탬프) '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 파서 및 이더넷 파서와 통합하여 완전한 네트워크 패킷 디섹터를 구성합니다.
gopacket
과 같은 라이브러리는 이미 효율적으로 이를 수행합니다. - 패킷 캡처 및 분석:
pcap
바인딩(예:github.com/google/gopacket/pcap
)과 함께 사용하여 라이브 네트워크 트래픽을 캡처하고 TCP 세그먼트를 분석하여 디버깅, 보안 모니터링 또는 성능 통찰력을 얻습니다. - 사용자 정의 프록시/방화벽: TCP 포트 번호, 플래그 또는 페이로드 콘텐츠를 기반으로 규칙을 구현하여 사용자 정의 네트워크 필터링 또는 라우팅을 수행합니다.
- 상태 저장 프로토콜 분석: 플래그를 사용하여 TCP 연결 상태(SYN, SYN-ACK, ACK, FIN)를 추적하여 연결 수명 주기를 이해합니다.
- 오류 확인: TCP 체크섬을 확인하여 데이터 무결성을 구현합니다. 그러나 이는 의사 헤더를 포함하기 때문에 더 복잡합니다.
결론
Go에서 TCP 프로토콜 파서를 처음부터 구축하는 것은 바이트 수준에서 네트워크 프로토콜이 어떻게 작동하는지 이해하는 데 매우 좋은 연습입니다. TCP 세그먼트 구조를 단순화하고 다양한 네트워킹 작업에 대한 기초 기술을 제공합니다. 높은 수준의 라이브러리가 이러한 세부 사항을 추상화하는 경우가 많지만, 원시 바이트를 해석하는 방법을 알면 복잡한 네트워크 문제를 진단하고 매우 특화된 네트워킹 애플리케이션을 구축할 수 있습니다. 이 간단한 파서는 바이너리 데이터를 처리하는 Go의 표준 라이브러리 기능을 보여주고 고급 네트워크 프로그래밍에 대한 발판을 제공합니다.