libpcap으로 TCP 패킷 스니퍼 직접 만들어보기
Whitehat School 네트워크 보안 과제 로 C 언어로 PCAP API를 활용해 TCP 패킷을 캡처하고, 이더넷/IP/TCP 헤더와 페이로드를 파싱하는 프로그램을 구현했다.
전체 코드: https://github.com/ye11oc4t/PCAP-Programming-Homework
왜 이걸 직접 구현하는가?
Wireshark를 쓰면 GUI로 패킷을 쉽게 볼 수 있다. 그런데 보안 엔지니어 입장에서는 "패킷이 어떻게 파싱되는가" 를 직접 이해하는 것이 중요하다.
실제 침해 대응이나 네트워크 포렌식 상황에서는 GUI 없이 raw 데이터를 다뤄야 할 때가 있고, PCAP 기반 도구들(Snort, Zeek, tcpdump 등)이 내부적으로 어떤 방식으로 동작하는지 알아야 커스텀 룰이나 파서를 작성할 수 있다. 따라서 libpcap을 이용해 코드레벨에서 PCAP를 구현하기로 했다.
환경 세팅
sudo apt install libpcap-dev
WSL2 환경(Ubuntu)에서 진행했다. 컴파일 시에는 -lpcap 링킹 옵션이 필요하다.
gcc ye11oc4t_improved.c -o ye11oc4t_improved -lpcap
핵심 구조: 3계층 헤더 구조체 정의
패킷 캡처의 핵심은 raw bytes를 각 프로토콜 계층의 구조체에 캐스팅(casting)하는 것이다. ye11oc4t_header.h에 Ethernet → IP → TCP 순서로 3개의 구조체를 정의했다.
1. Ethernet Header
struct ethheader {
u_char ether_dhost[6]; // destination MAC (6바이트)
u_char ether_shost[6]; // source MAC (6바이트)
u_short ether_type; // 상위 프로토콜 타입
};
ether_type이 0x0800이면 IPv4 패킷이다. 이 값을 체크해서 IP가 아닌 패킷(ARP 등)은 무시할 수 있다.
2. IP Header
struct ipheader {
unsigned char iph_ihl:4, iph_ver:4; // 헤더 길이, IP 버전
unsigned char iph_tos;
unsigned short int iph_len; // 전체 패킷 길이
unsigned short int iph_ident;
unsigned short int iph_flag:3, iph_offset:13;
unsigned char iph_ttl;
unsigned char iph_protocol; // 6=TCP, 17=UDP
unsigned short int iph_chksum;
struct in_addr iph_sourceip;
struct in_addr iph_destip;
};
iph_ihl은 4비트 비트필드로, IP 헤더 길이를 32비트(4바이트) 단위로 나타낸다. 실제 바이트 수는 iph_ihl * 4로 계산한다. 보통 옵션이 없으면 iph_ihl = 5 → 헤더 크기 20바이트.
iph_protocol이 IPPROTO_TCP(6)인지 확인해서 TCP가 아닌 패킷(UDP, ICMP 등)은 콜백 함수 내에서 early return한다.
3. TCP Header
struct tcpheader {
u_short tcp_sport; // 출발지 포트
u_short tcp_dport; // 목적지 포트
u_int tcp_seq; // 시퀀스 번호
u_int tcp_ack; // ACK 번호
u_char tcp_offx2; // 데이터 오프셋(상위 4비트) + reserved(하위 4비트)
u_char tcp_flags; // SYN, ACK, FIN 등
u_short tcp_win; // 윈도우 크기
u_short tcp_sum; // 체크섬
u_short tcp_urp; // 긴급 포인터
};
TCP 헤더 길이는 tcp_offx2의 상위 4비트를 추출하는 TH_OFF(tcp) 매크로를 써서 TH_OFF(tcp) * 4로 구한다.
패킷 파싱 로직: got_packet() 콜백
libpcap은 패킷이 캡처될 때마다 등록한 콜백 함수를 호출한다. 핵심 로직은 다음과 같다.
void got_packet(u_char *args, const struct pcap_pkthdr *header, const u_char *packet) {
// 1. 이더넷 헤더 추출
struct ethheader *eth = (struct ethheader *)packet;
if (ntohs(eth->ether_type) != 0x0800) return; // IPv4 아니면 무시
// 2. IP 헤더 추출 (이더넷 헤더 크기만큼 오프셋)
struct ipheader *ip = (struct ipheader *)(packet + sizeof(struct ethheader));
if (ip->iph_protocol != IPPROTO_TCP) return; // TCP 아니면 무시
// 3. TCP 헤더 추출 (IP 헤더 길이는 가변이므로 직접 계산)
int ip_header_len = ip->iph_ihl * 4;
struct tcpheader *tcp = (struct tcpheader *)((u_char *)ip + ip_header_len);
// 4. 페이로드 추출
int tcp_header_len = TH_OFF(tcp) * 4;
const u_char *payload = (u_char *)tcp + tcp_header_len;
int payload_len = ntohs(ip->iph_len) - ip_header_len - tcp_header_len;
// 5. 출력
print_mac(eth->ether_shost); // Source MAC
print_mac(eth->ether_dhost); // Dest MAC
printf("From: %s\n", inet_ntoa(ip->iph_sourceip));
printf("To : %s\n", inet_ntoa(ip->iph_destip));
printf("Src Port: %d\n", ntohs(tcp->tcp_sport));
printf("Dst Port: %d\n", ntohs(tcp->tcp_dport));
// ...
}
포인터 오프셋 계산이 핵심이다. 패킷 전체는 하나의 연속된 메모리 공간이고, 각 헤더는 그 위에 순서대로 쌓여있다. (u_char *)로 캐스팅해서 바이트 단위로 이동하고, 각 헤더 구조체로 다시 캐스팅하는 방식이다.
실행 결과 및 관찰한 것들
테스트 1: ping (ICMP) — 캡처 안 됨
ping 127.0.0.1
iph_protocol != IPPROTO_TCP이면 return하도록 구현했기 때문에 ping(ICMP, protocol=1)은 캡처되지 않는다. TCP 필터가 정상 동작한다는 검증이기도 하다.
테스트 2: curl localhost:8000 (loopback)
python3 -m http.server 8000
curl http://localhost:8000
결과 출력:
Ethernet Header:
Source MAC: 00:00:00:00:00:00
Dest MAC : 00:00:00:00:00:00
IP Header:
From: 127.0.0.1
To : 127.0.0.1
TCP Header:
Src Port: 40232
Dst Port: 8000
Payload (78 bytes):
GET / HTTP/1.1..
loopback 인터페이스(lo)의 특성 — 실제 이더넷 물리 레이어를 거치지 않기 때문에 MAC 주소가 00:00:00:00:00:00으로 표기된다. ip link show로 확인해보면 lo는 <LOOPBACK,UP,LOWER_UP>으로, 실제 NIC가 아닌 커널 내부 루프백 장치이다.
페이로드에서 GET / HTTP/1.1이 그대로 보이는 것을 확인할 수 있다. HTTP는 평문이기 때문에 libpcap 레벨에서 애플리케이션 데이터까지 완전히 노출된다. HTTPS를 써야 하는 이유를 코드 레벨에서 직접 확인한 셈이다.
테스트 3: curl naver.com (외부 요청)
pcap_open_live의 인터페이스를 "lo" → "eth0"으로 변경하고 외부 요청을 캡처했다.
Ethernet Header:
Source MAC: 00:15:5D:55:81:07
Dest MAC : 00:15:5D:47:AA:3D
IP Header:
From: 172.28.189.113
To : 223.130.200.236
TCP Header:
Src Port: 54834
Dst Port: 443
No Payload
실제 MAC 주소가 출력된다. Dst Port가 443(HTTPS)이기 때문에 페이로드는 TLS로 암호화되어 있어 No Payload로 표시된다.
느낀 점
1. 네트워크 감청(sniffing)은 생각보다 쉽다
네트워크 감청(sniffing)을 직접 실습해보면서 느낀 점은, 해당 기술이 생각보다 높은 난이도를 요구하지 않는다는 것이다.
특히 libpcap 기반의 패킷 캡처는 root 권한만 확보된다면 동일 네트워크 세그먼트 내 트래픽을 수집할 수 있는 수준까지는 비교적 쉽게 도달할 수 있었다.
이는 보안 관점에서 중요한 의미를 가진다. 공격자가 반드시 고도화된 익스플로잇을 사용하지 않더라도, 단순히 내부 네트워크 접근 권한과 시스템 권한만 확보하면 트래픽 가시성을 획득할 수 있다는 점을 보여주기 때문이다.
다만 현대 네트워크 환경에서는 스위치 기반 구조가 일반적이기 때문에, 모든 트래픽이 브로드캐스트되는 허브 환경과는 다르게 추가적인 기법이 필요하다. 대표적으로 ARP 스푸핑을 통해 트래픽을 우회시키거나, 포트 미러링 환경을 구성해야 한다. 그러나 이러한 차이는 구현 방식의 차이일 뿐, 패킷을 가로채고 분석한다는 본질적인 메커니즘은 동일하다.
특히 인상적이었던 부분은 HTTP 트래픽을 분석했을 때였다. 암호화가 적용되지 않은 HTTP의 경우, 요청과 응답의 페이로드가 그대로 노출되며, 경우에 따라 인증 정보나 민감 데이터까지 평문으로 확인할 수 있었다. 이를 통해 네트워크 구간에서는 필수적으로 전송 계층 암호화(HTTPS)를 하여 패킷을 가로채더라도 의미를 알 수 없게 해야겠다는 다짐을 하게 되었다.
2.프로토콜 필터링의 중요성
패킷 분석을 직접 구현해보면 가장 먼저 부딪히는 문제는 “모든 트래픽을 처리할 수 없다”는 현실이다.
이론적으로는 전체 패킷을 수집하고 분석하는 것이 이상적이지만, 실제 환경에서는 트래픽 양 자체가 방대하기 때문에 처리량(Throughput)과 지연(Latency) 문제가 즉시 발생한다.
따라서 SIEM이나 IDS를 설계할 때는 초기 단계부터 관심 있는 트래픽만 선별적으로 처리하는 전략이 필요하다. 예를 들어 웹 공격 탐지가 목적이라면 HTTP/HTTPS 관련 트래픽에 집중하고, 불필요한 프로토콜은 과감히 제외해야 한다.
이번 실습 코드에서 ICMP 패킷을 early return으로 필터링한 방식은 이러한 접근의 가장 단순한 형태다.
그러나 사용자 공간(User Space)에서 필터링하는 방식은 이미 커널에서 패킷을 복사해온 이후이기 때문에 비효율적이다.
이 문제를 해결하는 것이 BPF(Berkeley Packet Filter)다.pcap_compile과 pcap_setfilter를 사용하면 필터를 커널 레벨에서 적용할 수 있으며, 이는 다음과 같은 장점을 가진다:
- 불필요한 패킷을 user space로 전달하지 않음
- CPU 및 메모리 사용량 감소
- 고속 트래픽 환경에서도 안정적인 처리 가능
물론! 최근에는 eBPF 취약점을 이용한 침투를 많이 하기도 하지만... BPF를 사용하지 않으면 더 많은 침해사고가 일어날 것이다.
3. 헤더 파싱 취약점
같은 논지에서 지금 구현한 패킷 파싱 로직 또한 이 자체로 공격 대상이 될 수 있다.일반적인 구현에서는 IP 헤더와 TCP 헤더를 순차적으로 파싱하고, 필요한 필드만 추출하는 수준에서 끝나지만, 실제 공격자는 이 단순한 구조를 충분히 악용하고도 남기 때문이다.
대표적인 기법은 다음과 같다:
IP Fragment Overlap: IP 조각(fragment)을 겹치게 구성하여 서로 다른 해석을 유도
- TCP Segment Overlap: 세그먼트를 중첩시켜 IDS와 실제 호스트 간의 데이터 해석 불일치 유도
이러한 공격의 핵심은 “파서 간 해석 차이(Parser Ambiguity)”를 이용하는 것이다.
즉, 보안 장비는 정상으로 판단하지만 실제 시스템에서는 악성 코드가 실행되도록 만드는 방식이다. 현재 실습 코드 수준에서는 단일 패킷을 기준으로 단순 파싱만 수행하지만, 실제 프로덕션 환경에서는 다음과 같은 요소가 반드시 필요하다:
- IP Fragment 재조합 (Reassembly)
- TCP Stream 재구성
- 상태 기반(Stateful) 분석
이 과정이 제대로 구현되지 않으면, 공격자는 탐지 로직을 우회할 수 있다.
마치며
직접 구현해보니 Wireshark가 얼마나 복잡한 레이어 위에 있는지 실감했다. 패킷 하나를 파싱하는 데 포인터 산술, 네트워크 바이트 오더 변환(ntohs), 비트 마스킹이 전부 필요하다.
다음 단계로는 BPF 필터 적용, TCP 스트림 재조합, 혹은 특정 포트 트래픽만 파일로 저장하는 기능을 추가해볼 예정이다.