콘텐츠로 이동

1.7강 — BPF로 필요한 패킷만 고르기

이번 회차의 범위

1.6강에서 tcpdump 명령 끝에 붙인 'host ... and port 80'이 바로 BPF 필터입니다. 이번 회차는 이 필터 언어를 처음부터 다룹니다.

  • BPF가 무엇이고 왜 "가장 앞에서, 가장 싸게" 거르는 수단인가
  • 프로토콜·주소·포트 프리미티브와 src·dst 방향 한정어
  • and·or·not과 괄호, 그리고 흔히 틀리는 연산 우선순위
  • vlan, portrange, gateway, 그리고 바이트·비트 단위 조건
  • IPv6, VLAN, 조각난 패킷에서 필터가 조용히 놓치는 것
  • 큰 pcap을 BPF로 줄이기, 필터를 시험하고 기록하기

실습 자료와 표기

이번 회차의 **(직접 실행 결과)**는 이 작업 환경의 tcpdump 4.99.4(libpcap 1.10.4)로 얻었습니다. 이 환경은 IPv6가 꺼져 있어서 IPv6 트래픽을 실제로 만들 수 없었기 때문에, 필터 연습용 pcap(lab.pcap, 13개 패킷)을 파이썬으로 직접 조립했습니다. 주소는 사설·문서용 대역(10.10.0.0/16, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32)만 썼습니다. 각 패킷의 구성은 2절의 표에 있습니다.

1. 1Gbps 회선에서 한 PC의 트래픽만

(가상 사례) 1.4강 사건 직후, 연구 구간 PC 10.10.6.14에서 의심스러운 통신이 다시 일어나는지 지켜보기로 했습니다. 탭이 붙은 회선은 평균 수백 Mbps가 흐르는 구간이에요. 전부 저장하면 하루 수 TB가 쌓이는데, 필요한 건 그 PC 한 대의 트래픽뿐입니다.

이때 "일단 다 저장하고 나중에 거르자"는 답이 아닙니다. 디스크도 모자라고, 수집 기계의 CPU와 저장 장치가 따라가지 못해 1.6강에서 본 것처럼 정작 필요한 패킷까지 놓칠 수 있어요. 처음부터 필요한 것만 받아야 합니다. 그 역할을 하는 것이 BPF(Berkeley Packet Filter)입니다.

BPF는 패킷이 운영체제 커널에 들어오는 아주 낮은 단계에서 "이 패킷이 관심 대상인가"를 판단합니다. 관심 없는 패킷은 사용자 프로그램(tcpdump 등)으로 올려 보내지도 않으니, 화면에 보이든 디스크에 쓰든 다른 도구로 넘기든 그 뒤의 모든 단계가 가벼워집니다. 그리고 libpcap 기반 도구는 거의 다 BPF를 쓰니, 한 번 익히면 tcpdump, tshark의 수집 필터, Zeek, Arkime 등 어디서나 씁니다.

2. 비유: 공항 검색대의 1차 X선

공항 검색대에서 가방이 X선 기계를 지나가는 장면을 떠올려 보세요. 검색 요원은 화면을 보고 아주 단순한 규칙("액체가 보이면 따로 빼라")으로 가방 하나하나를 빠르게 판단합니다. 통과한 가방만 정밀 검사대로 갑니다.

BPF가 바로 이 1차 X선입니다. 규칙은 단순하지만 아주 빠르고, 모든 패킷에 적용됩니다. 그런데 이 비유에는 BPF의 가장 중요한 성질이 하나 숨어 있어요. 검색 요원은 가방마다 따로 판단합니다. "아까 그 승객의 다른 가방"을 기억하지 않아요. BPF도 똑같습니다. 패킷 하나를 보고 판단한 뒤에는 본 것을 모두 잊고, 다음 패킷을 처음부터 다시 판단합니다. 그래서 BPF로는 "SYN으로 시작한 연결의 나머지 패킷" 같은 연결 단위 조건을 만들 수 없고, 나중에 볼 src host가 왜 대화의 한쪽만 잡는지도 이 성질로 설명됩니다.

이번 회차의 연습용 패킷 13개는 이렇게 생겼습니다.

번호 내용 비고
1~4 10.10.6.14 ↔ 203.0.113.50 80번 HTTP: SYN, SYN-ACK, ACK, GET IPv4
5 10.10.6.14 → 10.10.4.53 UDP 53 DNS 질의 IPv4
6 10.10.6.14 → 10.10.4.53 TCP 53 SYN DNS over TCP
7 10.10.6.14 → 198.51.100.7 ICMP echo ping
8 2001:db8:6::14 → 2001:db8:ff::50 TCP 80 SYN IPv6
9 2001:db8:6::14 → 2001:db8:4::53 UDP 53 IPv6
10 10.10.6.14 → 203.0.113.60 TCP 443 SYN IPv4
11 10.10.6.14 → 224.0.0.251 UDP 5353 멀티캐스트
12 10.10.6.14 → 203.0.113.50 TCP 80 SYN 802.1Q VLAN 20 태그
13 203.0.113.60 → 10.10.6.14 TCP 443 RST+ACK 닫힌 포트의 응답

3. 원리: BPF는 작은 프로그램이다

우리가 쓰는 필터 문장은 사람용이고, 커널은 그것을 아주 작은 기계어 프로그램으로 바꿔서 패킷마다 실행합니다. tcpdump -d로 그 프로그램을 볼 수 있어요(직접 실행 결과).

$ tcpdump -d -y EN10MB 'udp port 53'
(000) ldh      [12]
(001) jeq      #0x86dd          jt 2    jf 8
(002) ldb      [20]
(003) jeq      #0x11            jt 4    jf 19
(004) ldh      [54]
(005) jeq      #0x35            jt 18   jf 6
(006) ldh      [56]
(007) jeq      #0x35            jt 18   jf 19
(008) jeq      #0x800           jt 9    jf 19
(009) ldb      [23]
(010) jeq      #0x11            jt 11   jf 19
(011) ldh      [20]
(012) jset     #0x1fff          jt 19   jf 13
(013) ldxb     4*([14]&0xf)
(014) ldh      [x + 14]
(015) jeq      #0x35            jt 18   jf 16
(016) ldh      [x + 16]
(017) jeq      #0x35            jt 18   jf 19
(018) ret      #262144
(019) ret      #0

-d는 "필터를 적용하지 말고 컴파일한 결과를 보여 달라", -y EN10MB는 "이더넷 회선이라고 가정하라"는 뜻입니다. 처음 보면 암호 같지만, 몇 줄만 읽어 보면 원리가 보입니다.

  • ldh [12]: 패킷의 12번째 바이트부터 2바이트를 읽습니다(load half-word). 이더넷 헤더에서 이 자리는 이더넷 형식(EtherType) 칸이에요.
  • jeq #0x86dd jt 2 jf 8: 그 값이 0x86dd(IPv6)이면 2번 줄로, 아니면 8번 줄로 갑니다(jump if equal, true면 jt, false면 jf).
  • IPv6 쪽(2~7번): ldb [20]은 IPv6 헤더의 "다음 헤더" 칸, 0x11(17)은 UDP, [54]와 [56]은 UDP 출발·도착 포트, 0x35는 53입니다.
  • IPv4 쪽(8~17번): 0x800이면 IPv4, [23]은 IPv4의 프로토콜 칸, jset #0x1fff는 조각(fragment) 오프셋이 0이 아니면 탈락시키는 줄, ldxb 4*([14]&0xf)는 IPv4 헤더 길이를 계산해 그만큼 건너뛰는 줄입니다.
  • ret #262144: 통과(최대 262,144바이트를 넘김). ret #0: 탈락.

이 짧은 프로그램에서 벌써 두 가지 중요한 사실이 보입니다. 첫째, udp port 53은 IPv4와 IPv6를 모두 검사합니다. 둘째, 12번 줄 때문에 IPv4 조각 중 첫 조각이 아닌 것은 포트 조건에 절대 맞지 않습니다. 큰 UDP 패킷이 여러 조각으로 나뉘면 두 번째 조각부터는 UDP 헤더가 없으니 포트를 알 수 없기 때문이에요. 이건 9절에서 다시 봅니다.

4. 프리미티브: 필터의 기본 단위

BPF 문장은 프리미티브라는 작은 조건들로 만듭니다. 패킷마다 각 프리미티브가 참인지 거짓인지 판단해요.

4.1 프로토콜

가장 단순한 것은 프로토콜 이름입니다. ip, ip6, tcp, udp, icmp 등이 있어요. 패킷이 그 프로토콜이면 "관심 대상"으로 다음 판단을 계속하고, 아니면 libpcap은 그 패킷을 더 처리하지 않고 다음 패킷을 기다립니다. 패킷은 원래 가던 길을 그대로 갑니다. BPF는 막는 장치가 아니라 복사본을 고르는 장치니까요.

연습용 파일에 돌려 본 결과입니다(직접 실행 결과).

필터 맞은 패킷 수 해석
(필터 없음) 13 전체
ip 10 IPv4만. IPv6 두 개(8, 9)와 VLAN 태그 패킷(12)은 빠짐
ip6 2 IPv6만(8, 9)
ip or ip6 12 IPv4와 IPv6 모두. 그래도 VLAN 태그 패킷(12)은 빠짐
tcp 8 IPv4 TCP와 IPv6 TCP(8) 모두. VLAN 태그 패킷은 빠짐
udp 3 5, 9, 11
icmp 1 7

여기서 첫 번째 함정이 보입니다. ip는 IPv4만 뜻합니다. "IP 트래픽 전부"를 원했다면 ip or ip6라고 써야 해요. IPv6가 점점 흔해지는 지금, ip만 썼다가 IPv6 통신을 통째로 놓치는 실수는 생각보다 자주 일어납니다. 두 번째 함정(VLAN)은 6.1절에서 봅니다.

4.2 주소와 포트

프리미티브 뜻 예
ether host 2계층 MAC 주소가 출발지나 도착지인 패킷 ether host 00:50:56:0a:04:53
host 3계층 IP 주소가 출발지나 도착지인 패킷 host 10.10.4.53
net IP 주소가 그 네트워크 범위(CIDR)에 드는 패킷 net 203.0.113.0/24
port 4계층 포트가 출발지나 도착지인 패킷(TCP·UDP 구분 안 함) port 53

결과는 이렇습니다(직접 실행 결과).

필터 맞은 수 해석
host 10.10.4.53 2 5, 6(DNS 서버의 IPv4 주소)
net 203.0.113.0/24 6 1~4, 10, 13. VLAN 태그 패킷(12)은 빠짐
ether host 00:50:56:0a:04:53 3 DNS 서버 MAC으로 간 5, 6, 9(IPv6 포함)
port 53 3 UDP 5, TCP 6, IPv6 UDP 9
udp port 53 2 5, 9
tcp port 53 1 6

port는 프로토콜을 따지지 않습니다. port 53은 UDP 53과 TCP 53을 모두 잡아요. DNS는 보통 UDP지만, 응답이 크거나 영역 전송을 할 때는 TCP를 씁니다. DNS를 모두 보고 싶다면 port 53이 맞고, UDP만 원한다면 udp port 53처럼 프로토콜을 앞에 붙입니다.

ether host 결과에 IPv6 패킷(9)이 들어간 것도 눈여겨보세요. MAC 주소는 2계층이라 그 위가 IPv4든 IPv6든 상관없습니다. 같은 장비의 IPv4·IPv6 트래픽을 한꺼번에 잡고 싶을 때 쓸 수 있어요. 다만 MAC 주소는 같은 구간 안에서만 의미가 있습니다. 라우터를 지나면 바뀌니, 탭 위치(1.1강 10절)에 따라 "그 장비의 MAC"이 아니라 "라우터의 MAC"이 찍혀 있을 수 있습니다.

4.3 방향: src와 dst

위의 프리미티브는 모두 방향을 따지지 않습니다. host 10.10.4.53은 그 주소가 출발지이든 도착지이든 맞아요. 방향을 정하려면 방향 한정어 src(출발지)와 dst(도착지)를 앞에 붙입니다(직접 실행 결과).

필터 맞은 수 해석
src host 10.10.6.14 8 그 PC가 보낸 IPv4 패킷만
dst host 10.10.6.14 2 그 PC가 받은 IPv4 패킷만(2, 13)

그 PC가 관련된 대화의 양쪽을 다 보려면 그냥 host 10.10.6.14를 써야 합니다. 2절의 비유를 떠올리세요. BPF는 패킷마다 따로 판단하니, src host는 그 패킷의 출발지 칸만 보고, 그 패킷에 대한 응답이 따로 온다는 사실은 모릅니다.

이상하게도 MAC 주소만 문법이 다릅니다. IP는 src host, src net, src port처럼 한정어가 앞에 오지만, MAC은 ether src, ether dst처럼 ether 뒤에 오고 host가 빠집니다. 양방향은 ether host예요. 외우기 귀찮지만 틀리면 문법 오류가 나니 금방 익숙해집니다.

5. 논리로 조합하기

프리미티브는 단순하지만 and(또는 &&), or(||), not(!)으로 조합해 정교한 조건을 만들 수 있습니다. 괄호로 순서를 정할 수도 있어요.

'tcp and (port 80 or port 443 or port 8080) and (not dst host 203.0.113.1)'

"80, 443, 8080 중 한 포트를 쓰는 TCP 패킷 중, 도착지가 203.0.113.1이 아닌 것"이라는 뜻입니다. 1.6강에서 말했듯 괄호와 공백은 셸의 특수 문자라서, 필터 전체를 따옴표로 감싸는 것이 꼭 필요합니다.

5.1 [주의] and와 or는 우선순위가 같다

여기서 많은 사람이 틀립니다. 수학이나 대부분의 프로그래밍 언어에서는 "그리고"가 "또는"보다 먼저 계산돼요. BPF는 다릅니다. libpcap 문서에 따르면 not이 가장 먼저고, and와 or는 우선순위가 같으며 왼쪽에서 오른쪽으로 묶입니다. 직접 비교해 보겠습니다(직접 실행 결과).

필터 맞은 수
tcp port 80 or udp port 53 and host 10.10.4.53 1
tcp port 80 or (udp port 53 and host 10.10.4.53) 6

괄호 없이 쓴 첫 줄을 "HTTP 전부, 또는 DNS 서버로 간 UDP DNS"라는 뜻으로 썼다면 큰일입니다. BPF는 이것을 왼쪽부터 묶어 (tcp port 80 or udp port 53) and host 10.10.4.53으로 읽기 때문에, DNS 서버가 관련된 패킷 하나(5번)만 남았어요. HTTP 패킷 다섯 개는 모두 빠졌습니다. 수집 필터로 이렇게 썼다면 HTTP는 처음부터 저장되지 않았을 겁니다. 그래서 and와 or를 섞을 때는 늘 괄호로 의도를 명시하세요.

또 하나, libpcap에는 "키워드를 생략하면 바로 앞 키워드를 쓴다"는 규칙이 있습니다. not host 10.10.4.53 and 10.10.4.54는 not host 10.10.4.53 and host 10.10.4.54로 읽혀요. not (host 10.10.4.53 or host 10.10.4.54)와는 전혀 다른 뜻입니다. 줄여 쓰지 말고 프리미티브마다 키워드를 다 적는 게 안전합니다.

5.2 컴퓨터에게 같아도 사람에게는 다르다

다음 두 필터를 보세요.

'ip and (tcp port 80 or udp port 53)'
'(ip and tcp port 80) or (ip and udp port 53)'

패킷 하나가 첫 번째 필터를 지나가는 순서를 그림으로 보면 이렇습니다.

flowchart TD
    P["패킷 하나 도착"] --> A{"IPv4인가?"}
    A -->|"아니오"| R["버림"]
    A -->|"예"| B{"TCP 80인가?"}
    B -->|"예"| K["통과(저장·표시)"]
    B -->|"아니오"| C{"UDP 53인가?"}
    C -->|"예"| K
    C -->|"아니오"| R

그림 읽는 법: 마름모가 하나의 판단이고, 위에서 아래로 차례로 묻습니다. 한 판단에서 결론이 나면(통과 또는 버림) 그 패킷의 평가는 끝나고, 다음 패킷은 맨 위부터 다시 시작합니다. 2절의 "가방마다 따로" 판단하는 성질이 이 그림에 그대로 들어 있어요.

뜻은 같습니다. 실제로 둘 다 연습용 파일에서 5개 패킷이 맞았고, tcpdump -d로 컴파일한 결과를 비교하니 21줄이 완전히 같은 프로그램이었습니다(직접 실행 결과).

$ tcpdump -d -y EN10MB 'ip and (tcp port 80 or udp port 53)' > a.bpf
$ tcpdump -d -y EN10MB '(ip and tcp port 80) or (ip and udp port 53)' > b.bpf
$ cmp -s a.bpf b.bpf && echo "IDENTICAL compiled code"
IDENTICAL compiled code

tcpdump 같은 도구는 필터를 실행 전에 최적화하기 때문에, 사람이 어떻게 썼든 같은 뜻이면 같은 프로그램이 되는 경우가 많습니다. 하지만 사람이 읽는 논리의 흐름은 다르죠. 첫 번째는 "IPv4 중에서 HTTP나 DNS", 두 번째는 "IPv4 HTTP, 또는 IPv4 DNS"로 읽힙니다.

교재가 강조하듯 사람이 필터를 이해하고 설명할 수 있느냐는 컴퓨터의 효율만큼, 어쩌면 그보다 더 중요합니다. 필터는 보고서에 적히고, 동료가 다시 쓰고, 법정에서 "이 증거는 어떤 조건으로 모았나"라는 질문의 답이 됩니다. 필터가 복잡할수록 문법 실수도 늘고 설명하기도 어려워지니, 가장 읽기 쉬운 모양을 고르세요.

6. 덜 흔하지만 쓸모 있는 프리미티브

6.1 vlan: 태그가 붙은 트래픽

VLAN(Virtual LAN)은 스위치 하나를 논리적으로 여러 구간으로 나누는 기술이고, 스위치끼리 여러 VLAN을 한 회선으로 나를 때는 이더넷 헤더 안에 4바이트짜리 802.1Q 태그를 끼워 넣습니다. 이런 회선을 트렁크라고 해요. 탭이나 미러링이 트렁크에 붙어 있으면 수집한 패킷에 이 태그가 들어 있습니다.

문제는 태그 4바이트 때문에 그 뒤의 모든 위치가 4바이트씩 밀린다는 점입니다. 3절에서 본 컴파일 결과의 [12], [23] 같은 고정 위치가 맞지 않게 되죠. 그래서 연습용 파일의 VLAN 태그 패킷(12번, VLAN 20의 TCP 80 SYN)은 이렇게 됐습니다(직접 실행 결과).

필터 맞은 수 해석
tcp port 80 5 1~4와 IPv6의 8. VLAN 태그 패킷 12는 빠짐
vlan and tcp port 80 1 12만
vlan 20 1 VLAN 번호 20인 패킷

vlan 키워드를 쓰면 libpcap이 그 뒤의 조건을 태그만큼 밀린 위치로 계산합니다. 그래서 트렁크에서 뜬 파일이라면 vlan and tcp port 80처럼 써야 해요. 태그가 있는 패킷과 없는 패킷이 섞여 있다면 tcp port 80 or (vlan and tcp port 80)처럼 둘 다 적습니다. 순서가 중요해서, vlan을 먼저 쓰고 태그 없는 조건을 뒤에 쓰면 뒤 조건까지 밀린 위치로 계산돼 의도와 달라질 수 있습니다. 교재 표현대로 "까다로울 수 있으니", 트렁크 회선의 필터는 반드시 실제 파일로 시험하세요.

조사 관점의 교훈은 이것입니다. "필터에 아무것도 안 걸렸다"는 결과가 나왔을 때, 수집 지점이 트렁크였는지부터 확인하세요. 태그 때문에 필터가 조용히 빗나갔을 수 있습니다.

6.2 portrange

포트 하나가 아니라 이어진 범위를 잡습니다. portrange 50000-50002는 연습용 파일에서 5개(1~4번의 50000번 포트와 8번의 50002번 포트)가 맞았어요(직접 실행 결과). src portrange, dst portrange처럼 방향 한정어도 붙일 수 있습니다. 동적 포트를 쓰는 서비스나, 특정 범위의 출발 포트를 쓰는 악성코드를 찾을 때 쓸 만합니다.

6.3 gateway

gateway 호스트는 "2계층 MAC 주소는 그 호스트의 것인데, 3계층 IP 주소는 그 호스트의 것이 아닌" 패킷, 즉 그 호스트를 거쳐 간 패킷을 잡습니다. libpcap 문서는 이것이 ether host <MAC> and not host <IP>와 같다고 설명해요. 정상이라면 기본 게이트웨이(라우터)가 이런 역할을 하니, 게이트웨이가 아닌 장비가 이 조건에 맞는다면 누군가 중간에서 트래픽을 가로채는(중간자, MITM) 가짜 게이트웨이를 의심할 수 있습니다.

단, 문서에 따르면 gateway 뒤에는 이름을 써야 하고, 그 이름이 분석 기계에서 IP 주소로도(hosts 파일, DNS 등) MAC 주소로도(/etc/ethers 등) 풀려야 합니다. 실무에서는 같은 뜻의 ether host ... and not host ...를 직접 쓰는 편이 간단하고, 이름 조회로 OPSEC 문제를 만들 일도 없습니다.

6.4 바이트와 비트 단위 조건

가장 암호 같지만 가장 강력한 기능입니다. 형식은 프로토콜[위치:크기]이고, 크기는 1·2·4바이트 중 하나(생략하면 1)입니다. 여기에 비교 연산(=, !=, >, <)과 비트 연산(& 그리고, | 또는)을 쓸 수 있어요.

[정정] 위치는 그 프로토콜 헤더의 시작부터 셉니다. 교재는 "위치는 0부터 시작하고 IP 헤더의 첫 바이트부터 계산한다"고 설명하는데, 그건 ip[...]의 경우입니다. libpcap 문서대로 tcp[...]는 TCP 헤더의 시작부터, ether[...]는 이더넷 헤더의 시작부터 셉니다.

예를 몇 개 보겠습니다(직접 실행 결과).

필터 뜻 맞은 수
ip[9] == 6 IPv4 헤더 9번째 바이트(프로토콜 칸)가 6(TCP) 7
tcp[13] & 0x02 != 0 TCP 헤더 13번째 바이트(플래그)에서 SYN 비트(0x02)가 켜짐 4
tcp[tcpflags] & tcp-syn != 0 위와 같은 뜻을 이름으로 씀 4
tcp[tcpflags] == tcp-syn 플래그가 SYN 하나만 켜짐 3

tcp[13] & 0x02 != 0을 풀어 보면, 13번째 바이트와 0x02(이진수 00000010)를 비트 단위로 "그리고" 해서, 그 결과가 0이 아니면, 즉 SYN 비트가 켜져 있으면 맞는다는 뜻입니다. libpcap은 이런 자리에 tcpflags, tcp-syn, tcp-ack, tcp-rst, tcp-fin 같은 이름을 제공하니, 숫자보다 이름이 읽기 쉽습니다.

마지막 두 줄의 차이가 중요합니다. & tcp-syn != 0은 "SYN이 켜져 있으면(다른 플래그도 켜져 있어도)"이라 SYN과 SYN-ACK(1, 2, 6, 10)을 모두 잡고, == tcp-syn은 "정확히 SYN만"이라 새 연결의 첫 패킷(1, 6, 10)만 잡습니다. "누가 연결을 시작했나"를 보려면 후자가 맞아요.

이제 1.6강에서 본 수수께끼 같은 필터를 풀 수 있습니다.

'tcp[((tcp[12]&0xf0)>>2):4] = 0x47455420'
  • tcp[12]: TCP 헤더의 12번째 바이트. 앞 4비트가 TCP 헤더 길이(4바이트 단위)입니다.
  • & 0xf0: 그 앞 4비트만 남깁니다.
  • >> 2: 오른쪽으로 2비트 밀기. 앞 4비트 값은 원래 16배 된 자리에 있으니 4로 나누면(>>2) "헤더 길이 × 4", 즉 **헤더 길이(바이트)**가 됩니다. 헤더 길이 칸 값이 5면 0x50 → 0x14 = 20바이트예요.
  • tcp[그 값:4]: TCP 헤더 바로 뒤, 즉 데이터의 처음 4바이트.
  • = 0x47455420: 그 4바이트가 G(0x47) E(0x45) T(0x54) 공백(0x20), 즉 GET인가.

연습용 파일에서는 4번 패킷 하나만 맞았습니다. 어떤 포트를 쓰든 HTTP GET 요청을 찾아내니, 1.2강에서 본 "프록시를 우회해 이상한 포트로 나가는 HTTP"를 찾는 데 쓸 수 있어요.

이런 조건은 보기엔 복잡해도 커널 가까이에서 아주 빠르게 실행됩니다. 이것이 관심 있는 트래픽을 고르는 가장 확실한 방법이라면, CPU를 많이 쓰는 뒷단 처리로 넘기기 전에 BPF로 거르는 것이 맞습니다.

7. [주의] 필터가 조용히 놓치는 것

지금까지 본 함정을 모아 봅니다. 어느 것도 오류 메시지를 내지 않고 조용히 덜 잡을 뿐이라 더 위험합니다.

IPv6에서 바이트 조건이 맞지 않습니다. 연습용 파일의 8번은 IPv6 TCP SYN인데, 6.4절의 SYN 필터 어디에도 잡히지 않았습니다. 확인해 보면 이렇습니다(직접 실행 결과).

필터 맞은 수
ip6 and tcp[13] & 0x02 != 0 0
ip6 and ip6[6] == 6 1

libpcap 문서는 지금도(1.10 브랜치와 개발 중인 최신 문서 모두) tcp[...], udp[...] 같은 위쪽 프로토콜의 바이트 접근이 IPv6 패킷에는 맞지 않는다고 적고 있습니다(2026-11-05 확인). IPv6에서는 ip6[...]로 IPv6 헤더부터 직접 세야 해요. ip6[6] == 6은 IPv6 헤더 6번째 바이트(다음 헤더 칸)가 6(TCP)인지 보는 조건입니다. 다만 이것도 확장 헤더가 없을 때만 맞습니다. 확장 헤더가 끼어 있으면 TCP 헤더가 더 뒤로 밀리기 때문이에요. IPv6 트래픽이 많은 환경에서 바이트 단위 필터를 쓴다면, 반드시 실제 파일로 시험하고 한계를 기록해야 합니다. tcp, tcp port 80처럼 이름으로 된 프리미티브는 IPv6도 잘 잡았다는 점(4절)과 구별하세요.

VLAN 태그가 있으면 고정 위치가 빗나갑니다. 6.1절에서 본 대로입니다.

IPv4 조각의 두 번째부터는 포트 조건에 맞지 않습니다. 3절의 컴파일 결과에서 본 jset #0x1fff 때문이에요. 공격자가 일부러 패킷을 잘게 쪼개 보내면 port 필터를 쓴 수집에서 뒷조각이 빠질 수 있습니다. 조각난 트래픽까지 확실히 보려면 주소 조건(host)만으로 수집하고 포트 판단은 나중에 재조립한 뒤에 합니다.

ip는 IPv4만입니다. 4.1절의 함정입니다.

8. 큰 파일 줄이기

tcpdump의 또 다른 중요한 쓰임은 이미 있는 pcap을 BPF로 줄이는 것입니다. 원본은 그대로 두고, 관련 있는 것만 뽑거나 관련 없는 것을 빼서 새 파일을 만들어요(직접 실행 결과).

$ cat reduce.bpf
not port 443
and not net 224.0.0.0/4
and not port 53
$ tcpdump -n -r lab.pcap -F reduce.bpf -w reduced.pcap
reading from file lab.pcap, link-type EN10MB (Ethernet), snapshot length 262144
$ capinfos -c -s lab.pcap reduced.pcap
File name:           lab.pcap
Number of packets:   13
File size:           1078 bytes
File name:           reduced.pcap
Number of packets:   7
File size:           587 bytes

-r로 원본을 읽고, -F로 파일에 적어 둔 필터를 쓰고, -w로 새 파일에 썼습니다. -F는 복잡한 필터를 파일로 관리하게 해 줘서, 팀이 쓰는 필터를 git 저장소에 두고 모두 같은 필터를 쓰게 할 수 있어요. 필터 파일 안에서는 줄을 나눠도 되고, 셸의 따옴표 문제도 없습니다.

이 필터가 뺀 것은 443번(대개 HTTPS라 내용을 볼 수 없음), 224.0.0.0/4(멀티캐스트, 대개 잡음), 53번(DNS, 다른 로그에 이미 있다고 가정)입니다. 13개가 7개로, 1,078바이트가 587바이트로 줄었습니다. 실제 대용량 파일에서도 이런 식으로 크기를 크게 줄일 수 있어요. Wireshark는 큰 pcap을 여는 데 아주 오래 걸리고, 수백 MB를 넘으면 열리지 않을 수도 있어서, 열기 전에 줄이는 것이 특히 중요합니다.

[주의] "다른 곳에 있다"는 가정을 확인하세요. DNS를 뺀 이유가 "DNS 서버 로그에 있으니까"라면, 정말 그 로그가 있고 사건 시간대를 덮는지 확인해야 합니다. 그리고 원본을 고치는 게 아니라 새 파일을 만드는 것이니, 필요하면 언제든 원본으로 돌아가 빠진 것을 다시 볼 수 있습니다. 원본은 반드시 남겨 두세요.

9. 실제 조사에서는 이렇게 해석합니다

수집 필터는 영구적입니다. 수집할 때 건 BPF로 빠진 패킷은 다시는 볼 수 없습니다. 반면 1.9강에서 볼 Wireshark의 디스플레이 필터는 이미 저장된 패킷 중 화면에 보일 것만 고르는 것이라 언제든 되돌릴 수 있어요. 그래서 수집 필터는 보수적으로, 즉 넉넉하게 잡고, 세밀한 선별은 저장한 뒤에 하는 것이 원칙입니다. 1절의 사례라면 host 10.10.6.14만 걸고 포트나 프로토콜로는 거르지 않는 편이 안전합니다. 7절의 함정(조각, VLAN, IPv6)에 덜 걸리니까요.

필터를 시험하고 기록하세요. 필터가 정교할수록 문법 실수의 가능성이 커집니다. 운영에 쓰기 전에 ① 실험실에서 알려진 트래픽으로 맞는 수를 세어 보고, ② tcpdump -d로 컴파일되는지 보고, ③ 실제 부하에서 패킷을 버리지 않는지 확인합니다. 그리고 보고서에는 쓴 필터를 글자 그대로 적습니다. pcapng라면 1.6강에서 본 것처럼 파일 안에도 남습니다.

"필터에 안 걸렸다"를 "없었다"로 읽지 마세요. 1.1강부터 반복한 원칙의 BPF 판입니다. 결과가 0이면 IPv6인지, VLAN 트렁크인지, 조각인지, 우선순위를 잘못 썼는지부터 확인합니다.

10. 정리

  • BPF는 커널 가까이에서 패킷마다 "관심 대상인가"를 판단하는 필터 언어이고, libpcap 기반 도구 거의 모두가 씁니다. 수집 단계에서 걸러 부하와 저장 공간을 줄입니다.
  • BPF는 패킷 하나씩 따로 판단하고 이전 패킷을 기억하지 않습니다. 그래서 src host는 대화의 한쪽만 잡습니다.
  • 프로토콜(ip, ip6, tcp, udp, icmp), 주소(ether host, host, net), port, 방향(src, dst, MAC은 ether src/ether dst)이 기본 프리미티브입니다. ip는 IPv4만, port는 TCP·UDP를 가리지 않습니다.
  • and와 or는 우선순위가 같고 왼쪽부터 묶입니다. 섞을 때는 늘 괄호를 씁니다. 컴퓨터는 같은 뜻을 같은 코드로 최적화하지만, 사람이 읽기 쉬운 모양을 고르는 것이 중요합니다.
  • vlan, portrange, gateway, 바이트·비트 조건(ip[9], tcp[tcpflags])은 강력하지만, 위치는 각 프로토콜 헤더의 시작부터 세고, VLAN 태그·IPv6·IPv4 뒷조각에서 조용히 빗나갈 수 있습니다.
  • 큰 파일은 tcpdump -r 원본 -F 필터파일 -w 새파일로 줄이고, 원본은 남겨 둡니다.
  • 수집 필터는 되돌릴 수 없으니 넉넉하게 잡고, 쓴 필터는 시험하고 그대로 기록합니다.

다음 회차(1.8강)에서는 tcpdump의 실전 옵션(파일 나누기, 회전, 보관 개수)과 Wireshark 화면을 읽는 법을 다룹니다.

복습 문제

문제 1. "그 PC가 주고받은 모든 IP 트래픽"을 수집하려고 ip and host 10.10.6.14라고 썼습니다. 이 PC는 IPv4와 IPv6 주소를 모두 갖고 있습니다. 무엇을 놓치고, 어떻게 고쳐야 하나요?

정답과 해설

ip는 IPv4만 뜻하고, host 10.10.6.14도 IPv4 주소입니다. 그래서 이 PC의 IPv6 트래픽 전부를 놓칩니다. 그 PC의 IPv6 주소(예: 2001:db8:6::14)를 함께 적어 host 10.10.6.14 or host 2001:db8:6::14로 쓰는 게 맞습니다. ip and는 아예 빼도 됩니다. 두 주소가 다 같은 장비라는 점을 이용해 MAC 주소로 ether host 00:50:56:0a:06:14라고 쓸 수도 있지만, MAC은 같은 구간 안에서만 의미가 있어서 탭이 라우터 건너편에 있으면 그 PC의 MAC이 보이지 않습니다. 수집 지점에 맞춰 고릅니다.

문제 2. (우선순위) 다음 필터는 연습용 파일에서 몇 개의 패킷에 맞을까요? 이유와 함께, 작성자가 원했을 법한 뜻으로 고쳐 보세요.

'host 203.0.113.50 or host 203.0.113.60 and tcp port 443'
정답과 해설

BPF는 and와 or를 같은 우선순위로 왼쪽부터 묶으니, 이 필터는 (host 203.0.113.50 or host 203.0.113.60) and tcp port 443입니다. 연습용 파일에서 443번이 관련된 패킷은 10(203.0.113.60으로 SYN)과 13(203.0.113.60에서 RST)뿐이고 둘 다 .60이 관련되니 2개가 맞습니다. 203.0.113.50과의 HTTP 패킷(1~4)은 80번이라 모두 빠집니다. 작성자가 "203.0.113.50과의 모든 트래픽, 그리고 203.0.113.60과의 HTTPS"를 원했다면 host 203.0.113.50 or (host 203.0.113.60 and tcp port 443)으로 써야 합니다. 이렇게 고치면 1~4와 10, 13으로 6개가 맞습니다(VLAN 태그 패킷 12는 여전히 빠짐). 두 필터의 결과 2개와 6개는 연습용 파일에 직접 돌려 확인했습니다.

문제 3. tcp[tcpflags] & tcp-syn != 0과 tcp[tcpflags] == tcp-syn의 차이를 설명하고, "내부에서 바깥으로 새로 연결을 시작한 PC 목록"을 만들 때 어느 쪽이 맞는지 말해 보세요.

정답과 해설

앞의 것은 "SYN 비트가 켜져 있으면, 다른 비트가 함께 켜져 있어도"라서 연결 요청(SYN)과 그 응답(SYN-ACK)을 모두 잡습니다. 뒤의 것은 플래그가 SYN 하나만 켜진 패킷, 즉 연결을 시작하는 첫 패킷만 잡습니다. 연결을 시작한 쪽을 찾으려면 == tcp-syn이 맞아요. 앞의 것을 쓰면 SYN-ACK을 보낸 외부 서버들도 목록에 섞입니다. 여기에 and src net 10.10.0.0/16을 붙이면 "내부 주소가 보낸 첫 SYN"이 됩니다. 그리고 이 조건은 IPv6 패킷에는 맞지 않으니(7절), IPv6를 쓰는 환경이라면 IPv6 쪽은 다른 방법(ip6[...]이나 저장 뒤 Wireshark 필터)으로 따로 봐야 합니다.

문제 4. (흔한 오해) 동료가 트렁크 회선의 탭에서 뜬 pcap에 tcp port 80을 걸었더니 0건이 나왔다며 "이 구간에는 HTTP가 없었다"고 합니다. 어떻게 확인하시겠어요?

정답과 해설

트렁크 회선이면 패킷마다 802.1Q VLAN 태그가 있어서, 태그 4바이트만큼 뒤의 위치가 밀립니다. 그러면 tcp port 80이 태그 있는 패킷을 조용히 놓칩니다(6.1절에서 직접 확인). 먼저 tcpdump -n -e -r 파일 -c 5로 패킷에 vlan 표시가 있는지 보고, 있다면 vlan and tcp port 80이나 tcp port 80 or (vlan and tcp port 80)로 다시 세어 봅니다. 그래도 0이라면 그때 "이 파일 범위에서 80번 HTTP는 보이지 않는다"고 말할 수 있고, 다른 포트의 HTTP는 6.4절의 GET 바이트 조건처럼 포트와 무관한 방법으로 따로 확인합니다.

문제 5. (바이트 계산) TCP 헤더의 12번째 바이트가 0x80입니다. TCP 헤더 길이는 몇 바이트이고, 이 패킷에 6.4절의 GET 필터를 적용하면 데이터의 시작을 어디서 찾나요?

정답과 해설

0x80 & 0xf0 = 0x80, 0x80 >> 2 = 0x20 = 32이니 TCP 헤더 길이는 32바이트입니다. 앞 4비트 값 8에 4를 곱한 것과 같아요(8 × 4 = 32). 기본 헤더 20바이트에 옵션 12바이트(예: 타임스탬프 옵션)가 붙은 흔한 경우입니다. 그래서 필터는 tcp[32:4], 즉 TCP 헤더 시작에서 32바이트 뒤의 4바이트를 0x47455420과 비교합니다. 헤더 길이를 고정값 20으로 가정해 tcp[20:4]라고 썼다면, 옵션이 있는 대부분의 실제 패킷에서 빗나갔을 겁니다. 그래서 길이를 패킷마다 계산하는 식을 쓰는 거예요.

문제 6. (IPv6) IPv6만 쓰는 구간에서 "새 TCP 연결 요청(SYN만 켜진 패킷)"을 BPF로 수집하려 합니다. tcp[tcpflags] == tcp-syn을 쓰면 어떻게 되고, 대안은 무엇인가요?

정답과 해설

libpcap 문서대로 tcp[...] 바이트 접근은 IPv6 패킷에 맞지 않으므로 하나도 수집되지 않습니다(7절에서 IPv6 SYN이 0건이었던 것과 같은 이유). 대안은 IPv6 헤더부터 직접 세는 것입니다. IPv6 기본 헤더는 40바이트이고, 확장 헤더가 없다면 TCP 헤더가 바로 이어지니 TCP 플래그 바이트는 ip6[40+13] = ip6[53]입니다. 그래서 ip6 and ip6[6] == 6 and ip6[53] == 0x02처럼 쓸 수 있어요(ip6[40+13]처럼 계산식으로 써도 됩니다). 연습용 파일에서는 두 형태 모두 IPv6 SYN인 8번 하나에 맞았습니다(직접 실행 결과). ip6[6] == 6은 "다음 헤더가 TCP"라는 확인입니다. 하지만 이 방법은 확장 헤더가 있으면 빗나갑니다. 그러니 이렇게 한계가 있는 수집 필터보다는, ip6 and tcp처럼 넉넉하게 수집한 뒤 Wireshark·tshark의 디스플레이 필터(1.9강)로 SYN만 골라내는 편이 더 안전합니다.

문제 7. (데이터 줄이기) 5GB pcap을 Wireshark로 보기 전에 not port 53 and not port 443으로 줄였더니 800MB가 됐습니다. 보고서에 무엇을 적고, 나중에 "DNS로 C2를 했다"는 가설이 나오면 어떻게 하나요?

정답과 해설

보고서에는 원본 파일과 해시, 줄일 때 쓴 명령과 필터 그대로(tcpdump -n -r original.pcap -w reduced.pcap 'not port 53 and not port 443'), 줄인 파일의 해시와 패킷 수, 그리고 "이 분석은 53번과 443번을 뺀 파일로 했다"는 한계를 적습니다. 나중에 DNS C2 가설이 나오면, 줄인 파일에는 53번이 없으니 원본으로 돌아가 이번에는 port 53으로 뽑아 따로 분석합니다. 원본을 남겨 뒀기 때문에 가능한 일이에요. 줄인 파일만 보고 "DNS 이상 징후 없음"이라고 적었다면, 애초에 볼 수 없는 것을 없다고 말한 셈이 됩니다.

문제 8. (설계) 1절 사례에서 동료가 수집 필터로 host 10.10.6.14 and (tcp port 80 or tcp port 443) and not ip6을 제안합니다. 이 필터의 위험을 세 가지 이상 들고, 더 나은 수집 필터를 제안해 보세요.

정답과 해설
  1. 80·443 밖의 통신을 영구히 놓칩니다. 공격자는 다른 포트(53, 8080, 임의의 높은 포트)로 C2를 할 수 있습니다.
  2. UDP를 놓칩니다. DNS 터널, QUIC(UDP 443) 같은 통신이 빠집니다.
  3. IPv6를 명시적으로 뺐습니다. 그 PC에 IPv6 주소가 있다면 그 통신이 전부 빠지고, 애초에 host 10.10.6.14는 IPv4 주소라 IPv6는 이미 빠져 있습니다.
  4. IPv4 뒷조각과 VLAN 태그 패킷을 놓칠 수 있습니다. 포트 조건 때문에 조각의 두 번째부터 빠지고, 트렁크라면 태그 있는 패킷이 빠집니다.

더 나은 제안: host 10.10.6.14 or host 2001:db8:6::14처럼 그 PC의 주소만 걸고(트렁크라면 vlan도 고려), 포트·프로토콜 선별은 저장한 뒤에 디스플레이 필터로 합니다. 수집 필터는 되돌릴 수 없으니 넉넉하게, 분석 필터는 세밀하게가 원칙입니다. 저장 공간이 걱정이라면 필터를 좁히기보다 1.8강의 파일 회전 옵션으로 관리합니다.

참고 자료

  • The Tcpdump Group, libpcap 1.10 브랜치 pcap-filter 문서 원본 (2026-11-05 확인). 프리미티브와 한정어, not이 가장 먼저이고 and·or는 우선순위가 같으며 왼쪽부터 묶인다는 규칙, 키워드 생략 규칙, gateway의 정의와 이름 조회 조건, proto[expr:size]의 위치가 그 프로토콜 계층 기준이라는 점, tcpflags 등의 이름.
  • The Tcpdump Group, libpcap 개발 브랜치 pcap-filter 문서 원본 (2026-11-05 확인). 위쪽 프로토콜의 바이트 접근이 IPv6 패킷에 맞지 않는다는 현재 설명.
  • IEEE 802.1Q(VLAN 태그)와 RFC 8200(IPv6 헤더와 확장 헤더), RFC 791(IPv4 헤더의 조각 오프셋)은 이 회차의 위치 계산에 쓴 헤더 구조의 근거입니다.

이 페이지에 대하여

여러 학습 자료를 바탕으로 직접 다시 쓴 개인 학습 노트입니다. 연습용 패킷은 설명을 위해 직접 조립한 것입니다.