1. 개요
Netfilter 는 리눅스 커널의 IP 패킷 경로에 정해진 다섯 지점을 두고, 그 지점에 외부 모듈이 함수를 등록해 패킷을 검사·수정·폐기할 수 있게 하는 커널 프레임워크다.
Netfilter 자체는 아무 판단도 하지 않는다. 자리, 명부, 등록 API 세 부품으로 이루어지고, 판단은 명부에 걸린 함수가 한다.
“훅(hook)” 은 두 뜻으로 쓰인다. 자리를 가리킬 때(hook point)와, 거기 등록된 함수를 가리킬 때(hook function). 이 문서는 자리를 2장, 함수를 5장에서 다룬다.
넓은 뜻으로 netfilter.org 프로젝트는 iptables, nftables, conntrack 같은 도구까지 통틀어 Netfilter 라고 부른다. 이 문서는 좁은 뜻, 커널 훅 프레임워크만 다룬다.
커널 2.4(2001) 부터 메인라인에 있고 CONFIG_NETFILTER=y 로 컴파일된다. 모든 배포판 커널이 켜 둔다. 이 옵션을 끄면 NF_HOOK 매크로는 다음 함수 호출 한 줄로 치환되어 사라지고, 방화벽·NAT·conntrack 이 전부 불가능해진다.
grep CONFIG_NETFILTER= /boot/config-$(uname -r)
# CONFIG_NETFILTER=y
2. 훅 포인트
자리는 추상적인 개념이 아니다. 커널 소스 net/ipv4/ 의 특정 함수 안 특정 줄에 적힌 NF_HOOK(...) 호출이다. 다섯 줄이 있고, 각 줄에 자리 번호가 상수로 박혀 있다.
2.1. 다섯 지점
| 커널 상수 | iptables 체인 | NF_HOOK 줄이 있는 함수 | 시점 |
|---|---|---|---|
NF_INET_PRE_ROUTING | PREROUTING | ip_rcv() | 장치에서 올라온 직후, 라우팅 전 |
NF_INET_LOCAL_IN | INPUT | ip_local_deliver() | 라우팅 결과 “내 것”, 소켓 전 |
NF_INET_FORWARD | FORWARD | ip_forward() | 라우팅 결과 “남 것”, 다른 장치로 넘기기 전 |
NF_INET_LOCAL_OUT | OUTPUT | __ip_local_out() | 소켓이 만든 패킷, 라우팅 전 |
NF_INET_POST_ROUTING | POSTROUTING | ip_output() | 장치로 내보내기 직전 |
iptables 체인 이름은 이 상수 이름에서 왔다. iptables -A INPUT ... 은 NF_INET_LOCAL_IN 자리의 명부에 걸린 filter 함수에 규칙 한 줄을 더하는 것이다.
/* net/ipv4/ip_input.c — 축약 */
int ip_local_deliver(struct sk_buff *skb)
{
/* 자리 번호가 상수로 박혀 있다 — 이 함수는 언제나 LOCAL_IN */
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, net, NULL,
skb, skb->dev, NULL, ip_local_deliver_finish);
}
/* net/ipv4/ip_forward.c — 축약 */
int ip_forward(struct sk_buff *skb)
{
return NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, net, NULL,
skb, skb->dev, rt->dst.dev, ip_forward_finish);
}
패킷이 “지금 어느 자리에 있는지” 를 판단하는 코드는 없다. 패킷(skb)에 자리 정보가 실려 있지도 않다. 어느 함수로 흘러들어갔느냐가 곧 자리이고, 그 함수에 번호가 적혀 있다.
2.2. NF_HOOK 의 동작
NF_HOOK 한 줄은 “이 번호의 명부를 순서대로 부르고, 전부 통과면 다음 함수로 가라” 다.
명부가 비어 있으면 okfn(skb) 호출 한 번과 같다. 부팅 직후의 커널이 이 상태다.
2.3. 경로의 조합
패킷의 입구는 둘이다. 장치에서 올라오면 ip_rcv(), 소켓에서 내려오면 __ip_local_out(). 입구가 다르니 PREROUTING 과 OUTPUT 은 섞이지 않는다. PREROUTING 뒤의 라우팅 조회가 결과를 skb 의 dst 칸에 적고, local 이면 ip_local_deliver() 를, 아니면 ip_forward() 를 부른다. FORWARD 와 OUTPUT 은 둘 다 ip_output() 으로 합류한다.
| 경로 | 지나는 자리 | 예 |
|---|---|---|
| 밖에서 와서 내가 받음 | PREROUTING → INPUT | ssh 수신 |
| 밖에서 와서 남에게 넘김 | PREROUTING → FORWARD → POSTROUTING | 노드가 Pod 패킷 전달 |
| 내가 만들어 내보냄 | OUTPUT → POSTROUTING | ssh 응답 |
| 내가 만들어 나에게 보냄 | OUTPUT → POSTROUTING, 다시 PREROUTING → INPUT | lo 로 curl |
3. 패킷 표현
훅 함수가 받는 패킷은 int 배열이 아니라 바이트 배열이다. 헤더 필드 크기가 4비트부터 4바이트까지 제각각이라 최소 단위로 둔다. 커널은 이 배열을 들고 다니지 않고, 배열의 위치를 적은 구조체 struct sk_buff 의 포인터 하나를 함수에서 함수로 넘긴다.
헤더를 읽을 때는 헤더 모양 그대로 정의된 구조체를 그 위치에 캐스팅한다. 메모리를 새로 만들지 않는다.
/* ip_hdr(skb) = (struct iphdr *)(data + network_header) */
/* tcp_hdr(skb) = (struct tcphdr *)(data + transport_header) */
struct iphdr *iph = ip_hdr(skb);
struct tcphdr *th = tcp_hdr(skb);
if (iph->protocol == IPPROTO_TCP && ntohs(th->dest) == 80) ...
2바이트 이상 필드는 네트워크 순서(big-endian) 로 놓여 있어서 ntohs(), ntohl() 로 뒤집어 읽는다. 포트 80 은 배열에 00 50 으로 놓여 있고, 그대로 읽으면 x86 에서 20480 이 나온다.
4. 호출 사슬
“패킷을 함수에서 함수로 넘긴다” 는 skb 포인터를 인자로 다음 함수를 부르는 것이다. 바이트 배열은 메모리에 한 번 놓인 채 움직이지 않고, 주소값 하나만 전달된다.
수신 한 번의 호출 구조는 이렇다. 한 칸 들어갈 때마다 호출이 한 겹 깊어진다.
재귀가 아니다. 명부에 걸린 함수(conntrack, filter 등) 는 skb 를 보고 판정만 돌려주지 자기 안에서 NF_HOOK 을 다시 부르지 않는다. 명부 순회는 평평한 for 문 한 번으로 끝난다. NF_HOOK 이 여러 번 나오는 것은 다음 함수(okfn) 쪽이 또 다른 번호의 NF_HOOK 줄을 품고 있기 때문이고, 한 패킷이 같은 번호를 두 번 밟는 일은 없다. PRE_ROUTING → LOCAL_IN 으로 번호가 바뀌며 한 방향으로 흐르는 파이프라인이다.
4.1. 동기 호출
한 패킷의 수신은 한 번의 긴 함수 호출이다. ip_rcv() 는 NF_HOOK 이 돌아올 때까지, NF_HOOK 은 ip_rcv_finish() 가 돌아올 때까지 기다린다. 제일 안쪽에서 패킷이 소켓 수신 큐에 들어가면 return 이 바깥으로 차례로 풀린다.
이 사슬은 softirq 문맥에서 돌고, 여기서는 잠들 수 없다. 명부에 걸린 함수가 느리면 그 CPU 에서 대기 중인 패킷 전부가 밀린다. iptables 규칙이 정해진 틀만 허용하는 것, eBPF 검증기가 무한 루프를 거부하는 것, NFQUEUE 가 느린 것이 전부 이 제약에서 나온다.
프로세스는 이 사슬 밖에 있다. 사슬이 다 풀린 뒤에야 “수신 큐에 뭐 들어왔다” 하고 깨워진다.
4.2. 스택 깊이
깊이는 정해져 있고 짧다. 수신 사슬이 20~30 프레임이고, 커널 스택은 x86-64 에서 16KB, softirq 는 별도 IRQ 스택을 쓴다. 패킷마다 사슬이 끝까지 풀린 뒤 다음 패킷이 시작하니 패킷 수만큼 누적되지 않는다.
송신 사슬 끝에서 수신 사슬이 그대로 이어지면 깊어질 수 있는데, lo 가 그 경우다. 커널은 여기를 큐로 끊는다.
/* drivers/net/loopback.c — 축약 */
static netdev_tx_t loopback_xmit(struct sk_buff *skb,
struct net_device *dev)
{
...
netif_rx(skb); /* backlog 큐에 넣고 바로 return */
return NETDEV_TX_OK; /* 송신 스택은 여기서 전부 풀림 */
}
/* 잠시 뒤 softirq 가 큐를 꺼내 ip_rcv() 부터 새 스택 */
그래서 lo 로 보낸 패킷이 네 자리를 다 지나는 것은 한 스택이 아니라 두 번의 스택이다.
4.3. 재진입 상한
VXLAN, IPIP 같은 터널은 송신 중에 캡슐을 씌우고 다시 송신 경로로 들어간다. 터널 안에 터널이면 스택이 겹쳐 쌓이고, 커널은 dev_queue_xmit() 에서 재진입 횟수를 세다가 XMIT_RECURSION_LIMIT(8) 을 넘으면 패킷을 버리고 로그를 남긴다.
Dead loop on virtual device vxlan0, fix it urgently!
5. 훅 함수
훅의 둘째 뜻. 명부에 등록된 함수다. 다섯 자리 모두 같은 타입의 함수를 받고, 같은 판정값을 돌려준다.
typedef unsigned int nf_hookfn(void *priv, struct sk_buff *skb,
const struct nf_hook_state *state);
/* state 에 hook 번호, in/out 장치, net 이 들어 있다 */
5.1. 등록과 순서
등록은 struct nf_hook_ops 에 함수와 자리를 적어 nf_register_net_hook() 을 부르는 것이다. 구조체 안에 함수(.hook) 와 자리(.hooknum) 가 따로 있다.
static unsigned int my_hook(void *priv, struct sk_buff *skb,
const struct nf_hook_state *state)
{
if (ip_hdr(skb)->protocol == IPPROTO_ICMP)
return NF_DROP;
return NF_ACCEPT;
}
static struct nf_hook_ops ops = {
.hook = my_hook, /* 훅의 둘째 뜻: 함수 */
.hooknum = NF_INET_LOCAL_IN, /* 훅의 첫째 뜻: 자리 */
.pf = NFPROTO_IPV4,
.priority = NF_IP_PRI_FILTER, /* 0 */
};
nf_register_net_hook(&init_net, &ops); /* 명부에 넣는다 */
nf_unregister_net_hook(&init_net, &ops); /* 뺀다 */
명부는 고정이 아니다. 부팅 직후에는 다섯 명부가 전부 비어 있고, 모듈이 로드되면서 채운다. nf_conntrack 은 네 자리에, iptable_filter 는 세 자리에 자기 함수를 넣는다. iptables 명령을 처음 치면 해당 모듈이 자동 로드된다.
같은 자리에 여럿이면 priority 오름차순으로 불린다. conntrack(-200) 이 nat(-100) 보다 먼저 와야 “기존 연결인지” 를 확인한 뒤 NAT 가 메모를 쓰거나 읽을 수 있다.
| priority | 상수 | 누가 |
|---|---|---|
| -400 | NF_IP_PRI_CONNTRACK_DEFRAG | 조각 재조립 |
| -300 | NF_IP_PRI_RAW | raw 테이블 |
| -200 | NF_IP_PRI_CONNTRACK | conntrack 조회 |
| -150 | NF_IP_PRI_MANGLE | mangle 테이블 |
| -100 | NF_IP_PRI_NAT_DST | DNAT |
| 0 | NF_IP_PRI_FILTER | filter 테이블 |
| 100 | NF_IP_PRI_NAT_SRC | SNAT / MASQUERADE |
| INT_MAX | NF_IP_PRI_CONNTRACK_CONFIRM | conntrack 메모 확정 |
지금 명부에 뭐가 걸려 있는지는 nftables 가 보여 준다. 리눅스 전용 명령이고, macOS 에는 Netfilter 가 없다(BSD 의 pf 를 쓴다).
sudo nft list hooks
family ip {
hook prerouting {
-0000000400 nf_defrag_ipv4
-0000000200 nf_conntrack_in
-0000000100 chain nat PREROUTING
}
hook input {
+0000000000 chain filter INPUT
+0000000100 chain nat INPUT
+2147483647 nf_confirm
}
...
}
5.2. 지점별 가용 정보
함수 모양은 같지만 자리마다 state 에 채워지는 값이 다르다. 라우팅 전에는 나갈 장치를 모르고, 소켓이 만든 패킷에는 들어온 장치가 없다.
| 자리 | 들어온 장치 (-i) | 나갈 장치 (-o) | 라우팅 결과 |
|---|---|---|---|
| PREROUTING | 있음 | 없음 | 없음 |
| INPUT | 있음 | 없음 | local |
| FORWARD | 있음 | 있음 | 있음 |
| OUTPUT | 없음 | 있음 | 있음 |
| POSTROUTING | 없음 | 있음 | 있음 |
목적지를 바꾸는 DNAT 는 라우팅 전(PREROUTING, OUTPUT) 에서만 말이 된다. 라우팅 뒤에 바꾸면 이미 내려진 결정과 어긋난다. 출발지를 바꾸는 SNAT 는 나갈 장치가 정해진 뒤(POSTROUTING) 에서만 말이 된다. 커널이 금지하는 게 아니라 논리상 그렇고, iptables 가 이 논리를 테이블·타깃 제한으로 코드화해 둔다(6장).
5.3. 판정값
| 값 | 뜻 |
|---|---|
NF_ACCEPT | 통과. 명부의 다음 칸으로 |
NF_DROP | 폐기. kfree_skb(), 다음 함수 안 부름 |
NF_STOLEN | 함수가 패킷을 가져갔다. 커널은 더 관여하지 않음 |
NF_QUEUE | 유저 공간(NFQUEUE) 으로 넘김. 판정은 거기서 |
NF_REPEAT | 같은 함수를 한 번 더 부름. 거의 안 쓰임 |
수정은 판정값이 아니다. 함수가 skb 를 고친 뒤 NF_ACCEPT 를 돌려주면 수정된 패킷이 다음으로 간다. DNAT 가 iph->daddr 를 덮어쓰고 체크섬을 다시 계산한 뒤 ACCEPT 하는 식이다.
5.4. 권한과 한계
훅 함수는 skb 포인터를 통째로 받으니 헤더뿐 아니라 페이로드까지 전부 읽고 고칠 수 있다. iptables 의 -m string, DPI, conntrack 의 FTP 헬퍼(페이로드 안 IP 문자열까지 고침) 가 이 자리에서 한다.
한계는 셋이다.
- 암호화된 페이로드는 바이트는 읽어도 뜻을 모른다. TLS 안의 HTTP 는 안 보인다
- 조각난 패킷은 조각 하나만 보면 반쪽이다. 그래서 conntrack 이 PREROUTING 명부 맨 앞(-400) 에 재조립 함수를 걸어 두고, 뒤의 함수들은 합쳐진 패킷을 본다
skb의 바이트가 한 덩어리로 연속이 아닐 수 있다. 큰 패킷은 페이로드가 페이지 조각에 흩어져 있어skb_linearize()로 합치거나 조각을 따라가야 한다
그리고 이 자리에서 다 읽어도 패킷은 아직 소켓에 안 닿았다. 127.0.0.1 바인딩이 패킷을 막는 게 아니라 마지막 소켓 찾기에서 실패하게 할 뿐인 것, 애플리케이션 모르게 커널 레벨에서 내용이 읽히고 바뀔 수 있는 것, 둘 다 여기서 나온다.
6. 위에 올라가는 것
| 도구 | 거는 자리 | 비고 |
|---|---|---|
| iptables raw | PREROUTING, OUTPUT | conntrack 전에 NOTRACK |
| iptables mangle | 다섯 자리 전부 | 헤더 필드 수정 |
| iptables nat | PREROUTING, INPUT, OUTPUT, POSTROUTING | FORWARD 없음 |
| iptables filter | INPUT, FORWARD, OUTPUT | 통과/거부 |
| nftables | 다섯 자리 | iptables 와 같은 자리에 거는 새 규칙 엔진. iptables (nf_tables) 는 이 위에서 도는 구세대 문법 |
| conntrack | PREROUTING, OUTPUT(조회), INPUT, POSTROUTING(확정) | 연결 메모장 |
| ipvs | INPUT 등 | 커널 로드밸런서. kube-proxy ipvs 모드 |
eBPF BPF_PROG_TYPE_NETFILTER | 다섯 자리 | 커널 6.4+. iptables 함수 대신 내 프로그램을 건다 |
iptables 는 자기 함수 하나를 여러 자리에 걸어 두고, 그 함수 안에서 사용자가 넣은 규칙 목록을 훑는다. 규칙 안의 사용자 정의 체인 점프는 이 함수 안에서 목록을 따라가는 것이지 NF_HOOK 을 다시 타는 게 아니다. 타깃별 자리 제한도 iptables 가 검사한다.
iptables -t nat -A POSTROUTING -j DNAT --to 10.0.0.1
# iptables: Invalid argument.
# DNAT 는 PREROUTING / OUTPUT 전용
7. 앞에 끼어드는 것
Netfilter 자리 자체를 바꿔 끼우는 것은 없다. 대신 그 앞에 새 자리를 뚫거나, 커널 스택을 아예 안 탄다.
| 방식 | 어디서 | Netfilter 와의 관계 |
|---|---|---|
| eBPF on Netfilter | Netfilter 자리 | 대체 아님. 같은 자리에 내 프로그램 |
| eBPF TC | Netfilter 앞 | 우회. 여기서 끝내면 Netfilter 명부에 할 일이 없다 |
| eBPF XDP | 드라이버 직후 | 우회. 스택 진입 전에 버리거나 돌려보냄 |
| DPDK / AF_XDP | 커널 스택 밖 | 완전 우회. 유저 공간이 NIC 를 직접 읽음 |
Cilium 이 kube-proxy 를 없애는 방식이 TC 자리다. Service 테이블을 eBPF 맵에 넣고 DNAT·NetworkPolicy·노드 간 라우팅을 거기서 끝낸다. iptables 규칙 목록을 위에서부터 훑는 대신 해시 맵 조회 한 번이고, 그 대가로 conntrack 도 자기 맵으로 따로 만든다. 그래도 kubelet 이 요구하는 br_netfilter 와 일부 MASQUERADE 는 iptables 로 남는 경우가 많다.
iptables 규칙을 eBPF 로 자동 번역해 Netfilter 를 없애려던 bpfilter 는 몇 년 끌다 2024년에 메인라인에서 제거됐다. 자리 자체는 바꾸기 어렵고, 그 앞에 새 자리를 뚫는 쪽이 리눅스의 현재 답이다.
8. 관련 문서
- iptables (예정)
- Conntrack — 자리에 걸린 함수 둘과 해시 테이블. 한 항목이 두 키로 걸리는 이유
- eBPF (예정)
- Service — Service IP 가 PREROUTING / OUTPUT 의 DNAT 로 Pod IP 가 된다
- CNI — 노드가 FORWARD 자리를 지나는 라우터가 되게 설정한다
- VXLAN — 4.3 의 재진입이 일어나는 터널
9. 출처
- kernel.org — Netfilter hooks (nf_hook_ops, 판정값)
- netfilter.org — Netfilter hacking HOWTO, “Netfilter Architecture”
- kernel.org — struct sk_buff
- nftables wiki — Netfilter hooks
- docs.ebpf.io — BPF_PROG_TYPE_NETFILTER
- kernel.org — netif_rx, backlog
- 커널 소스
net/ipv4/ip_input.c,ip_forward.c,ip_output.c,include/linux/netfilter.h,include/uapi/linux/netfilter_ipv4.h