NFQUEUE & DPI 엔진 통합
리눅스 커널 nfnetlink_queue 내부 구조, libnetfilter_queue API, Suricata/nDPI/Snort IPS 통합, NFQUEUE 성능 최적화(fanout/busy polling/batch), eBPF L7 분류, NGFW DPI 역할 종합 가이드. 커널 내부 데이터 경로, 핵심 자료구조/API, 운영 환경 튜닝 포인트와 장애 디버깅 절차까지 실무 관점으로 다룹니다.
핵심 요약
- NFQUEUE는 커널에서 패킷을 유저스페이스 DPI 엔진으로 전달하는 Netfilter 타겟
nfnetlink_queue커널 모듈이 커널↔유저 간 통신 채널 제공libnetfilter_queue라이브러리로 유저스페이스에서 패킷 수신/판정- Suricata IPS 모드, nDPI, Snort 3가 NFQUEUE를 통해 L7 분류 수행
- fanout 모드로 다중 큐·다중 스레드 처리로 성능 확장
- eBPF 소켓 필터로 L7 초고속 분류 가능 (커널 내부에서 처리)
- DPI는 암호화 트래픽(TLS 1.3)에서 한계 — JA3/SNI/JARM 지문으로 보완
- NGFW에서 DPI는 IPS/IDS, URL 필터링, 애플리케이션 식별에 핵심
- Intel Hyperscan(SIMD 정규식 엔진)으로 Aho-Corasick 대비 패턴 매칭 수십 배 가속
- QUIC/HTTP3는 UDP+TLS 1.3 구조로 기존 DPI 기법 적용이 어려워 별도 파서 필요
--queue-balance와 RSS로 다수의 NIC 큐를 여러 Worker 프로세스에 분산하는 클러스터 패턴 지원- verdict 실패 시
NFQA_CFG_F_FAIL_OPEN으로 fail-open 또는 fail-closed 정책 선택 가능
단계별 이해
- Netfilter 타겟 이해: NFQUEUE는 ACCEPT/DROP 대신 패킷을 유저스페이스 큐로 전달하는 특수 타겟입니다. 커널은 판정을 받을 때까지 패킷을
nf_queue_entry에 보관합니다. - 커널 채널 파악:
nfnetlink_queue모듈이 netlink 소켓을 통해 패킷 데이터를 유저스페이스에 전달합니다. 큐가 가득 차면 drop 또는 fail-open 정책이 적용됩니다. - 유저스페이스 처리: DPI 엔진이
libnetfilter_queue로 패킷을 받아 L7 분류 후 ACCEPT/DROP/MARK 판정을 커널로 반환합니다.nfq_set_verdict2()로 mark 값도 함께 설정합니다. - 성능 고려: 커널↔유저 복사 비용이 있으므로 fanout, batch, bypass 전략을 조합합니다. GSO 플래그로 분할 없이 원본 패킷을 전달하면 CPU 부하가 줄어듭니다.
- 고성능 패턴 매칭: Intel Hyperscan은 SIMD 명령어로 수천 개의 정규식을 병렬 매칭합니다. Snort 3와 Suricata가 기본 엔진으로 채택하여 패턴 매칭 속도를 크게 향상시킵니다.
- eBPF 보완: 이미 분류된 세션은 eBPF map으로 커널 내에서 고속 처리합니다. TC eBPF로 flow_cache를 조회해 캐시 히트 시 NFQUEUE를 완전히 우회합니다.
- QUIC 대응: QUIC Initial Packet의 Client Hello에서 SNI를 추출하고 Connection ID로 세션을 추적합니다. 암호화 이후 페이로드는 분석 불가능합니다.
개요: NGFW와 DPI
차세대 방화벽(NGFW)은 단순한 L3/L4 패킷 필터를 넘어 애플리케이션 계층(L7)까지 검사합니다. 리눅스 커널은 NFQUEUE 메커니즘을 통해 이를 구현합니다.
전통적 방화벽 vs NGFW
| 구분 | 전통적 방화벽 | NGFW (DPI 포함) |
|---|---|---|
| 검사 계층 | L2~L4 (IP/Port) | L2~L7 (애플리케이션까지) |
| 상태 추적 | 5-tuple 기반 | 애플리케이션 컨텍스트 포함 |
| 암호화 대응 | 없음 | TLS 인터셉트 또는 지문 분석 |
| IPS/IDS | 별도 장비 필요 | 인라인 통합 |
| 처리량 오버헤드 | 거의 없음 | 10~30% (DPI 우회 최적화로 감소) |
| 리눅스 구현 | iptables/nftables | NFQUEUE + Suricata/nDPI |
NGFW 패킷 처리 흐름
nfnetlink_queue 내부 구조
nfnetlink_queue는 Netfilter의 NFQUEUE 타겟을 구현하는 커널 모듈입니다.
패킷을 유저스페이스로 전달하고 판정(verdict)을 받아 처리합니다.
커널 내부적으로 nfqnl_instance 구조체가 큐 하나를 표현하며, netlink 소켓을 통해 유저스페이스와 비동기로 통신합니다.
핵심 자료구조 — nfqnl_instance 전체 필드
/* net/netfilter/nfnetlink_queue.c */
struct nfqnl_instance {
struct hlist_node hlist; /* 인스턴스 해시 테이블 (queue_num 해시) */
struct rcu_head rcu; /* RCU 해제용 콜백 */
u_int16_t queue_num; /* 큐 번호 (0~65535) — NFQUEUE --queue-num */
u_int8_t copy_mode; /* NFQNL_COPY_NONE/META/PACKET */
u_int32_t copy_range; /* nfq_set_mode() 지정 최대 복사 바이트 */
u_int32_t queue_maxlen; /* 큐 최대 크기 (기본 1024, 최대 65535) */
atomic_t queue_total; /* 현재 큐잉된 패킷 수 */
atomic_t queue_dropped; /* 큐 포화로 드롭된 패킷 수 */
atomic_t queue_user_dropped; /* 유저 verdict 오류로 드롭된 수 */
struct sk_buff_head skb_queue; /* 대기 중인 netlink 응답 큐 */
struct sock *peer_sk; /* 연결된 유저스페이스 netlink 소켓 */
u_int32_t peer_portid; /* 유저 프로세스 netlink portid */
u_int32_t id_sequence; /* 모노토닉 패킷 ID 생성 카운터 */
struct list_head queue_list; /* nf_queue_entry 판정 대기 목록 */
spinlock_t lock; /* queue_list, queue_total 보호 */
unsigned int queue_flags; /* NFQA_CFG_F_* 비트필드 */
u_int32_t flags; /* 내부 상태 플래그 */
};
/* copy_mode 값 */
#define NFQNL_COPY_NONE 0 /* 패킷 데이터 전달 안 함 (메타만) */
#define NFQNL_COPY_META 1 /* 패킷 헤더만 전달 */
#define NFQNL_COPY_PACKET 2 /* copy_range 바이트까지 전달 */
nfqnl_recv_config() 흐름 — 유저 설정 적용
/* 유저스페이스에서 nfq_set_mode() 호출 시 커널 경로 */
/* nfq_set_mode() → netlink 메시지 → nfqnl_recv_config() */
static int nfqnl_recv_config(struct sock *ctnl, struct sk_buff *skb,
const struct nlmsghdr *nlh, ...)
{
u16 queue_num = ntohs(nfmsg->res_id);
struct nfqnl_instance *queue = instance_lookup(queue_num);
if (!queue) {
/* 큐 없으면 신규 생성 */
queue = instance_create(queue_num, NETLINK_CB(skb).portid, net);
}
if (nfqa[NFQA_CFG_PARAMS]) {
struct nfqnl_msg_config_params *params =
nla_data(nfqa[NFQA_CFG_PARAMS]);
nfqnl_set_mode(queue, params->copy_mode,
ntohl(params->copy_range));
}
if (nfqa[NFQA_CFG_QUEUE_MAXLEN]) {
__be32 *v32 = nla_data(nfqa[NFQA_CFG_QUEUE_MAXLEN]);
queue->queue_maxlen = ntohl(*v32);
}
if (nfqa[NFQA_CFG_FLAGS]) {
/* NFQA_CFG_F_FAIL_OPEN 등 설정 */
__be32 flags = nla_get_be32(nfqa[NFQA_CFG_FLAGS]);
__be32 mask = nla_get_be32(nfqa[NFQA_CFG_MASK]);
queue->flags = (ntohl(queue->flags) & ~ntohl(mask)) |
(ntohl(flags) & ntohl(mask));
}
}
큐 버퍼 관리 — nfqnl_flush()
/* 유저 프로세스 종료 시 미처리 패킷 일괄 해제 */
static void nfqnl_flush(struct nfqnl_instance *queue,
nfqnl_cmpfn cmpfn, unsigned long data)
{
struct nf_queue_entry *entry, *next;
spin_lock_bh(&queue->lock);
list_for_each_entry_safe(entry, next, &queue->queue_list, list) {
if (!cmpfn || cmpfn(entry, data)) {
list_del(&entry->list);
atomic_dec(&queue->queue_total);
/* 정책: fail-open이면 ACCEPT, 아니면 DROP */
if (queue->flags & NFQA_CFG_F_FAIL_OPEN)
nf_reinject(entry, NF_ACCEPT);
else
nf_reinject(entry, NF_DROP);
}
}
spin_unlock_bh(&queue->lock);
}
/* skb를 유저스페이스로 복사하는 내부 경로 */
/* nfqnl_build_packet_message():
1. nlmsg_new()로 netlink 버퍼 할당
2. nfqnl_put_sk_uidgid() — UID/GID 추가 (NFQA_CFG_F_UID_GID)
3. nfqnl_put_bridge() — bridge 포트 정보
4. nfq_put_ct_info() — conntrack 정보 (NFQA_CFG_F_CONNTRACK)
5. skb_copy_bits() → NFQA_PAYLOAD nlattr에 패킷 데이터 복사
6. netlink_unicast() → 유저 소켓으로 전송
*/
nfnetlink_queue 플래그
| 플래그 | 값 | 설명 |
|---|---|---|
NFQA_CFG_F_FAIL_OPEN | 0x01 | 큐 포화 시 DROP 대신 ACCEPT (Fail-open) |
NFQA_CFG_F_CONNTRACK | 0x02 | conntrack 정보 함께 전달 |
NFQA_CFG_F_GSO | 0x04 | GSO 패킷 분할 없이 원본 전달 |
NFQA_CFG_F_UID_GID | 0x08 | 소켓 소유자 UID/GID 포함 |
NFQA_CFG_F_SECCTX | 0x10 | LSM 보안 컨텍스트 포함 |
libnetfilter_queue API
libnetfilter_queue는 유저스페이스 DPI 엔진이 NFQUEUE와 통신하는 라이브러리입니다.
내부적으로 libmnl 위에 구축되어 있으며, 3개의 핵심 핸들 구조체를 중심으로 동작합니다.
핵심 구조체 관계도
/*
* nfq_handle — 라이브러리 전역 핸들 (nfq_open()으로 생성)
* └─ nfq_q_handle — 개별 큐 핸들 (nfq_create_queue()로 생성, 큐번호 1:1)
* └─ nfq_data — 개별 패킷 데이터 (콜백 호출 시 전달)
*
* nfq_handle: netlink fd 래핑, 프로토콜패밀리(AF_INET/AF_INET6) 바인딩
* nfq_q_handle: 큐번호, 콜백함수 포인터, copy_mode 보관
* nfq_data: 해당 패킷의 헤더/페이로드/메타데이터 접근자 묶음
*/
완전한 C DPI 엔진 예제 (수신→파싱→판정)
#include <libnetfilter_queue/libnetfilter_queue.h>
#include <libnetfilter_queue/pktbuff.h>
#include <linux/netfilter.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <sys/socket.h>
/* conntrack 메타데이터 출력 */
static void print_conntrack(struct nfq_data *nfa) {
struct nfqnl_msg_packet_hw *hw;
if ((hw = nfq_get_packet_hw(nfa))) {
printf("HW addr: %02x:%02x:%02x:%02x:%02x:%02x\n",
hw->hw_addr[0], hw->hw_addr[1], hw->hw_addr[2],
hw->hw_addr[3], hw->hw_addr[4], hw->hw_addr[5]);
}
}
/* 패킷 판정 콜백 */
static int packet_callback(struct nfq_q_handle *qh,
struct nfgenmsg *nfmsg,
struct nfq_data *nfa, void *data)
{
struct nfqnl_msg_packet_hdr *ph = nfq_get_msg_packet_hdr(nfa);
uint32_t id = ntohl(ph->packet_id);
unsigned char *payload;
int len = nfq_get_payload(nfa, &payload);
/* UID/GID 정보 (NFQA_CFG_F_UID_GID 활성화 시) */
uint32_t uid, gid;
if (nfq_get_uid(nfa, &uid) == 0)
printf("UID=%u GID=%u\n", uid, gid);
/* IP 헤더 파싱 */
if (len < (int)sizeof(struct iphdr))
return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL);
struct iphdr *ip = (struct iphdr *)payload;
int ip_hlen = ip->ihl * 4;
uint32_t verdict = NF_ACCEPT;
if (ip->protocol == IPPROTO_TCP && len > ip_hlen + 20) {
struct tcphdr *tcp = (struct tcphdr *)(payload + ip_hlen);
int tcp_hlen = tcp->doff * 4;
unsigned char *app = payload + ip_hlen + tcp_hlen;
int app_len = len - ip_hlen - tcp_hlen;
if (app_len >= 4) {
if (memcmp(app, "GET ", 4) == 0 ||
memcmp(app, "POST", 4) == 0) {
verdict = check_url_policy(app, app_len);
}
}
}
/* verdict2: mark + verdict 동시 설정 */
if (verdict == NF_ACCEPT)
return nfq_set_verdict2(qh, id, NF_ACCEPT, 0x1, 0, NULL);
else
return nfq_set_verdict(qh, id, NF_DROP, 0, NULL);
}
int main(void) {
struct nfq_handle *h = nfq_open();
nfq_unbind_pf(h, AF_INET);
nfq_bind_pf(h, AF_INET);
struct nfq_q_handle *qh = nfq_create_queue(h, 0, &packet_callback, NULL);
nfq_set_mode(qh, NFQNL_COPY_PACKET, 0xffff);
nfq_set_queue_flags(qh, NFQA_CFG_F_FAIL_OPEN, NFQA_CFG_F_FAIL_OPEN);
nfq_set_queue_flags(qh, NFQA_CFG_F_CONNTRACK, NFQA_CFG_F_CONNTRACK);
nfq_set_queue_flags(qh, NFQA_CFG_F_UID_GID, NFQA_CFG_F_UID_GID);
int fd = nfq_fd(h);
/* SO_RCVBUF 튜닝 — 수신 버퍼 확대 */
int rcvbuf = 4 * 1024 * 1024;
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
char buf[65536] __attribute__((aligned(16)));
while (1) {
int rv = recv(fd, buf, sizeof(buf), 0);
if (rv > 0)
nfq_handle_packet(h, buf, rv);
}
nfq_destroy_queue(qh);
nfq_close(h);
}
Python bindings — scapy + nfqueue
# pip install scapy netfilterqueue
from netfilterqueue import NetfilterQueue
from scapy.all import IP, TCP, Raw
def process_packet(pkt):
scapy_pkt = IP(pkt.get_payload())
if TCP in scapy_pkt and Raw in scapy_pkt:
payload = bytes(scapy_pkt[Raw])
if payload.startswith(b"GET ") or payload.startswith(b"POST "):
# HTTP 트래픽 허용, mark 설정
pkt.set_mark(1)
pkt.accept()
return
pkt.accept() # 기본: 허용
nfqueue = NetfilterQueue()
nfqueue.bind(0, process_packet, max_len=10000, mode=NetfilterQueue.COPY_PACKET)
try:
nfqueue.run()
except KeyboardInterrupt:
pass
nfqueue.unbind()
Rust bindings — nfq crate 예제
// Cargo.toml: nfq = "0.5"
use nfq::{Queue, Verdict};
fn main() -> std::io::Result<()> {
let mut queue = Queue::open()?;
queue.bind(0)?; // 큐 번호 0에 바인딩
loop {
let mut msg = queue.recv()?;
let payload = msg.get_payload();
// IP 헤더 최소 길이 확인
let verdict = if payload.len() >= 20 {
let proto = payload[9];
if proto == 6 { // TCP
let ihl = (payload[0] & 0x0f) as usize * 4;
let tcp_off = (payload[ihl + 12] >> 4) as usize * 4;
let app = &payload[ihl + tcp_off..];
if app.starts_with(b"GET ") || app.starts_with(b"POST") {
msg.set_nfmark(1); // mark 설정
}
}
Verdict::Accept
} else {
Verdict::Accept
};
msg.set_verdict(verdict);
queue.verdict(msg)?;
}
}
verdict 종류
| verdict | 값 | 설명 |
|---|---|---|
NF_ACCEPT | 1 | 패킷 계속 전달 |
NF_DROP | 0 | 패킷 폐기 |
NF_STOLEN | 2 | 패킷 소유권 이전 (재주입 등) |
NF_QUEUE | 3 | 다른 큐로 재전송 |
NF_REPEAT | 4 | Netfilter 훅 재실행 |
| 마크 + ACCEPT | - | nfq_set_verdict2()로 mark 설정 후 ACCEPT |
DPI 엔진 통합 (Suricata/nDPI/Snort)
주요 오픈소스 DPI 엔진의 NFQUEUE 통합 방법입니다.
Suricata IPS 모드 설정
# /etc/suricata/suricata.yaml
nfq:
mode: repeat # 재처리 후 verdict 설정
repeat-mark: 1
repeat-mask: 1
bypass-mark: 1 # 이미 검사된 세션 bypass
bypass-mask: 1
route-queue: 2 # DROP 대신 다른 큐로 전달
batchcount: 20 # 배치 판정 (성능 향상)
fail-open: yes # 큐 포화 시 허용
# iptables NFQUEUE 규칙 설정 (Suricata IPS)
iptables -I FORWARD -j NFQUEUE --queue-num 0 --queue-bypass
# 이미 처리된 패킷 (mark=1) 건너뜀
iptables -I FORWARD -m mark --mark 1 -j ACCEPT
# nftables로 동일 설정
nft add rule inet filter forward mark 0x1 accept
nft add rule inet filter forward queue num 0 bypass
nDPI 통합 예제
#include <ndpi_api.h>
struct ndpi_detection_module_struct *ndpi_struct;
struct ndpi_flow_struct ndpi_flow;
/* 초기화 */
ndpi_struct = ndpi_init_detection_module(ndpi_no_prefs());
ndpi_set_protocol_detection_bitmask2(ndpi_struct, &all_protocols);
/* 패킷마다 호출 */
ndpi_protocol protocol = ndpi_detection_process_packet(
ndpi_struct, &ndpi_flow,
ip_payload, ip_payload_len,
timestamp_ms, NULL);
if (protocol.master_protocol == NDPI_PROTOCOL_TLS) {
/* TLS 트래픽 — SNI/JA3로 앱 식별 */
char *sni = ndpi_flow.protos.tls_quic.client_requested_server_name;
enforce_tls_policy(sni, &ndpi_flow);
}
NFQUEUE Fanout 멀티스레딩
단일 큐는 단일 스레드에서만 처리 가능합니다. fanout 기능으로 여러 큐에 로드 밸런싱합니다.
Fanout 설정
# 4개 큐로 fanout (CPU 코어당 1개 큐)
iptables -I FORWARD -j NFQUEUE \
--queue-num 0:3 \ # 큐 범위 0~3
--queue-balance \ # 5-tuple 해시로 로드 밸런싱
--queue-bypass # 큐 없으면 bypass
# nftables 버전
nft add rule inet filter forward \
queue num 0-3 flags bypass,fanout
# CPU affinity 설정 — 코어 0~3에 각 스레드 고정
for i in 0 1 2 3; do
numactl --cpunodebind=0 --physcpubind=$i \
suricata -c /etc/suricata/suricata.yaml \
--runmode single -q $i &
done
큐 모드 비교
| 모드 | nftables 옵션 | 분배 방식 | 용도 |
|---|---|---|---|
| 단일 큐 | queue num 0 | 없음 | 단순 DPI |
| fanout | queue num 0-3 flags fanout | 5-tuple 해시 | 멀티코어 DPI |
| round-robin | --queue-balance (iptables) | 순차적 | 균등 분배 |
| bypass | flags bypass | - | 큐 없으면 ACCEPT |
NFQUEUE 성능 최적화
NFQUEUE의 주요 성능 병목은 커널↔유저 컨텍스트 스위칭과 메모리 복사입니다. 단일 큐 단순 구성에서는 약 200~500Kpps 수준이지만, 최적화를 조합하면 1Mpps 이상 달성이 가능합니다.
큐 크기별 성능 수치 (참고 기준)
| 큐 크기(maxlen) | fanout 큐 수 | 처리량 (Kpps) | 평균 레이턴시 (µs) | 비고 |
|---|---|---|---|---|
| 1024 (기본) | 1 | 200~300 | 80~120 | 단순 헤더 검사 |
| 8192 | 1 | 350~500 | 60~90 | SO_RCVBUF 4MB |
| 8192 | 4 | 800~1200 | 40~70 | fanout + CPU affinity |
| 16384 | 8 | 1500~2000 | 30~50 | batch verdict 20 |
| 16384 + GSO | 8 | 2500~3500 | 20~35 | NFQA_CFG_F_GSO + bypass |
최적화 기법 비교
| 기법 | 설명 | 적용 방법 | 성능 향상 |
|---|---|---|---|
| 배치 verdict | 여러 패킷 판정을 묶어서 전송 | batchcount: 20 | ~30% |
| Busy polling | 블로킹 대신 폴링으로 지연 감소 | SO_BUSY_POLL | 레이턴시 50%↓ |
| Zero-copy (GSO) | GSO 분할 없이 원본 전달 | NFQA_CFG_F_GSO | CPU 20%↓ |
| Fail-open | 큐 포화 시 ACCEPT → 중단 방지 | NFQA_CFG_F_FAIL_OPEN | 안정성 |
| Conntrack bypass | 기존 세션은 DPI 건너뜀 | mark + ACCEPT 규칙 | ~70% |
| eBPF pre-filter | L4 이하는 eBPF로 조기 필터링 | XDP/TC eBPF | DPI 부하 60%↓ |
CPU 코어 fanout 바인딩 — IRQ affinity
# NIC IRQ를 CPU 0~3에 분산 (RSS 4개 큐 기준)
ethtool -l eth0 # RX 큐 수 확인
ethtool -L eth0 combined 4 # 4개 큐 활성화
# IRQ → CPU affinity 설정
for i in 0 1 2 3; do
IRQ=$(grep "eth0-rx-$i" /proc/interrupts | awk '{print $1}' | tr -d ':')
echo $((1 << $i)) > /proc/irq/$IRQ/smp_affinity
done
# NFQUEUE fanout — 큐 0~3에 worker 프로세스 바인딩
for i in 0 1 2 3; do
taskset -c $i suricata -c /etc/suricata/suricata.yaml \
--runmode single -q $i &
done
# iptables fanout 규칙 (CPU와 큐 1:1 매핑)
iptables -I FORWARD -j NFQUEUE --queue-balance 0:3 --queue-bypass
SO_RCVBUF 튜닝
# 시스템 소켓 버퍼 최대값 증가
sysctl -w net.core.rmem_max=33554432 # 32MB
sysctl -w net.core.rmem_default=8388608 # 8MB
# 애플리케이션에서 직접 설정
# int rcvbuf = 4 * 1024 * 1024;
# setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
# 큐 maxlen 증가 (nftables)
nft add rule inet filter forward queue num 0-3 flags bypass,fanout
# 또는 nfq_set_queue_maxlen(qh, 16384)로 코드에서 설정
pktgen + NFQUEUE 벤치마킹
# pktgen으로 테스트 패킷 생성 (다른 머신에서)
modprobe pktgen
echo "add_device eth0@0" > /proc/net/pktgen/kpktgend_0
pgset() { local result; echo $1 > /proc/net/pktgen/$2; }
pgset "count 10000000" /proc/net/pktgen/eth0@0
pgset "pkt_size 64" /proc/net/pktgen/eth0@0
pgset "dst_mac aa:bb:cc:dd:ee:ff" /proc/net/pktgen/eth0@0
pgset "dst 192.168.1.1" /proc/net/pktgen/eth0@0
echo "start" > /proc/net/pktgen/pgctrl
# NFQUEUE 수신 측에서 처리량 측정
watch -n 1 'cat /proc/net/netfilter/nfnetlink_queue | \
awk "{print \"total:\", \$3, \"dropped:\", \$5}"'
# bpftrace로 verdict 처리 시간 측정
bpftrace -e '
kprobe:nfqnl_enqueue_packet { @ts[tid] = nsecs; }
kretprobe:nfqnl_recv_verdict {
$lat = nsecs - @ts[tid];
@latency = hist($lat);
delete(@ts[tid]);
}
interval:s:5 { print(@latency); clear(@latency); }'
세션 bypass 패턴
# 1단계: 첫 패킷만 DPI (Suricata가 mark=1 설정)
nft add rule inet mangle prerouting \
ct state new queue num 0 bypass
# 2단계: 기존 연결은 mark 확인 후 bypass
nft add rule inet mangle prerouting \
ct mark 1 counter accept
# 3단계: Established 연결 중 DPI 완료 → bypass
nft add rule inet mangle prerouting \
ct state established,related \
ct mark 0 queue num 0 bypass
eBPF 기반 L7 분류
eBPF 소켓 필터는 커널 내부에서 L7 분류를 수행하여 유저스페이스 복사 없이 고속 처리합니다.
eBPF 소켓 필터 (SO_ATTACH_FILTER)
/* eBPF L7 분류 프로그램 — 커널 내부 실행 */
SEC("socket")
int l7_classifier(struct __sk_buff *skb) {
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
struct ethhdr *eth = data;
if (data + sizeof(*eth) > data_end) return 0;
struct iphdr *ip = data + sizeof(*eth);
if (data + sizeof(*eth) + sizeof(*ip) > data_end) return 0;
if (ip->protocol != IPPROTO_TCP) return skb->len;
struct tcphdr *tcp = (void *)ip + ip->ihl * 4;
void *payload = (void *)tcp + tcp->doff * 4;
if (payload + 4 <= data_end) {
__u32 magic;
bpf_probe_read(&magic, sizeof(magic), payload);
/* HTTP GET 감지 (big-endian: 0x47455420) */
if (magic == __constant_htonl(0x47455420)) {
__u32 key = 0;
__u64 *cnt = bpf_map_lookup_elem(&http_counter, &key);
if (cnt) __sync_fetch_and_add(cnt, 1);
}
}
return skb->len; /* 0 = 드롭, len = 통과 */
}
TC eBPF를 활용한 L7 라우팅
/* TC classifier: L7 기반 패킷 마킹 (커널 내부) */
SEC("tc")
int tc_l7_mark(struct __sk_buff *skb) {
__u32 flow_key = compute_flow_key(skb);
__u32 *app_id = bpf_map_lookup_elem(&flow_cache, &flow_key);
if (app_id) {
/* 캐시 히트: 이미 분류된 앱 */
skb->mark = *app_id;
return TC_ACT_OK;
}
/* 캐시 미스: NFQUEUE로 DPI 위임 */
return TC_ACT_PIPE; /* Netfilter로 계속 진행 */
}
eBPF + NFQUEUE 통합 배포 스크립트
# 1단계: TC eBPF 프로그램 컴파일 및 로드
clang -O2 -target bpf -c tc_l7_mark.c -o tc_l7_mark.o
tc qdisc add dev eth0 clsact
tc filter add dev eth0 ingress bpf da obj tc_l7_mark.o sec tc
# 2단계: flow_cache BPF map 확인
bpftool map list | grep flow_cache
bpftool map dump name flow_cache | head -20
# 3단계: NFQUEUE 규칙 설정 (TC eBPF 이후 실행)
# TC eBPF에서 캐시 미스 → TC_ACT_PIPE → Netfilter NFQUEUE
nft add rule inet filter forward \
mark 0 queue num 0-3 flags bypass,fanout
# 4단계: DPI 완료 후 flow_cache 갱신 (유저스페이스 → eBPF map)
# bpf_map_update_elem(&flow_cache, &flow_key, &app_id, BPF_ANY);
# 5단계: 분류된 트래픽은 TC eBPF에서 직접 처리 (NFQUEUE 우회)
# 처리량: 캐시 히트율 90% 가정 시 NFQUEUE 부하 10%로 감소
eBPF vs NFQUEUE 성능 비교
| 항목 | TC eBPF (커널 내부) | NFQUEUE (유저스페이스) | XDP |
|---|---|---|---|
| 실행 위치 | 커널 TC 레이어 | 유저스페이스 프로세스 | 드라이버/NIC |
| 패킷 복사 | 없음 | netlink 복사 필요 | 없음 (UMEM) |
| 처리량 | 10~30 Mpps | 1~3 Mpps | 30~100 Mpps |
| L7 분류 능력 | 단순 패턴 (마법 바이트) | 완전 DPI (Suricata/nDPI) | 제한적 |
| 정규식 지원 | 불가 | Hyperscan 완전 지원 | 불가 |
| 연결 추적 | BPF map 수동 관리 | conntrack 자동 연동 | BPF map 수동 |
| 권장 조합 | XDP(DDoS) → TC eBPF(캐시) → NFQUEUE(신규 세션 DPI) | ||
TLS 트래픽 분류 (JA3/SNI)
TLS 1.3에서 페이로드가 암호화되므로 핸드셰이크 지문으로 우회 식별합니다. JA3(클라이언트 지문), JA3S(서버 지문), JARM(서버 능동 스캔) 세 가지 기법을 조합하면 악성 도구를 높은 정확도로 식별할 수 있습니다.
TLS 지문 기법 비교
| 기법 | 입력 데이터 | 해시 | 용도 | 우회 어려움 |
|---|---|---|---|---|
| JA3 | TLS ClientHello (버전, 암호, 확장, 곡선) | MD5 32자 | 클라이언트(악성코드/C2) 식별 | 중간 (랜덤화로 우회 가능) |
| JA3S | TLS ServerHello (버전, 암호, 확장) | MD5 32자 | 서버(C2 인프라) 식별 | 중간 |
| JARM | 능동 스캔 10개 TLS Hello 응답 | 62자 문자열 | 서버 TLS 스택 핑거프린팅 | 높음 (능동 스캔 필요) |
| SNI | ClientHello server_name Extension | 문자열 | 도메인 기반 URL 필터링 | 낮음 (ESNI/ECH로 우회) |
JA3 지문 계산 원리
/* JA3 계산 입력 구성 (Suricata 내부 구현 참고) */
/* TLS ClientHello에서 추출:
1. SSLVersion (예: 769 = TLS 1.0)
2. Ciphers (예: 49195-49199-52393-52392-...)
3. Extensions list (예: 0-5-10-11-13-23-16-...)
4. EllipticCurves (예: 29-23-24)
5. EllipticCurvePointFormats (예: 0)
*/
/* 쉼표로 구분된 문자열 → MD5 해시
MD5("769,49195-49199-52393,0-5-10-11,29-23-24,0")
→ "abc123def456abc123def456abc123de" (32자)
*/
/* JARM: 능동 스캔으로 서버 TLS 스택 지문 */
/* 10개의 특수 ClientHello를 전송하고 ServerHello 응답을 수집
각 응답의 cipher + extension 조합 → 62자 지문
예: "2ad2ad0002ad2ad00042d42d000000000000000000000000000000000000"
서버 측에서 TLS 라이브러리 버전/설정을 식별 (OpenSSL vs BoringSSL vs JSSE 등)
*/
Suricata JA3/JARM 규칙 활용
# Suricata 규칙으로 알려진 악성 JA3 차단
# alert tls any any -> any any (
# msg:"Cobalt Strike Beacon JA3";
# ja3.hash; content:"72a589da586844d7f0818ce684948eea";
# sid:1000001; rev:1;)
# JA3S (서버 응답 지문)
# alert tls any any -> any any (
# msg:"Malware C2 Server JA3S";
# ja3s.hash; content:"f4febc55ea12b31ae17cfbf5a4b33b72";
# sid:1000002; rev:1;)
# SNI 기반 도메인 차단
# alert tls any any -> any any (
# msg:"Blocked Domain SNI";
# tls.sni; content:"malware.example.com"; nocase;
# sid:1000003;)
# nDPI를 통한 TLS 지문 추출
# ndpi_flow.protos.tls_quic.ja3_client — JA3 클라이언트 해시
# ndpi_flow.protos.tls_quic.ja3_server — JA3S 서버 해시
SNI 기반 URL 필터링
# nftables SNI 기반 차단 (TLS Extension 파싱)
# 커널 6.3+ nft_tproxy + Suricata 조합
# 방법 1: SNI → DNS 싱크홀 (DNSBL)
# NFQUEUE DPI에서 SNI 추출 후 IP를 nftables set에 추가
nft add element inet filter blocklist { 1.2.3.4 }
nft add rule inet filter forward ip daddr @blocklist drop
# 방법 2: 투명 프록시 (TPROXY) → Squid/mitmproxy SSL Bump
nft add rule inet mangle prerouting \
tcp dport 443 \
tproxy to 127.0.0.1:3129
# 방법 3: Suricata + NFQUEUE 인라인 SNI 차단
# Suricata가 SNI 추출 후 tls.sni 규칙 매칭 → NF_DROP verdict
suricata --runmode workers -q 0 -q 1 -q 2 -q 3 \
-c /etc/suricata/suricata.yaml
# ECH (Encrypted Client Hello) 대응 — TLS 1.3+ 우회 문제
# ECH 활성화 시 SNI 암호화 → Outer SNI만 접근 가능
# ECH 차단: ClientHello에서 ECH Extension(0xfe0d) 감지 후 DROP
NGFW에서 DPI의 역할과 한계
DPI는 NGFW의 핵심 기능이지만, 현실적인 한계도 존재합니다. 리눅스 기반 NGFW에서 NFQUEUE + Suricata + nDPI 조합은 상용 NGFW에 버금가는 기능을 구현할 수 있습니다.
DPI 활용 시나리오
| 기능 | 구현 방법 | 효과 |
|---|---|---|
| IPS 서명 탐지 | Suricata 규칙 + Hyperscan 매칭 | CVE 익스플로잇, 악성 페이로드 차단 |
| 애플리케이션 식별 | nDPI 300+ 프로토콜 분류 | 앱별 QoS/정책, 대역폭 제어 |
| URL 필터링 | HTTP Host/SNI 추출 | 카테고리별 접근 제어, 유해사이트 차단 |
| 악성코드 차단 | 페이로드 해시 + Hyperscan 시그니처 | 알려진 악성 파일/쉘코드 차단 |
| DLP | 콘텐츠 패턴 매칭 (정규식) | 민감 데이터 (주민번호/카드번호) 유출 방지 |
| 봇넷 C&C 탐지 | JA3/JARM + DGA 도메인 탐지 | 봇넷 통신 차단, 감염 호스트 격리 |
| QUIC 분류 | nDPI QUIC + Initial Packet SNI | HTTP/3 트래픽 식별 및 정책 적용 |
| 암호화 트래픽 분류 | JA3 + nDPI 행동 분석 | TLS 터널링 악성코드 탐지 |
리눅스 NFQUEUE 기반 NGFW 아키텍처 스택
# 계층 구조 (상위 → 하위)
# ┌─────────────────────────────────────────────┐
# │ 정책 관리 (Ansible/REST API/nftables ruleset) │
# ├─────────────────────────────────────────────┤
# │ DPI 엔진 클러스터 (Suricata x4 Worker) │
# │ ├─ Hyperscan: IPS 시그니처 매칭 │
# │ ├─ nDPI: 프로토콜/앱 분류 │
# │ └─ JA3/SNI: TLS 지문 + URL 필터링 │
# ├─────────────────────────────────────────────┤
# │ NFQUEUE (queue 0-3, fanout, fail-open) │
# ├─────────────────────────────────────────────┤
# │ nftables (세션 bypass, mark, conntrack) │
# ├─────────────────────────────────────────────┤
# │ TC eBPF (flow_cache 조회, pre-filter) │
# ├─────────────────────────────────────────────┤
# │ XDP (최초 필터, DDoS 방어) │
# ├─────────────────────────────────────────────┤
# │ NIC (RSS 4큐, IRQ affinity, TSO/GSO) │
# └─────────────────────────────────────────────┘
DPI 한계
- TLS 1.3 암호화: 핸드셰이크 이후 페이로드 완전 암호화 → MITM(SSL Inspection) 또는 JA3/JARM 지문 분석으로 보완
- ECH (Encrypted Client Hello): TLS 1.3+에서 SNI까지 암호화 → Outer SNI만 접근 가능, 내부 SNI 추출 불가
- QUIC/HTTP3: UDP 기반 + TLS 1.3 완전 통합 → Initial Packet 이후 분석 불가, Connection ID 추적 필요
- 성능 오버헤드: 10Gbps+ 환경에서 NFQUEUE DPI는 CPU 집약적 → SmartNIC/DPU 오프로드 또는 AF_XDP 병용 필요
- 우회 기법: 분할 전송(TCP Segmentation), 패딩, 다형성 악성코드, 정상 클라우드 서비스 악용
- False positive: 정상 트래픽 오검출 → 규칙 튜닝, Threshold 설정, alert only 모드 검증 필수
- 스트리밍 악성코드: 청크 전송으로 DPI 창 분할 → reassembly 활성화 (Suricata stream.checksum-validation)
판정(Verdict) 처리 심화
NFQUEUE verdict는 단순한 ACCEPT/DROP을 넘어 패킷 수정, 재주입, 큐 재지정 등 다양한 동작을 지원합니다.
커널 내부의 nfqnl_recv_verdict() 함수가 유저스페이스 판정을 받아 처리합니다.
verdict 타입 상세
| verdict | 값 | 커널 동작 | 사용 사례 |
|---|---|---|---|
NF_ACCEPT | 1 | nf_reinject() → 다음 훅으로 전달 | 정상 패킷 통과 |
NF_DROP | 0 | kfree_skb() → 패킷 폐기 | 악성 패킷 차단 |
NF_STOLEN | 2 | skb 소유권을 유저스페이스로 이전, 커널은 더 이상 관여 안 함 | 패킷 캡처 후 수동 재주입 |
NF_QUEUE | 3 | nf_queue() 재호출 → 다른 큐로 전달 | 2단계 DPI 파이프라인 |
NF_REPEAT | 4 | 현재 훅을 처음부터 재실행 | 패킷 수정 후 재검사 |
NF_STOP | 5 | 현재 훅 체인 중단, 이후 훅은 건너뜀 | 성능 최적화 (드물게 사용) |
nfq_set_verdict2() vs nfq_set_verdict_mark()
/* verdict2: verdict + nfmark 동시 설정 (가장 일반적) */
int nfq_set_verdict2(struct nfq_q_handle *qh,
u_int32_t id,
u_int32_t verdict, /* NF_ACCEPT 등 */
u_int32_t mark, /* sk_buff->mark 값 */
u_int32_t data_len, /* 수정된 페이로드 길이 (0=원본) */
const unsigned char *buf); /* 수정된 페이로드 */
/* verdict_mark: 이미 설정된 nfmark를 유지하면서 verdict만 변경 */
/* nfq_set_verdict_mark()는 libnetfilter_queue 1.0.3+ 제공 */
/* 사용 예: Suricata bypass 패턴 */
/* DPI 완료 후 세션에 mark=1 부여 → 이후 패킷은 iptables에서 bypass */
return nfq_set_verdict2(qh, id, NF_ACCEPT,
0x1, /* bypass mark */
0, NULL);
패킷 수정 후 재주입 (페이로드 변경)
/* 패킷 내용을 수정하여 재주입하는 예: HTTP Host 헤더 변조 */
static int mangle_callback(struct nfq_q_handle *qh,
struct nfgenmsg *nfmsg,
struct nfq_data *nfa, void *data)
{
struct nfqnl_msg_packet_hdr *ph = nfq_get_msg_packet_hdr(nfa);
uint32_t id = ntohl(ph->packet_id);
unsigned char *orig_payload;
int orig_len = nfq_get_payload(nfa, &orig_payload);
unsigned char new_payload[65535];
int new_len = modify_http_host(orig_payload, orig_len,
new_payload, sizeof(new_payload));
if (new_len > 0) {
/* 수정된 페이로드로 패킷 재주입 */
/* 커널의 nfqnl_mangle()이 skb를 새 데이터로 교체 */
return nfq_set_verdict2(qh, id, NF_ACCEPT, 0,
new_len, new_payload);
}
return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL);
}
커널 내부 nfqnl_mangle()
/* net/netfilter/nfnetlink_queue.c — 페이로드 수정 내부 구현 */
static int nfqnl_mangle(void *data, unsigned int data_len,
struct nf_queue_entry *e, int diff)
{
struct sk_buff *nskb = e->skb;
if (diff < 0) {
if (skb_trim(nskb, nskb->len + diff))
return -EINVAL;
} else if (diff > 0) {
if (data_len > 0xFFFF)
return -EINVAL;
if (skb_tailroom(nskb) < diff) {
if (pskb_expand_head(nskb, 0, diff - skb_tailroom(nskb), GFP_ATOMIC))
return -ENOMEM;
}
skb_put(nskb, diff);
}
if (skb_store_bits(nskb, 0, data, data_len))
return -EFAULT;
return 0;
}
verdict 실패 시 fallback — NF_QUEUE_NR 매크로
/* 다른 큐로 재지정하는 verdict (2단계 파이프라인) */
/* NF_QUEUE_NR(queue_num): NF_QUEUE verdict에 큐 번호를 인코딩 */
#define NF_QUEUE_NR(x) ((((x) << 16) | NF_QUEUE) & ~INT_MIN)
/* 예: 1단계(큐 0)에서 고위험 패킷을 큐 1(심층 분석)로 전달 */
if (risk_score > THRESHOLD)
return nfq_set_verdict(qh, id, NF_QUEUE_NR(1), 0, NULL);
else
return nfq_set_verdict2(qh, id, NF_ACCEPT, 0x1, 0, NULL);
/* verdict 처리 실패(큐 소비자 없음) 시:
- NFQA_CFG_F_FAIL_OPEN 설정 → NF_ACCEPT (트래픽 유지)
- 미설정 → NF_DROP (보안 우선)
/proc/net/netfilter/nfnetlink_queue의 drop_count 확인
*/
제로카피 NFQUEUE
NFQUEUE의 성능 최대 병목은 커널→유저스페이스 패킷 데이터 복사입니다. 여러 플래그와 설정으로 복사 오버헤드를 줄이거나 완전히 제거할 수 있습니다.
NFQA_CFG_F_GSO — GSO 패킷 원본 전달
/* GSO(Generic Segmentation Offload) 패킷을 분할하지 않고 원본 전달 */
/* 기본 동작: 커널이 GSO 패킷을 MTU 크기로 분할 후 각각 큐잉 → CPU 낭비 */
/* GSO 플래그 활성화: 최대 64KB 슈퍼패킷을 그대로 전달 → 처리 횟수 감소 */
nfq_set_queue_flags(qh,
NFQA_CFG_F_GSO | NFQA_CFG_F_UID_GID | NFQA_CFG_F_CONNTRACK,
NFQA_CFG_F_GSO | NFQA_CFG_F_UID_GID | NFQA_CFG_F_CONNTRACK);
/* DPI 엔진에서 GSO 패킷 길이 확인 */
int len = nfq_get_payload(nfa, &payload);
/* len이 MTU(1500)을 초과하면 GSO 패킷 → 내부 세그먼트 반복 처리 필요 */
NFQA_CFG_F_UID_GID — 소켓 소유자 메타데이터
/* 로컬 소켓의 UID/GID 정보 — 애플리케이션별 정책에 활용 */
uint32_t uid = 0, gid = 0;
nfq_get_uid(nfa, &uid);
nfq_get_gid(nfa, &gid);
/* 예: UID 1000 (일반 사용자) 발생 HTTPS 트래픽만 DPI */
if (uid >= 1000 && tcp_dport == 443) {
/* SNI 추출 후 필터링 */
}
NFQA_CFG_F_CONNTRACK — conntrack 정보 활용
/* conntrack 정보 직접 수신 (별도 /proc 조회 불필요) */
struct nfq_nlmsg_parse_ctx ctx;
nfq_nlmsg_parse(nlh, &ctx);
/* conntrack 상태 확인 */
const struct nf_conntrack *ct = nfq_get_conntrack(nfa);
if (ct) {
uint32_t ct_mark = nfct_get_attr_u32(ct, ATTR_MARK);
if (ct_mark & 0x1) {
/* 이미 분류된 세션 → bypass */
return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL);
}
}
nlattr 파싱 최적화 — nfq_nlmsg_parse()
/* 최신 libnetfilter_queue (1.0.5+) 권장 API */
/* nfq_nlmsg_parse()는 내부적으로 mnl_attr_parse()를 사용하며,
개별 nfq_get_*() 호출보다 한 번에 파싱 → 오버헤드 감소 */
struct nlattr *attr[NFQA_MAX + 1] = {};
int ret = nfq_nlmsg_parse(nlh, attr);
if (attr[NFQA_PACKET_HDR]) {
struct nfqnl_msg_packet_hdr *ph =
mnl_attr_get_payload(attr[NFQA_PACKET_HDR]);
id = ntohl(ph->packet_id);
}
if (attr[NFQA_PAYLOAD]) {
payload = mnl_attr_get_payload(attr[NFQA_PAYLOAD]);
payload_len = mnl_attr_get_payload_len(attr[NFQA_PAYLOAD]);
}
if (attr[NFQA_UID])
uid = ntohl(mnl_attr_get_u32(attr[NFQA_UID]));
배치 처리 최적화
/* 배치 verdict: 여러 패킷 판정을 한 번의 netlink 전송으로 묶기 */
/* Suricata batchcount 설정 (suricata.yaml) */
/* nfq: batchcount: 20 → 20개 verdict를 하나의 sendmsg()로 전송 */
/* 직접 구현 시 — verdict 버퍼링 패턴 */
static struct {
uint32_t id[32];
uint32_t verdict[32];
int count;
} verdict_batch;
if (++verdict_batch.count >= 20) {
flush_verdict_batch(&verdict_batch); /* 한 번에 전송 */
verdict_batch.count = 0;
}
Hyperscan 기반 고성능 패턴 매칭
Intel Hyperscan은 SIMD 명령어(SSE4/AVX2/AVX-512)를 활용한 고성능 정규식 라이브러리로, Snort 3와 Suricata의 기본 패턴 매칭 엔진으로 채택되었습니다. NFQUEUE + Hyperscan 조합은 수천 개의 시그니처를 초고속으로 매칭합니다.
Hyperscan vs Aho-Corasick 성능 비교
| 항목 | Aho-Corasick | Hyperscan (AVX2) | Hyperscan (AVX-512) |
|---|---|---|---|
| 알고리즘 | 유한 오토마톤(DFA) | NFA + SIMD 병렬화 | NFA + 512비트 병렬화 |
| 패턴 1000개 @ 1Gbps | ~850 Mbps | ~9.5 Gbps | ~18 Gbps |
| 메모리 사용 | 패턴당 선형 | 컴파일 시 고정 | 컴파일 시 고정 |
| PCRE 지원 | 제한적 | 완전 지원 (HS_FLAG_*) | 완전 지원 |
| 실시간 컴파일 | 가능 | hs_compile() 필요 (오프라인) | hs_compile() 필요 |
| 스트림 매칭 | 가능 | hs_stream_t 모드 | hs_stream_t 모드 |
NFQUEUE + Hyperscan 통합 아키텍처 SVG
hs_compile() / hs_scan() API 패턴 예제
#include <hs/hs.h>
/* 패턴 컴파일 (프로그램 시작 시 1회) */
static const char *patterns[] = {
"(?i)X-Malware:", /* 악성 HTTP 헤더 */
"(?i)cmd\\.exe", /* Windows 쉘 명령어 */
"\\xde\\xad\\xbe\\xef", /* 알려진 악성코드 시그니처 */
};
static const unsigned flags[] = {
HS_FLAG_CASELESS | HS_FLAG_SINGLEMATCH,
HS_FLAG_CASELESS | HS_FLAG_SINGLEMATCH,
HS_FLAG_SINGLEMATCH,
};
static const unsigned ids[] = { 0, 1, 2 };
hs_database_t *db;
hs_compile_error_t *err;
hs_compile_multi(patterns, flags, ids,
sizeof(patterns) / sizeof(patterns[0]),
HS_MODE_BLOCK, NULL, &db, &err);
hs_scratch_t *scratch;
hs_alloc_scratch(db, &scratch); /* 스레드당 1개 */
/* 매칭 콜백 */
static int on_match(unsigned int id, unsigned long long from,
unsigned long long to, unsigned int flags, void *ctx)
{
*(int *)ctx = id + 1; /* 매칭된 패턴 ID 기록 */
return 1; /* 1 반환 시 스캔 중단 (첫 매칭 후 종료) */
}
/* 패킷마다 호출 (hs_scratch는 스레드 로컬) */
int matched_id = 0;
hs_scan(db, (const char *)payload, payload_len,
0, scratch, on_match, &matched_id);
if (matched_id > 0)
nfq_set_verdict(qh, id, NF_DROP, 0, NULL);
else
nfq_set_verdict2(qh, id, NF_ACCEPT, 0x1, 0, NULL);
Snort/Suricata에서의 Hyperscan 활용
# Suricata — Hyperscan 빌드 확인
suricata --build-info | grep -i hyperscan
# Hyperscan support: yes
# Snort 3 — Hyperscan DAQ 빌드
cmake -DENABLE_HYPERSCAN=ON ..
snort3 --daq nfq --daq-var device=eth0 -Q
# nDPI + Hyperscan: nDPI 자체는 Hyperscan을 직접 사용하지 않으나,
# 커스텀 DPI 파이프라인에서 1차 Hyperscan 매칭 후 2차 nDPI 분류 조합 가능
# 예: 포트 기반 프리필터(eBPF) → Hyperscan 시그니처 → nDPI 프로토콜 분류
QUIC/HTTP3 DPI 과제
QUIC은 UDP 위에서 TLS 1.3을 직접 통합한 차세대 전송 프로토콜로, 기존 TCP 기반 DPI 기법을 그대로 적용할 수 없습니다. HTTP/3은 QUIC 위에서 동작하며, 전 세계 웹 트래픽의 30% 이상을 차지합니다.
QUIC 프로토콜 구조
| 계층 | TCP+TLS 기반 | QUIC 기반 | DPI 영향 |
|---|---|---|---|
| 전송 계층 | TCP (커널 처리) | UDP (유저스페이스 구현) | TCP 상태 추적 불가 |
| 암호화 | TLS 1.2/1.3 (레코드 헤더 평문) | TLS 1.3 완전 통합 | 핸드셰이크 이후 완전 암호화 |
| 헤더 | IP+TCP 헤더 평문 | Short Header 암호화 가능 | Long Header만 파싱 가능 |
| 연결 식별 | 5-tuple | Connection ID (DCID/SCID) | NAT 뒤에서도 추적 가능 |
| SNI 위치 | ClientHello Extension (평문) | Initial Packet ClientHello (평문) | Initial Packet에서만 추출 |
QUIC DPI의 기술적 한계
- QUIC Bit 그리닝(Greasing): RFC 9000에서 Fixed Bit를 임의로 설정 가능하도록 허용 → 시그니처 매칭 어려움
- Connection Migration: IP 주소/포트 변경 시 Connection ID로 세션 유지 → 5-tuple 기반 추적 실패
- 0-RTT 재개: 이전 세션 재개 시 ClientHello 없이 즉시 암호화 데이터 전송 → SNI 추출 불가
- QUIC 버전 다양성: QUIC v1(RFC 9000), QUIC v2(RFC 9369) 등 버전별 패킷 형식 차이
QUIC Initial Packet — SNI 추출
nDPI의 QUIC 지원 현황
/* nDPI 4.x QUIC 지원 — ndpi_flow_struct 내부 */
if (protocol.master_protocol == NDPI_PROTOCOL_QUIC) {
/* SNI 필드: protos.tls_quic.client_requested_server_name */
char *sni = ndpi_flow.protos.tls_quic.client_requested_server_name;
/* ALPN: protos.tls_quic.alpn */
char *alpn = ndpi_flow.protos.tls_quic.alpn; /* "h3", "h3-29" 등 */
/* QUIC 버전 */
uint32_t quic_ver = ndpi_flow.protos.tls_quic.quic_version;
}
HTTP/3 vs HTTP/2 DPI 복잡도 비교
| 항목 | HTTP/1.1 | HTTP/2 (TLS) | HTTP/3 (QUIC) |
|---|---|---|---|
| 전송 | TCP | TCP + TLS | UDP + QUIC (TLS 통합) |
| 헤더 평문 여부 | 완전 평문 | ALPN/SNI만 평문 | Initial Packet만 파싱 가능 |
| SNI 추출 | Host 헤더 | TLS ClientHello | QUIC Initial ClientHello |
| 멀티플렉싱 | 없음 | Stream (HOL 블로킹) | Stream (독립적) |
| DPI 난이도 | 쉬움 | 중간 | 어려움 |
| nDPI 지원 | 완전 | 완전 (JA3) | 부분 (v4.x) |
| NFQUEUE 접근법 | TCP + L7 파싱 | SNI/JA3 추출 | Initial Packet 파싱 + CID 추적 |
UDP NFQUEUE를 통한 QUIC 트래픽 분류
# QUIC(UDP/443) 패킷을 NFQUEUE로 전달
iptables -I FORWARD -p udp --dport 443 -j NFQUEUE \
--queue-num 1 --queue-bypass
# nftables 버전
nft add rule inet filter forward \
udp dport 443 queue num 1 bypass
# QUIC 차단 (QUIC Fallback → TCP 강제)
# QUIC 차단 시 브라우저는 자동으로 TCP+TLS로 폴백
iptables -I FORWARD -p udp --dport 443 -j DROP
# 이 경우 DPI 없이도 HTTP/3 사용 차단 가능
분산 NFQUEUE 아키텍처
고트래픽 환경에서 단일 NFQUEUE + 단일 DPI 프로세스는 병목이 됩니다.
--queue-balance와 RSS를 조합하면 다수의 NIC 큐를 복수의 Worker 프로세스에 분산할 수 있습니다.
NFQUEUE 클러스터 모드 설정
# nftables — queue balance (0~3 큐에 균등 분산)
nft add table inet dpi_cluster
nft add chain inet dpi_cluster forward { type filter hook forward priority 0\; }
nft add rule inet dpi_cluster forward \
queue num 0-3 flags bypass,fanout
# iptables — --queue-balance N:M 옵션
# 5-tuple 해시로 4개 큐(0~3)에 분산, 큐 없으면 bypass
iptables -I FORWARD -j NFQUEUE \
--queue-num 0 \
--queue-balance \
--queue-bypass
# 실제 범위 지정 (iptables 1.4.12+)
iptables -I FORWARD -j NFQUEUE --queue-num 0:3 --queue-bypass
RSS + NFQUEUE 파티셔닝
# 1단계: NIC RSS 4개 큐 설정
ethtool -L eth0 combined 4
ethtool -X eth0 hfunc toeplitz # Toeplitz 해시 (5-tuple 기반)
# 2단계: RSS 큐 → CPU 고정
for i in 0 1 2 3; do
echo $((1 << $i)) > /proc/irq/$(grep "eth0-rx-$i" \
/proc/interrupts | cut -d: -f1 | tr -d ' ')/smp_affinity
done
# 3단계: NFQUEUE fanout — CPU i는 큐 i를 처리
iptables -I FORWARD -j NFQUEUE --queue-num 0:3 --queue-bypass
# 4단계: Worker 프로세스 — CPU affinity + 큐 번호 매핑
for i in 0 1 2 3; do
taskset -c $i suricata \
--runmode single \
-q $i \
-c /etc/suricata/suricata.yaml &
done
클러스터 아키텍처 SVG
Suricata AF_PACKET cluster_flow 방식과의 비교
| 항목 | NFQUEUE fanout | AF_PACKET cluster_flow | AF_XDP |
|---|---|---|---|
| 커널 경유 | Netfilter 전체 | XDP 이후 | XDP 직접 |
| 패킷 복사 | netlink 복사 | 소켓 버퍼 복사 | 제로카피 (UMEM) |
| 처리량 | 1~3 Mpps | 3~8 Mpps | 10~30 Mpps |
| IPS 지원 | 완전 (verdict) | 인라인 어렵 | 가능 (XDP_DROP) |
| Netfilter 연동 | 완전 | 없음 | 없음 |
| 배포 복잡도 | 낮음 | 중간 | 높음 |
장애 복구 — fail-open/fail-closed 정책
# 방법 1: fail-open (큐 소비자 없을 때 ACCEPT)
# nfq_set_queue_flags(qh, NFQA_CFG_F_FAIL_OPEN, NFQA_CFG_F_FAIL_OPEN);
# 서비스 연속성 우선 — 보안보다 가용성 중시
# 방법 2: fail-closed (큐 소비자 없을 때 DROP)
# nfq_set_queue_flags(qh, NFQA_CFG_F_FAIL_OPEN, 0); (기본값)
# 보안 우선 — 차단이 허용보다 안전한 환경 (금융/정부)
# Worker 프로세스 자동 복구 (systemd)
cat > /etc/systemd/system/suricata-queue0.service <<'EOF'
[Unit]
Description=Suricata IPS Queue 0
After=network.target
[Service]
ExecStart=/usr/bin/suricata -c /etc/suricata/suricata.yaml \
--runmode single -q 0
CPUAffinity=0
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
systemctl enable suricata-queue{0,1,2,3}
커널 소스 구조
NFQUEUE 관련 커널 소스 파일과 주요 심볼 목록입니다.
커널 6.x 기준으로 net/netfilter/ 디렉터리에 집중되어 있습니다.
net/netfilter/
├── nfnetlink_queue.c # NFQUEUE 핵심 구현 (nfqnl_instance, enqueue, verdict)
├── nfnetlink.c # nfnetlink 서브시스템 (netlink 메시지 디스패치)
├── nf_queue.c # 큐 공통 인프라 (nf_queue_entry, nf_reinject)
├── xt_NFQUEUE.c # iptables/xtables NFQUEUE 타겟 구현
├── nft_queue.c # nftables queue expression 구현
└── nf_conntrack_helper.c # conntrack 헬퍼 (ALG 연동)
include/uapi/linux/netfilter/
├── nfnetlink_queue.h # NFQUEUE 사용자 API — NFQA_* 속성, 플래그 정의
├── nfnetlink.h # nfnetlink 메시지 타입 (NFNL_SUBSYS_QUEUE)
└── nf_tables.h # nftables 관련 헤더 (NFT_QUEUE_ATTR_*)
include/linux/netfilter/
├── nf_queue.h # nf_queue_entry 구조체 정의
└── nfnetlink_queue.h # nfqnl_instance 내부 헤더
주요 심볼 및 역할
| 심볼 | 파일 | 역할 |
|---|---|---|
nfqnl_enqueue_packet() | nfnetlink_queue.c | 패킷을 큐에 삽입 — skb → netlink 메시지 변환 |
nfqnl_recv_verdict() | nfnetlink_queue.c | 유저 verdict 수신 — nf_reinject() 호출 |
nfqnl_recv_config() | nfnetlink_queue.c | 큐 설정 수신 — copy_mode, maxlen, flags 적용 |
nfqnl_build_packet_message() | nfnetlink_queue.c | skb → netlink nlattr 직렬화 |
nfqnl_flush() | nfnetlink_queue.c | 미처리 패킷 일괄 해제 (fail-open/closed) |
nfqnl_mangle() | nfnetlink_queue.c | 유저 수정 페이로드 → skb 재작성 |
nf_queue_entry_free() | nf_queue.c | 큐 엔트리 해제 (skb, 훅 참조 카운터 해제) |
nf_reinject() | nf_queue.c | verdict 후 패킷을 Netfilter 훅으로 재주입 |
nft_queue_eval() | nft_queue.c | nftables queue expression 평가 |
instance_create() | nfnetlink_queue.c | 새 nfqnl_instance 생성 및 해시 등록 |
instance_lookup() | nfnetlink_queue.c | queue_num으로 nfqnl_instance RCU 조회 |
커널 커밋 추적 요점
# NFQUEUE 관련 주요 커밋 확인
git log --oneline net/netfilter/nfnetlink_queue.c | head -20
# 특정 기능 추가 버전 확인
# NFQA_CFG_F_GSO: v3.6 (2012) — commit 0ef0f4658)
# NFQA_CFG_F_UID_GID: v3.14 (2014)
# NFQA_CFG_F_SECCTX: v4.3 (2015)
# nfq_nlmsg_parse() API: libnetfilter_queue 1.0.3 (2018)
# QUIC Initial Packet 파싱: nDPI 4.0+ (2022)
# 소스 심볼 검색
grep -n "nfqnl_instance" net/netfilter/nfnetlink_queue.c | head -10
진단 및 모니터링
NFQUEUE 동작 상태를 모니터링하는 방법입니다.
/proc/net/netfilter/nfnetlink_queue의 통계와 bpftrace 기반 실시간 측정을 조합하면 성능 병목을 정밀하게 파악할 수 있습니다.
큐 통계 확인
# NFQUEUE 통계 확인
cat /proc/net/netfilter/nfnetlink_queue
# 컬럼: queue_num portid queue_total copy_mode copy_range drop_count user_drop_count seq_id
# 예시: 0 1234 45 2 65535 12 0 1000
# drop_count: 큐 포화 드롭 (NFQA_CFG_F_FAIL_OPEN 미설정 시 DROP)
# user_drop_count: 유저스페이스 verdict 오류 드롭
# nfqueue 상태 (conntrack 도구 활용)
conntrack -S
# ss로 netlink 소켓 확인
ss -f netlink
# 출력 예: nl UNCONN 0 0 * 1234 * * users:(("suricata",pid=1234,fd=3))
# 큐 드롭 카운터 실시간 모니터링
watch -n 1 'awk "{printf \"Q%s: total=%s dropped=%s user_drop=%s\n\",\$1,\$3,\$6,\$7}" \
/proc/net/netfilter/nfnetlink_queue'
# 큐 드롭이 증가하면 → maxlen 증가 또는 fail-open 설정 필요
성능 분석 — bpftrace 기반
# 큐잉/판정 처리량 측정
bpftrace -e '
kprobe:nfqnl_enqueue_packet {
@enqueue = count();
@enqueue_tid[tid] = nsecs;
}
kprobe:nfqnl_recv_verdict {
@verdict = count();
}
interval:s:1 {
printf("enqueue/s: %d, verdict/s: %d\n", @enqueue, @verdict);
clear(@enqueue); clear(@verdict);
}'
# verdict 처리 지연(latency) 히스토그램
bpftrace -e '
kprobe:nfqnl_enqueue_packet { @ts[arg1] = nsecs; }
kprobe:nfqnl_recv_verdict {
$start = @ts[arg0];
if ($start > 0) {
@lat = hist((nsecs - $start) / 1000); /* us 단위 */
delete(@ts[arg0]);
}
}
interval:s:10 { print(@lat); }'
# perf로 NFQUEUE 컨텍스트 스위치 오버헤드 측정
perf stat -e context-switches,cache-misses,instructions,cycles \
-p $(pidof suricata) sleep 10
# Suricata 내부 NFQ 통계
suricatasc -c dump-counters | jq '.nfq'
# nfq.packets: 처리된 패킷 수
# nfq.verdicts: 판정 횟수
# nfq.err_recv: recv 오류 수
# Suricata 내부 경고 확인
grep -i "nfqueue\|nfq" /var/log/suricata/suricata.log
DPI 패턴 매칭 성능 프로파일링
# Hyperscan 스캔 시간 측정
bpftrace -e '
uprobe:/usr/lib/libhs.so:hs_scan { @start[tid] = nsecs; }
uretprobe:/usr/lib/libhs.so:hs_scan {
@hs_scan_lat = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}
interval:s:5 { print(@hs_scan_lat); }'
# nDPI detection 시간 측정
bpftrace -e '
uprobe:/usr/lib/libndpi.so:ndpi_detection_process_packet {
@start[tid] = nsecs;
}
uretprobe:/usr/lib/libndpi.so:ndpi_detection_process_packet {
@ndpi_lat = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}
interval:s:5 { print(@ndpi_lat); }'
# perf record로 hotspot 함수 파악
perf record -g -p $(pidof suricata) sleep 30
perf report --stdio | head -40
트러블슈팅
| 증상 | 원인 | 해결책 |
|---|---|---|
| 패킷 DROP 증가 | 큐 포화 (maxlen 초과) | nfq_set_queue_maxlen(qh, 16384) 증가, Fail-open 활성화 |
| 레이턴시 급증 | DPI 처리 병목 | fanout 큐 증가, bypass 규칙 추가, Hyperscan 도입 |
| DPI 엔진 응답 없음 | 프로세스 크래시 | Fail-open 설정, systemd Restart=always 감시 |
| 연결 끊김 | verdict 타임아웃 | 큐 maxlen 증가 또는 DPI 처리 시간 단축 |
| CPU 100% | 전체 트래픽 DPI | 세션 bypass 규칙 추가, eBPF pre-filter 도입 |
| QUIC 트래픽 미분류 | QUIC DPI 미지원 | nDPI 4.x 업그레이드, UDP NFQUEUE 큐 추가 |
| Hyperscan 컴파일 오류 | AVX2 미지원 CPU | hs_compile() 시 HS_MODE_BLOCK + 소프트웨어 폴백 처리 |
| user_drop_count 증가 | 유저 소켓 버퍼 부족 | SO_RCVBUF 4MB 이상 설정, rmem_max sysctl 증가 |
관련 문서
- Netfilter 프레임워크 심화 — 훅 시스템, nftables, conntrack 기초
- nf_conntrack 헬퍼 (ALG) — FTP/SIP ALG 내부 구조
- Netfilter Flowtable — 확립된 세션 고속 처리
- BPF/eBPF/XDP — eBPF L7 분류 기초, XDP_DROP 패킷 필터링
- eBPF 기반 보안 정책 — BPF LSM, cgroup 방화벽, flow_cache
- TPROXY (투명 프록시) — TLS 인터셉트 프록시, QUIC TPROXY
- 네트워크 스택 고급 — NAPI, RSS 멀티코어 분산, SO_BUSY_POLL
- IPsec/XFRM 심화 — 터널 모드 DPI와 NFQUEUE 연동
- AF_XDP — 제로카피 패킷 처리, UMEM 기반 고성능 대안
- Kernel TLS (kTLS) — TLS 오프로드, SNI/JA3 추출 대안 접근