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 패킷 처리 흐름
NFQUEUE 커널 내부 함수 호출 경로
위 다이어그램은 패킷이 NFQUEUE 타겟을 만났을 때 커널 내부에서 일어나는 전체 함수 호출 경로입니다.
NF_HOOK() 매크로에서 시작하여 nf_hook_slow()가 등록된 훅 함수를 순회하다 NFQUEUE verdict를 만나면 nf_queue()가 호출됩니다.
nfqnl_enqueue_packet()에서 Netlink 메시지를 구성한 뒤 netlink_unicast()로 유저 공간에 전달합니다.
유저 공간에서 verdict가 돌아오면 nfqnl_recv_verdict() → nf_reinject() 경로로 패킷이 Netfilter 훅 체인으로 복귀합니다.
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 보안 컨텍스트 포함 |
nf_queue_entry 구조체 상세
NFQUEUE에 대기 중인 각 패킷은 struct nf_queue_entry로 관리됩니다. 이 구조체는 원본 패킷(skb)뿐 아니라 해당 패킷이 Netfilter 훅 체인의 어느 지점에서 큐에 들어갔는지(state)를 기록하여, verdict 수신 후 정확한 위치에서 처리를 재개할 수 있게 합니다.
/* include/net/netfilter/nf_queue.h */
struct nf_queue_entry {
struct list_head list; /* 큐 연결 리스트 노드 */
struct sk_buff *skb; /* 대기 중인 원본 패킷 버퍼 */
unsigned int id; /* 큐 내 고유 패킷 식별자 (유저 공간 verdict 매칭용) */
unsigned int hook; /* 패킷이 큐에 진입한 Netfilter 훅 포인트 (NF_INET_*) */
struct nf_hook_state state; /* 훅 실행 상태 전체 스냅샷 */
u16 size; /* 유저 공간에 복사할 페이로드 크기 (copy_range) */
u16 reason; /* 큐 진입 사유 (verdict 비트 디코딩) */
struct nf_hook_entry *hook_entry; /* 큐 진입 시점의 훅 엔트리 (reinject 복귀 지점) */
};
/* nf_hook_state: 훅 실행 컨텍스트 전체 보존 */
struct nf_hook_state {
u8 hook; /* NF_INET_PRE_ROUTING, NF_INET_LOCAL_IN 등 */
u8 pf; /* 프로토콜 패밀리 (NFPROTO_IPV4/IPV6) */
struct net_device *in; /* 입력 네트워크 디바이스 */
struct net_device *out; /* 출력 네트워크 디바이스 */
struct sock *sk; /* 연관 소켓 (있는 경우) */
struct net *net; /* 네트워크 네임스페이스 */
int (*okfn)(struct net *, struct sock *, struct sk_buff *);
/* 훅 체인 통과 후 호출할 함수 (ip_rcv_finish 등) */
};
nf_queue_entry_get_refs()는 큐 진입 시 skb, in/out 디바이스, conntrack 엔트리(nf_ct)의 참조 카운트를 모두 증가시킵니다. 이는 패킷이 유저 공간 verdict를 기다리는 동안 관련 커널 객체가 해제되는 것을 방지합니다. verdict 처리 후 nf_queue_entry_free()에서 모든 참조를 정리합니다.
NFQNL_COPY_PACKET 모드에서 size 값이 0이면 전체 패킷을, 그 외에는 지정된 바이트만큼만 유저 공간에 복사합니다. 메타데이터만 필요한 경우 NFQNL_COPY_META를 사용하면 성능이 크게 향상됩니다.
NFQUEUE Netlink 메시지 형식
NFQUEUE는 Netlink 소켓(NETLINK_NETFILTER)을 통해 커널과 유저 공간 사이에서 패킷 데이터를 교환합니다. 아래 다이어그램은 하나의 NFQUEUE 메시지가 Netlink 프로토콜 위에서 어떤 바이너리 레이아웃으로 구성되는지 보여줍니다.
NFQA_PACKET_HDR의 packet_id와 hw_protocol은 ntohl()/ntohs()로 변환해야 합니다. libnetfilter_queue의 nfq_get_msg_packet_hdr() 함수가 이 변환을 자동으로 처리합니다.
NFQA_* 속성 전체 목록
NFQUEUE Netlink 프로토콜에서 사용하는 모든 NFQA_* 속성입니다. 커널 소스 include/uapi/linux/netfilter/nfnetlink_queue.h에 정의되어 있습니다.
| 상수 | 값 | 타입 | 방향 | 설명 |
|---|---|---|---|---|
NFQA_UNSPEC | 0 | — | — | 미사용 (속성 인덱스 시작점) |
NFQA_PACKET_HDR | 1 | struct nfqnl_msg_packet_hdr | K→U | 패킷 ID, 하드웨어 프로토콜, 훅 번호 |
NFQA_VERDICT_HDR | 2 | struct nfqnl_msg_verdict_hdr | U→K | 판정 결과 (NF_ACCEPT/NF_DROP 등) 및 패킷 ID |
NFQA_MARK | 3 | __be32 | 양방향 | 패킷의 nfmark 값 (iptables -j MARK 설정값) |
NFQA_TIMESTAMP | 4 | struct nfqnl_msg_packet_timestamp | K→U | 패킷 수신 타임스탬프 (초 + 마이크로초) |
NFQA_IFINDEX_INDEV | 5 | __be32 | K→U | 입력 네트워크 인터페이스 인덱스 |
NFQA_IFINDEX_OUTDEV | 6 | __be32 | K→U | 출력 네트워크 인터페이스 인덱스 |
NFQA_IFINDEX_PHYSINDEV | 7 | __be32 | K→U | 물리 입력 인터페이스 인덱스 (브릿지 환경) |
NFQA_IFINDEX_PHYSOUTDEV | 8 | __be32 | K→U | 물리 출력 인터페이스 인덱스 (브릿지 환경) |
NFQA_HWADDR | 9 | struct nfqnl_msg_packet_hw | K→U | 소스 하드웨어(MAC) 주소 |
NFQA_PAYLOAD | 10 | 바이너리 데이터 | 양방향 | 패킷 페이로드 (copy_range 크기만큼, verdict 시 수정 데이터) |
NFQA_CT | 11 | nested (CTA_*) | K→U | conntrack 정보 (NFQA_CFG_F_CONNTRACK 설정 시) |
NFQA_CT_INFO | 12 | __be32 | K→U | conntrack 상태 (IP_CT_NEW, IP_CT_ESTABLISHED 등) |
NFQA_CAP_LEN | 13 | __be32 | K→U | 원본 패킷의 실제 길이 (copy_range로 잘린 경우) |
NFQA_SKB_INFO | 14 | __be32 | K→U | SKB 메타 정보 (GSO 타입, 체크섬 상태 등 비트 플래그) |
NFQA_EXP | 15 | nested | K→U | conntrack expectation 정보 |
NFQA_UID | 16 | __be32 | K→U | 패킷 소유 소켓의 UID (NFQA_CFG_F_UID_GID 설정 시) |
NFQA_GID | 17 | __be32 | K→U | 패킷 소유 소켓의 GID (NFQA_CFG_F_UID_GID 설정 시) |
NFQA_SECCTX | 18 | 문자열 | K→U | SELinux 보안 컨텍스트 (NFQA_CFG_F_SECCTX 설정 시) |
NFQA_VLAN | 19 | nested | K→U | VLAN 태그 정보 (NFQA_VLAN_TCI, NFQA_VLAN_PROTO 포함) |
NFQA_L2HDR | 20 | 바이너리 데이터 | K→U | Layer 2 헤더 (NFQA_CFG_F_GSO 설정 시, Ethernet 헤더 포함) |
NFQA_PRIORITY | 21 | __be32 | K→U | 패킷 우선순위 (skb->priority) |
NFQA_CGROUP | 22 | __be32 | K→U | 소켓의 cgroup classid (net_cls) |
NFQA_CT, NFQA_UID/NFQA_GID, NFQA_SECCTX 등은 기본적으로 포함되지 않습니다. NFQA_CFG_CMD로 큐 바인딩 후, NFQA_CFG_FLAGS와 NFQA_CFG_MASK를 사용하여 해당 플래그를 명시적으로 활성화해야 합니다.
NFQA_PAYLOAD를 포함하면 커널이 원본 skb의 데이터를 교체합니다. 이때 IP/TCP 체크섬은 자동 재계산되지 않으므로, 유저 공간에서 직접 체크섬을 갱신하거나 NFQA_SKB_INFO의 NFQA_SKB_CSUMNOTREADY 플래그를 확인하여 하드웨어 체크섬 오프로드를 활용해야 합니다.
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);
}
Suricata IPS 내부 파이프라인 상세
Suricata가 NFQUEUE로부터 패킷을 수신한 후 내부적으로 거치는 처리 단계를 상세히 살펴본다. 단일 패킷이 verdict를 받기까지 여러 엔진 모듈을 순차적으로 통과하며, 각 단계에서 프로토콜 분석과 시그니처 매칭이 이루어진다.
Suricata 멀티 테넌트 NFQUEUE 설정
# /etc/suricata/suricata.yaml — 멀티 테넌트 NFQUEUE 설정
nfq:
mode: accept # repeat | route | accept
fail-open: yes
batchcount: 20
multi-detect:
enabled: yes
default: yes
selector: direct # vlan | direct | device
loaders:
- id: 1
yaml: tenant-1.yaml # 고객 A 룰셋
- id: 2
yaml: tenant-2.yaml # 고객 B 룰셋
mappings:
- tenant-id: 1
device: eth0 # 또는 vlan-id: 100
- tenant-id: 2
device: eth1
# 각 tenant별 NFQUEUE 분리 (iptables)
# iptables -I FORWARD -i eth0 -j NFQUEUE --queue-num 0
# iptables -I FORWARD -i eth1 -j NFQUEUE --queue-num 1
Snort 3 DAQ (Data Acquisition) 심화
Snort 3는 Snort 2와 달리 DAQ(Data Acquisition) 추상화 계층을 도입하여 패킷 수집 소스를 모듈화하였다. NFQUEUE 연동 시 daq_nfq 모듈을 사용하며, inspector 체인을 통해 패킷 분석이 이루어진다.
Snort 3 Inspector 계층 구조
| Inspector | 유형 | 역할 | 처리 순서 |
|---|---|---|---|
binder | Control | 플로우를 적절한 서비스 inspector에 바인딩 | 1단계 |
wizard | Control | 프로토콜 자동 탐지 (magic pattern 기반) | 2단계 |
stream | Network | TCP/UDP 세션 트래킹, 스트림 재조립 | 3단계 |
stream_tcp | Network | TCP 3-way handshake, 재전송 처리 | 3-1단계 |
stream_udp | Network | UDP 세션 상태 관리 | 3-2단계 |
http_inspect | Service | HTTP/1.x, HTTP/2 파싱 및 정규화 | 4단계 |
ssl | Service | TLS handshake 분석, JA3 추출 | 4단계 |
dce_smb | Service | SMB/DCE-RPC 프로토콜 분석 | 4단계 |
dns | Service | DNS 쿼리/응답 파싱 | 4단계 |
normalize | Packet | IP/TCP 정규화 (TTL, 옵션 제거 등) | 전처리 |
port_scan | Probe | 포트 스캔 탐지 | 후처리 |
appid | Control | 애플리케이션 식별 (AppID 라이브러리) | 3~4단계 |
Snort 3 + NFQUEUE 설정 예제
-- /etc/snort/snort.lua — Snort 3 NFQUEUE 모드 설정
-- DAQ 설정: nfq 모듈 사용
daq =
{
module_dirs =
{
'/usr/lib/daq/',
},
modules =
{
{
name = 'nfq',
mode = 'inline',
variables =
{
'queue=0', -- NFQUEUE 번호
'queue_maxlen=8192', -- 큐 최대 길이
'fail_open', -- 장애 시 ACCEPT
},
},
},
}
-- Inspector 파이프라인
binder =
{
{
when = { proto = 'tcp', ports = '80 8080' },
use = { type = 'http_inspect' },
},
{
when = { proto = 'tcp', ports = '443' },
use = { type = 'ssl' },
},
{
when = { proto = 'any' },
use = { type = 'wizard' },
},
}
stream = { }
stream_tcp = { policy = 'linux' }
http_inspect = { }
ssl = { }
-- IPS 정책
ips =
{
enable_builtin_rules = true,
include = 'rules/snort3-community.rules',
variables =
{
nets = { HOME_NET = '192.168.0.0/16' },
},
action_override = 'alert', -- IPS 모드에서는 'drop' 사용
}
# Snort 3 실행 (NFQUEUE 모드)
sudo snort -c /etc/snort/snort.lua --daq nfq \
--daq-var queue=0 --daq-var queue_maxlen=8192 \
-Q -l /var/log/snort/ -k none
# iptables NFQUEUE 룰 (Snort 3용)
sudo iptables -I FORWARD -j NFQUEUE --queue-num 0 \
--queue-bypass --fail-open
DPI 엔진 상세 비교
NFQUEUE 기반 DPI를 구현할 때 선택할 수 있는 주요 엔진의 기능, 성능, 라이선스를 상세히 비교한다.
| 항목 | Suricata 7.x | nDPI 4.x | Snort 3.x |
|---|---|---|---|
| 아키텍처 | 멀티스레드 (worker 모드) | 라이브러리 (libndpi) | 모듈형 (inspector 체인) |
| 프로토콜 탐지 수 | ~80+ app-layer 파서 | ~350+ 프로토콜 | ~100+ inspector 기반 |
| TLS JA3 지원 | JA3 + JA3S 기본 내장 | JA3 + JA3S + HASSH | JA3 내장 (ssl inspector) |
| TLS JA4 지원 | JA4+ (7.0.3+, 실험적) | JA4 (4.8+) | 미지원 (플러그인 필요) |
| QUIC 지원 | QUIC 파서 내장 (Initial 패킷 분석) | QUIC/HTTP3 프로토콜 탐지 | 제한적 (기본 탐지만) |
| 패턴 엔진 | Hyperscan (Intel), AC 대체 가능 | Aho-Corasick 내장 | Hyperscan 기본, Literal Search 대체 |
| 룰 포맷 | Suricata Rules (ET Open/Pro) | C API (콜백 기반) | Snort Rules (Community/SO) |
| 성능 (10G 기준) | ~3-5 Mpps (worker 모드) | ~8-12 Mpps (라이브러리) | ~2-4 Mpps (inline 모드) |
| 메모리 사용량 | ~2-8 GB (룰셋 크기 비례) | ~50-200 MB (경량) | ~1-4 GB (룰셋 비례) |
| 라이선스 | GPL v2 | LGPL v3 | GPL v2 |
| NFQUEUE 연동 | 기본 내장 (nfq runmode) | 사용자 코드에서 직접 연동 | DAQ daq_nfq 모듈 |
| Lua 스크립팅 | 지원 (output + detect) | 미지원 | 미지원 (removed in 3.x) |
| EVE JSON 로깅 | 기본 내장 (상세 메타데이터) | 해당 없음 (라이브러리) | JSON alert 출력 지원 |
| 상용 룰셋 | ET Pro (Proofpoint) | 해당 없음 | Snort Subscriber (Cisco Talos) |
| 주요 사용처 | IDS/IPS, NSM, NGFW | 트래픽 분류, QoS, 방화벽 내장 | IDS/IPS, Cisco 생태계 |
nftables Flowtable + NFQUEUE 연동
nftables의 flowtable 기능은 커널 내에서 conntrack 기반 패킷 오프로드를 수행하여 netfilter 훅을 우회(bypass)한다. 이를 NFQUEUE DPI와 결합하면 첫 번째 패킷만 DPI 검사를 수행하고, 분류 완료 후 후속 패킷을 flowtable으로 오프로드하여 극적인 성능 향상을 달성할 수 있다.
nftables Flowtable + NFQUEUE 설정
#!/usr/sbin/nft -f
# Flowtable + NFQUEUE DPI 연동 설정
flush ruleset
table inet filter {
# flowtable 정의: 오프로드 대상 인터페이스
flowtable ft {
hook ingress priority 0
devices = { eth0, eth1 }
flags offload # 하드웨어 오프로드 시도 (NIC 지원 시)
}
chain forward {
type filter hook forward priority 0; policy accept;
# 1단계: 이미 분류된 플로우는 flowtable으로 오프로드
# ct mark 0x1 = DPI 허용, 0x2 = DPI 차단
ct mark 0x1 flow add @ft counter accept
ct mark 0x2 counter drop
# 2단계: 새 연결(미분류)만 NFQUEUE로 전달
ct state new,established ct mark 0x0 \
counter queue num 0-3 fanout bypass
# 3단계: DPI가 아직 verdict 내리지 않은 패킷 허용
ct state established counter accept
}
}
# DPI 데몬에서 verdict 시 conntrack mark 설정:
# nfq_set_verdict2(qh, id, NF_ACCEPT, htonl(0x1), ...)
# → ct mark = 0x1 (허용 + 오프로드)
# nfq_set_verdict(qh, id, NF_DROP, ...)
# → ct mark = 0x2 (차단)
DPI 데몬에서 conntrack mark 설정
/* DPI verdict 시 conntrack mark를 설정하여 flowtable 오프로드 트리거 */
#include <libnetfilter_queue/libnetfilter_queue.h>
static int dpi_verdict_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);
/* DPI 분류 수행 */
int classification = perform_dpi(payload, len);
switch (classification) {
case DPI_ALLOW:
/* ct mark = 1: 허용 + flowtable 오프로드 대상 */
return nfq_set_verdict2(qh, id,
NF_ACCEPT,
htonl(0x00000001), /* conntrack mark */
0, NULL);
case DPI_BLOCK:
/* ct mark = 2: 차단 (flowtable 등록 안 함) */
return nfq_set_verdict2(qh, id,
NF_DROP,
htonl(0x00000002),
0, NULL);
case DPI_NEED_MORE:
/* 아직 분류 미완: mark 0 유지, 다음 패킷도 NFQUEUE */
return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL);
}
return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL);
}
- 분류 시점 제한: flowtable에 등록된 후에는 NFQUEUE를 완전히 우회하므로, 중간에 프로토콜이 변경되는 터널링 트래픽(예: HTTP CONNECT 후 TLS)은 오탐 가능성이 있다.
- 양방향 오프로드: flowtable은 양방향 플로우를 모두 오프로드하므로, 단방향만 검사하던 DPI 규칙이 있으면 우회될 수 있다.
- NAT 필수 선행: flowtable은 PREROUTING의 NAT 처리 이후에 적용되므로, DNAT/SNAT이 먼저 완료되어야 한다.
- 하드웨어 오프로드 제약:
flags offload사용 시 NIC 드라이버가 TC flower offload를 지원해야 하며, 미지원 NIC에서는 소프트웨어 flowtable로 폴백된다. - conntrack 타임아웃: flowtable 엔트리는 conntrack 타임아웃과 동기화되므로, 장기 연결(예: WebSocket)은 재분류 없이 계속 오프로드된다.
- 로깅 제한: 오프로드된 패킷은 nftables 카운터에만 집계되고, 개별 패킷 로깅이 불가능하다.
Flowtable 오프로드 성능 비교
| 구성 | 처리량 (Gbps) | 패킷률 (Mpps) | CPU 사용률 | 레이턴시 (μs) |
|---|---|---|---|---|
| NFQUEUE DPI만 사용 (매 패킷 검사) | 2.1 | 0.31 | 92% | ~850 |
| NFQUEUE DPI + SW flowtable | 8.7 | 1.28 | 35% | ~120 |
| NFQUEUE DPI + HW flowtable offload | 23.5 | 3.47 | 8% | ~15 |
| flowtable만 사용 (DPI 없음, 참고치) | 25.0 | 3.69 | 5% | ~10 |
Flowtable 상태 확인 명령
# flowtable에 등록된 오프로드 플로우 확인
sudo conntrack -L -m 1 # mark=1인 플로우 (DPI 허용, 오프로드 대상)
# nftables flowtable 카운터 확인
sudo nft list ruleset | grep -A2 "flow add"
# 하드웨어 오프로드 통계 (ethtool)
sudo ethtool -S eth0 | grep "tc_flower"
# flowtable 엔트리 실시간 모니터링
sudo conntrack -E -m 1 # mark=1 이벤트 실시간 출력
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) | ||
JA4+ 차세대 TLS 지문 기법
JA3는 TLS Client Hello의 필드를 해시하여 클라이언트를 식별하는 기법으로 널리 사용되어 왔으나, TLS 확장 순서 랜덤화(Chrome 106+, Firefox 등)에 의해 동일 클라이언트도 다른 해시를 생성하는 문제가 발생했다. JA4+는 이러한 한계를 극복하기 위해 FoxIO에서 개발한 차세대 지문 체계로, 6개의 하위 지문으로 구성된다.
JA4+ 지문 체계 구성
| 지문 유형 | 대상 | 분석 레이어 | 주요 용도 |
|---|---|---|---|
| JA4 | TLS Client Hello | TLS 핸드셰이크 | 클라이언트 애플리케이션 식별 |
| JA4S | TLS Server Hello | TLS 핸드셰이크 | 서버 응답 패턴 식별 |
| JA4H | HTTP Request | HTTP 헤더 | HTTP 클라이언트 식별 |
| JA4X | X.509 인증서 | TLS Certificate | 인증서 발급자/패턴 분석 |
| JA4T | TCP 핸드셰이크 | TCP SYN/SYN-ACK | OS 핑거프린팅 (p0f 대체) |
| JA4L | Light Distance | TCP RTT | 클라이언트 물리적 거리 추정 |
JA3 vs JA4+ 비교
| 항목 | JA3 | JA4 | JA4S | JA4H |
|---|---|---|---|---|
| 입력 데이터 | TLS version, ciphers, extensions, elliptic curves, EC formats | TLS version, SNI 유무, cipher 개수, extension 개수, ALPN, 정렬된 cipher+extension 목록 | Server Hello cipher, extension, ALPN | HTTP method, headers (정렬), cookie 유무, Accept-Language |
| 해시 방식 | MD5 (전체 필드 연결) | 인간 판독 가능 접두사(a부분) + SHA256 truncated(b,c부분) | 접두사 + SHA256 | 접두사 + SHA256 |
| 확장 순서 영향 | 있음 (순서 의존) | 없음 (정렬 후 해시) | 없음 | 없음 (정렬) |
| GREASE 처리 | 포함 가능 (오탐 원인) | 자동 제외 | 자동 제외 | 해당 없음 |
| 출력 예시 | e7d705a3286e19ea (MD5) | t13d1516h2_8daaf6152771_b186095e22b6 | t130200_1301_h2 | ge11cn07enus_60a...4bc_e9a...1fd |
| 가독성 | 없음 (해시만) | 접두사에서 TLS 버전, 프로토콜 유추 가능 | 접두사에서 cipher 확인 가능 | 접두사에서 method/언어 확인 가능 |
| 라이선스 | BSD 3-Clause | FoxIO License (BSL 변형) | FoxIO License | FoxIO License |
JA4 지문 계산 구조 상세
/*
* JA4 지문 구조: [a부분]_[b부분]_[c부분]
*
* a부분 (인간 판독 가능):
* [q|t] → QUIC(q) 또는 TCP(t)
* [TLS version] → "13" = TLS 1.3, "12" = TLS 1.2
* [d|i] → SNI 존재(d=domain) / 부재(i=IP)
* [cipher count] → 2자리 (예: "15" = 15개 cipher)
* [ext count] → 2자리 (예: "16" = 16개 extension)
* [ALPN 첫번째] → "h2" = HTTP/2, "h1" = HTTP/1.1, "00" = 없음
*
* b부분: SHA256(정렬된 cipher suite 목록)의 앞 12자
* c부분: SHA256(정렬된 extension 목록)의 앞 12자
*
* 핵심: cipher와 extension을 정렬하므로 순서 랜덤화 영향 없음
*/
/* JA4 계산 의사 코드 */
char *compute_ja4(struct tls_client_hello *ch)
{
char a_part[16];
char b_hash[13], c_hash[13];
/* a부분: 프로토콜 메타데이터 */
snprintf(a_part, sizeof(a_part),
"%c%s%c%02d%02d%s",
ch->is_quic ? 'q' : 't', /* QUIC or TCP */
tls_version_str(ch->version), /* "13", "12" 등 */
ch->has_sni ? 'd' : 'i', /* domain or IP */
count_ciphers_no_grease(ch), /* GREASE 제외 */
count_extensions_no_grease(ch), /* GREASE 제외 */
get_first_alpn(ch)); /* "h2", "h1", "00" */
/* b부분: 정렬된 cipher suite의 SHA256 (앞 12자) */
uint16_t *ciphers = get_sorted_ciphers(ch); /* 오름차순 정렬 */
sha256_hex_truncate(ciphers, b_hash, 12);
/* c부분: 정렬된 extension의 SHA256 (앞 12자) */
uint16_t *exts = get_sorted_extensions(ch); /* SNI, ALPN 제외 후 정렬 */
sha256_hex_truncate(exts, c_hash, 12);
/* 최종: "t13d1516h2_8daaf6152771_b186095e22b6" */
return format_ja4(a_part, b_hash, c_hash);
}
JA4T — TCP 핑거프린팅
/*
* JA4T: TCP SYN 패킷 기반 OS 핑거프린팅
*
* 분석 필드:
* - Window Size (초기 윈도우 크기)
* - TCP Options 순서 및 값 (MSS, Window Scale, SACK, Timestamp 등)
* - TTL (초기 TTL에서 OS 추정)
*
* 출력 형식: [window]_[options_list]_[ttl]
*
* 예시:
* Linux 6.x: "65535_2-4-8-1-3_64" (MSS, SACK, TS, NOP, WS)
* Windows 11: "65535_2-4-8-1-3_128"
* macOS 14: "65535_2-4-1-1-3_64"
*
* p0f와 유사하지만 JA4+ 체계 내에서 통합 분석 가능
*/
Suricata 7.x JA4 지원 현황
Suricata 7.0.3부터 JA4 지문을 실험적으로 지원하며, suricata.yaml에서 활성화할 수 있다.
# /etc/suricata/suricata.yaml — JA4 활성화
app-layer:
protocols:
tls:
enabled: yes
ja3-fingerprints: yes # JA3/JA3S (기본)
ja4-fingerprints: yes # JA4+ (7.0.3+ 실험적)
# EVE 로그에 JA4 출력 포함
outputs:
- eve-log:
enabled: yes
types:
- tls:
extended: yes # JA3 + JA4 모두 포함
# Suricata 룰에서 JA4 매칭
# JA4 a부분만으로 빠른 필터링
alert tls any any -> any any (msg:"Suspicious JA4 - known malware"; \
ja4.hash; content:"t13d1711h2_8daaf6152771_b186095e22b6"; \
sid:2100001; rev:1;)
# JA4 a부분 접두사만 매칭 (TLS 1.3 + QUIC 탐지)
alert tls any any -> any any (msg:"QUIC TLS 1.3 traffic"; \
ja4.hash; content:"q13d"; startswith; \
sid:2100002; rev:1;)
# EVE JSON 출력 예시:
# {
# "tls": {
# "ja3": { "hash": "e7d705a3286e19ea...", "string": "771,4866-..." },
# "ja4": { "hash": "t13d1516h2_8daaf6152771_b186095e22b6" },
# "ja4s": { "hash": "t130200_1301_h2" }
# }
# }
HASSH — SSH 핑거프린팅
HASSH는 SSH 핸드셰이크의 Key Exchange Init 메시지에서 알고리즘 목록을 추출하여 MD5 해시를 생성한다. JA3의 SSH 버전이라 할 수 있으며, SSH 클라이언트/서버 식별에 활용된다.
| 항목 | HASSH (클라이언트) | HASSHServer (서버) |
|---|---|---|
| 입력 필드 | kex_algorithms, encryption_algorithms_client_to_server, mac_algorithms_client_to_server, compression_algorithms_client_to_server | kex_algorithms, encryption_algorithms_server_to_client, mac_algorithms_server_to_client, compression_algorithms_server_to_client |
| 해시 방식 | MD5(필드 연결) | MD5(필드 연결) |
| 출력 예시 | ec7378c1a92f5a8d | b12d2871a1c7d86c |
| OpenSSH 9.x | 2307...e8b0 | 4a3e...c915 |
| PuTTY 0.8x | a12f...3b17 | — |
| Suricata 지원 | hassh.hash / hassh.string 키워드 | hassh.server.hash / hassh.server.string |
# Suricata HASSH 탐지 룰
alert ssh any any -> any any (msg:"Known SSH brute-force tool HASSH"; \
hassh.hash; content:"ec7378c1a92f5a8dcebc7e2130115fc8"; \
sid:2200001; rev:1;)
# HASSH + JA4 조합으로 터널링 탐지
# (SSH 클라이언트가 비정상적 JA4T TCP 지문을 보이면 터널 의심)
alert ssh any any -> any any (msg:"SSH tunnel suspected - abnormal TCP fingerprint"; \
hassh.hash; content:"ec7378c1a92f5a8dcebc7e2130115fc8"; \
flow:to_server,established; \
threshold: type both, track by_src, count 5, seconds 60; \
sid:2200002; rev:1;)
- TLS 클라이언트 식별: JA4 + JA4X (Client Hello + 인증서) — 동일 클라이언트가 다른 인증서를 제시하면 MITM 의심
- 봇넷/C2 탐지: JA4 + JA4S + JA4H — C2 서버의 Server Hello + HTTP 패턴이 고정적인 특성 활용
- OS + 앱 식별: JA4T + JA4 — TCP 핑거프린트(OS)와 TLS 핑거프린트(앱)의 불일치 탐지 (예: Linux TCP 지문인데 Windows-only 앱의 JA4)
- SSH 터널 탐지: HASSH + JA4T — 비정상적 SSH 도구의 TCP 특성 조합
- VPN/프록시 탐지: JA4L(물리 거리) + JA4(클라이언트) — 지리적 거리와 클라이언트 유형의 불일치
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
DPI 우회 기법과 대응 전략
DPI 시스템의 한계를 악용하는 다양한 우회 기법이 존재하며, 각 기법에 대한 체계적인 대응 전략이 필요합니다. 공격자는 프로토콜 스택의 각 계층에서 DPI를 무력화하는 방법을 조합하며, 방어 측은 재조립·행위 분석·다계층 탐지를 통해 대응합니다.
우회 기법 상세 비교
| 기법 | 설명 | DPI 영향 | 대응 방법 | Suricata 규칙 예시 |
|---|---|---|---|---|
| TCP Segmentation | HTTP 요청을 1~2바이트 TCP 세그먼트로 분할 전송하여 DPI 시그니처 매칭 회피 | 단일 패킷 기반 시그니처 실패, 전체 요청 문자열이 여러 패킷에 분산 | TCP 스트림 재조립(reassembly) 활성화, depth 충분히 설정 | alert http any any -> any any (msg:"Segmented HTTP evasion"; flow:to_server,established; content:"GET"; depth:4; stream_size:server,<,6; sid:1000001;) |
| IP Fragmentation | IP 패킷을 MTU 이하로 강제 분할하여 L4/L7 헤더가 다른 프래그먼트에 위치 | TCP/UDP 헤더가 두 번째 프래그먼트에 위치하면 포트 기반 필터 우회 | IP defrag 활성화 (defrag 정책), 비정상 오프셋 탐지 |
alert ip any any -> any any (msg:"Tiny IP fragment"; fragbits:M; dsize:<8; sid:1000002;) |
| TLS ESNI/ECH | ClientHello의 server_name 확장을 암호화하여 DPI의 SNI 기반 도메인 식별 차단 | SNI 필드 추출 불가, 도메인 기반 정책 적용 실패 | Outer SNI(public_name) 분석, JA3/JARM 지문, DNS 쿼리 상관 분석 | alert tls any any -> any any (msg:"ECH detected"; tls.sni; content:"cloudflare-ech.com"; sid:1000003;) |
| Domain Fronting | TLS SNI에 허용 도메인, HTTP Host 헤더에 실제 목적지를 설정하여 CDN 경유 우회 | SNI 기반 허용 정책 통과 후 실제로는 차단 대상 서버에 연결 | SNI vs HTTP Host 교차 검증, CDN IP 대역 모니터링 | alert http any any -> any any (msg:"Domain fronting suspect"; lua:detect_domain_fronting.lua; sid:1000004;) |
| DNS Tunneling | DNS 쿼리/응답에 데이터를 인코딩하여 DNS 프로토콜을 데이터 채널로 악용 | 정상 DNS 트래픽으로 위장, DPI가 DNS 페이로드 내부까지 검사하지 않으면 통과 | DNS 쿼리 길이/엔트로피 분석, TXT 레코드 빈도 이상 탐지 | alert dns any any -> any any (msg:"DNS tunnel suspect"; dns.query; content:"|00|"; byte_test:1,>,50,0,relative; sid:1000005;) |
| HTTP/WebSocket Tunnel | HTTP CONNECT 또는 WebSocket Upgrade 후 임의 프로토콜을 내부에서 전송 | 초기 핸드셰이크만 HTTP로 감지, 이후 페이로드는 미검사 | CONNECT/Upgrade 후 트래픽 지속 검사, 비정상 바이너리 탐지 | alert http any any -> any any (msg:"HTTP tunnel"; content:"CONNECT"; http_method; flow:to_server; sid:1000006;) |
| Slow Drip | 극저속 전송(1바이트/초)으로 DPI 버퍼 타임아웃 유도, 시그니처 완성 전 만료 | reassembly 타임아웃 전 패턴 완성 실패, 세션 추적 메모리 소모 | 최소 전송 속도 임계치 설정, 비정상 RTT 세션 종료 | alert tcp any any -> any any (msg:"Slow drip evasion"; flow:to_server; stream_size:server,<,100; flow:established,to_server; detection_filter:track by_src,count 50,seconds 60; sid:1000007;) |
| Polymorphic Malware | 실행마다 코드 변형(인코딩/암호화/패킹)으로 고정 바이트 시그니처 우회 | 해시/바이트 패턴 기반 탐지율 급감, 시그니처 DB 무력화 | 행위 기반 분석, 쉘코드 디코더 탐지, 샌드박스 연동 | alert http any any -> any any (msg:"Shellcode decoder"; content:"|eb|"; byte_test:1,>=,0xc0,1,relative; sid:1000008;) |
TCP Segmentation 우회 — 공격과 방어
# 공격자 측: TCP Segmentation으로 DPI 우회 시도
# HTTP GET 요청을 1바이트씩 분할 전송하여 DPI 시그니처 회피
import socket
import time
def send_segmented_http(host, port, path):
"""TCP 세그먼트를 극단적으로 작게 분할하여 DPI 회피 시도"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.connect((host, port))
request = f"GET {path} HTTP/1.1\r\nHost: {host}\r\n\r\n"
# 1바이트씩 전송 — DPI가 단일 패킷에서 "GET" 패턴을 찾지 못함
for byte in request:
sock.send(byte.encode())
time.sleep(0.01) # 10ms 간격으로 각 바이트 전송
response = sock.recv(4096)
sock.close()
return response
# 결과: 단일 패킷 기반 DPI는 "GET /malware" 시그니처를 탐지 실패
# "G", "E", "T", " ", "/" ... 각각이 별도 TCP 세그먼트
# 방어 측: Suricata stream reassembly 설정으로 TCP segmentation 우회 무력화
# /etc/suricata/suricata.yaml
stream:
memcap: 256mb # 스트림 재조립 메모리 상한
checksum-validation: yes # 체크섬 검증 (DPI 품질 보장)
midstream: false # 이미 진행 중인 세션 픽업 비활성화
async-oneside: false # 단방향 스트림 비허용
reassembly:
memcap: 512mb # reassembly 전용 메모리 (세그먼트 버퍼링)
depth: 1mb # 스트림당 최대 재조립 깊이
toserver-chunk-size: 2560 # 서버 방향 청크 크기
toclient-chunk-size: 2560 # 클라이언트 방향 청크 크기
randomize-chunk-size: yes # 청크 크기 랜덤화 (핑거프린트 방지)
randomize-chunk-range: 10 # 랜덤 범위 ±10%
raw: yes # raw reassembly 활성화 (인코딩 무관 검사)
segment-prealloc: 2048 # 세그먼트 사전 할당
# 핵심: depth를 충분히 높이면 TCP segmentation 우회가 불가능
# Suricata는 모든 세그먼트를 재조립한 뒤 시그니처 매칭 수행
# "G"+"E"+"T"+" "+"/malware" → 재조립 후 "GET /malware" 완성 → 탐지 성공
Domain Fronting 탐지 코드
/* NFQUEUE 콜백에서 Domain Fronting 탐지
* SNI (TLS ClientHello)와 HTTP Host 헤더를 비교하여 불일치 탐지
* SNI: allowed.example.com vs Host: blocked.evil.com → Domain Fronting */
#include <string.h>
#include <libnetfilter_queue/libnetfilter_queue.h>
/* TLS ClientHello에서 SNI 추출 */
static int extract_tls_sni(const unsigned char *payload, int len,
char *sni_buf, int sni_max)
{
/* TLS Record: 0x16 (Handshake) */
if (len < 5 || payload[0] != 0x16) return -1;
int pos = 5; /* TLS Record Header 크기 */
if (pos >= len || payload[pos] != 0x01) return -1; /* ClientHello */
pos += 4; /* Handshake Type(1) + Length(3) */
pos += 2; /* ProtocolVersion */
pos += 32; /* Random */
/* Session ID */
if (pos >= len) return -1;
pos += 1 + payload[pos];
/* Cipher Suites */
if (pos + 2 > len) return -1;
pos += 2 + (payload[pos] << 8 | payload[pos + 1]);
/* Compression Methods */
if (pos >= len) return -1;
pos += 1 + payload[pos];
/* Extensions 파싱 — server_name (type=0x0000) 찾기 */
if (pos + 2 > len) return -1;
int ext_len = payload[pos] << 8 | payload[pos + 1];
pos += 2;
int ext_end = pos + ext_len;
while (pos + 4 <= ext_end && pos + 4 <= len) {
uint16_t etype = payload[pos] << 8 | payload[pos + 1];
uint16_t elen = payload[pos + 2] << 8 | payload[pos + 3];
pos += 4;
if (etype == 0x0000 && pos + elen <= len) {
/* ServerNameList → HostName (type=0x00) */
int snl_pos = pos + 2; /* skip list length */
if (snl_pos < len && payload[snl_pos] == 0x00) {
int name_len = payload[snl_pos + 1] << 8 | payload[snl_pos + 2];
if (name_len < sni_max && snl_pos + 3 + name_len <= len) {
memcpy(sni_buf, &payload[snl_pos + 3], name_len);
sni_buf[name_len] = '\0';
return name_len;
}
}
}
pos += elen;
}
return -1;
}
/* HTTP Host 헤더 추출 */
static int extract_http_host(const unsigned char *payload, int len,
char *host_buf, int host_max)
{
const char *needle = "Host: ";
const char *p = memmem(payload, len, needle, 6);
if (!p) return -1;
p += 6;
const char *end = memchr(p, '\r', len - (p - (const char*)payload));
if (!end || end - p >= host_max) return -1;
memcpy(host_buf, p, end - p);
host_buf[end - p] = '\0';
return end - p;
}
/* NFQUEUE 콜백: Domain Fronting 탐지 */
static int detect_fronting_cb(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);
char sni[256] = {0}, host[256] = {0};
/* 세션 추적: TLS ClientHello에서 SNI 저장 후
* 동일 세션의 HTTP 요청에서 Host와 비교 */
if (extract_tls_sni(payload, len, sni, sizeof(sni)) > 0) {
session_store_sni(ph, sni); /* 세션별 SNI 캐시 저장 */
}
if (extract_http_host(payload, len, host, sizeof(host)) > 0) {
const char *cached_sni = session_get_sni(ph);
if (cached_sni && strcasecmp(cached_sni, host) != 0) {
/* SNI ≠ Host → Domain Fronting 의심! */
syslog(LOG_WARNING,
"Domain fronting detected: SNI=%s Host=%s",
cached_sni, host);
return nfq_set_verdict(qh, id, NF_DROP, 0, NULL);
}
}
return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL);
}
-- Suricata Lua 스크립트: Domain Fronting 탐지
-- /etc/suricata/rules/detect_domain_fronting.lua
function init(args)
local needs = {}
needs["tls"] = tostring(true)
needs["http.host"] = tostring(true)
return needs
end
function match(args)
local tls_sni = SCFlowString("tls.sni")
local http_host = SCFlowString("http.host")
if tls_sni ~= nil and http_host ~= nil then
-- SNI와 Host가 다르면 Domain Fronting 의심
if string.lower(tls_sni) ~= string.lower(http_host) then
return 1 -- 탐지: alert 발생
end
end
return 0
end
Vectorscan — Hyperscan 오픈소스 후속
Intel이 2024년 Hyperscan의 오픈소스 라이선스를 변경(독점 라이선스로 전환)하자, 커뮤니티는 마지막 BSD 라이선스 버전(5.4.11)을 기반으로 Vectorscan을 포크했습니다. Vectorscan은 Hyperscan과 완전한 API 호환성을 유지하면서 ARM NEON/SVE 지원을 추가한 크로스플랫폼 정규식 엔진입니다.
Hyperscan vs Vectorscan 비교
| 항목 | Hyperscan (Intel) | Vectorscan (커뮤니티) |
|---|---|---|
| 라이선스 | 독점 (2024~ 비공개), 기존 BSD 3-Clause (5.4.x까지) | BSD 3-Clause (오픈소스 유지) |
| 지원 플랫폼 | x86_64 전용 (SSE4.2/AVX2/AVX-512) | x86_64 + ARM (NEON/SVE) + POWER (VSX) |
| SIMD 지원 | SSE4.2, AVX2, AVX-512 | SSE4.2, AVX2, AVX-512, NEON, SVE |
| API 호환성 | 원본 (hs_* API) | 100% 호환 (드롭인 교체) |
| Suricata 통합 | 공식 지원 (빌드 옵션) | Suricata 7.0+에서 공식 지원 |
| Snort 통합 | Snort 3 기본 엔진 | 빌드 시 라이브러리 경로 교체로 사용 |
| 성능 (x86_64) | 기준값 (최적화 수십 년) | 동등 수준 (동일 코드베이스 기반) |
| 성능 (ARM) | 미지원 | NEON 최적화로 x86 대비 80~95% 수준 |
| 커뮤니티 | Intel 내부 (비공개) | GitHub 활발 (VectorCamp/vectorscan) |
| Chimera 지원 | 내장 (libchimera) | 포함 (Chimera 포크 동시 유지) |
Vectorscan 빌드 예제
# Vectorscan 소스 빌드 (Hyperscan 드롭인 교체)
git clone https://github.com/VectorCamp/vectorscan.git
cd vectorscan
mkdir build && cd build
# x86_64 빌드 (AVX2 최적화)
cmake -DCMAKE_INSTALL_PREFIX=/usr/local \
-DBUILD_AVX2=ON \
-DBUILD_AVX512=ON \
-DFAT_RUNTIME=ON \
..
make -j$(nproc)
sudo make install
# ARM64 빌드 (NEON/SVE)
# cmake -DCMAKE_INSTALL_PREFIX=/usr/local ..
# ARM에서는 자동으로 NEON 감지, SVE는 GCC 10+ 필요
# Suricata에서 Vectorscan 사용 (Hyperscan과 동일 빌드 옵션)
cd /path/to/suricata
./configure --enable-hyperscan \
--with-libhs-includes=/usr/local/include/hs \
--with-libhs-libraries=/usr/local/lib
make -j$(nproc)
# 확인: Vectorscan이 Hyperscan API를 100% 대체
suricata --build-info | grep -i hyperscan
# Hyperscan support: yes
# (내부적으로 Vectorscan 라이브러리 사용)
Hyperscan/Vectorscan 스트리밍 모드 상세
스트리밍 모드는 TCP 세션처럼 데이터가 청크 단위로 도착하는 환경에 최적화된 모드로, 세션 간 상태를 유지하면서 청크 경계를 넘나드는 패턴도 정확하게 매칭합니다. DPI에서 TCP 스트림 검사에 필수적인 모드입니다.
#include <hs/hs.h>
#include <stdio.h>
#include <string.h>
/* 매칭 콜백: 패턴 매칭 시 호출 */
static int on_match(unsigned int id, unsigned long long from,
unsigned long long to, unsigned int flags,
void *ctx)
{
printf("Pattern %u matched at offset %llu-%llu\n", id, from, to);
return 0; /* 0=계속 스캔, 비0=중단 */
}
int main(void)
{
hs_database_t *db = NULL;
hs_compile_error_t *err = NULL;
hs_stream_t *stream = NULL;
hs_scratch_t *scratch = NULL;
/* 1단계: 패턴 컴파일 (HS_MODE_STREAM — 스트리밍 모드) */
const char *patterns[] = {
"GET /malware", /* id=0: HTTP 악성 경로 */
"(?i)x-forwarded-for:", /* id=1: 프록시 헤더 */
"\\x90{10,}", /* id=2: NOP 슬레드 (쉘코드) */
};
unsigned int ids[] = { 0, 1, 2 };
unsigned int flags[] = {
HS_FLAG_SINGLEMATCH, /* 최초 1회만 매칭 */
HS_FLAG_CASELESS | HS_FLAG_SINGLEMATCH, /* 대소문자 무시 */
HS_FLAG_SINGLEMATCH,
};
hs_error_t ret = hs_compile_multi(
patterns, flags, ids, 3,
HS_MODE_STREAM, /* 스트리밍 모드: 청크 간 상태 유지 */
NULL, &db, &err
);
if (ret != HS_SUCCESS) {
fprintf(stderr, "compile error: %s\n", err->message);
hs_free_compile_error(err);
return 1;
}
/* 2단계: 스크래치 공간 할당 (스레드당 1개) */
hs_alloc_scratch(db, &scratch);
/* 3단계: 스트림 열기 — TCP 세션 시작 시 호출 */
hs_open_stream(db, 0, &stream);
/* 4단계: 청크 단위로 데이터 스캔 — 패킷 도착마다 호출 */
/* TCP segmentation 우회 시뮬레이션: "GET /malware"가 3개 청크에 분산 */
hs_scan_stream(stream, "GET ", 4, 0, scratch, on_match, NULL);
hs_scan_stream(stream, "/malw", 5, 0, scratch, on_match, NULL);
hs_scan_stream(stream, "are HTTP/1.1\r\n", 15, 0, scratch, on_match, NULL);
/* → "GET /malware" 패턴이 청크 경계를 넘어 정상 매칭! */
/* 5단계: 스트림 닫기 — TCP 세션 종료 시 호출 */
hs_close_stream(stream, scratch, on_match, NULL);
/* 정리 */
hs_free_scratch(scratch);
hs_free_database(db);
return 0;
}
Chimera — PCRE + Hyperscan 하이브리드 엔진
Chimera는 Hyperscan의 고속 매칭과 PCRE의 완전한 정규식 호환성을 결합한 하이브리드 엔진입니다. Hyperscan의 DFA 기반 엔진이 처리할 수 없는 역참조(backreference), 전방/후방 참조(lookahead/lookbehind) 등 복잡한 PCRE 패턴을 Hyperscan이 후보 매칭을 빠르게 찾은 뒤 PCRE로 정밀 검증하는 2단계 방식으로 처리합니다.
#include <hs/ch.h> /* Chimera 헤더 */
/* Chimera 매칭 콜백 */
static ch_callback_t ch_on_match(unsigned int id,
unsigned long long from, unsigned long long to,
unsigned int flags, unsigned int size,
const ch_capture_t *captured, void *ctx)
{
printf("Chimera match id=%u [%llu-%llu]\n", id, from, to);
if (captured && size > 0) {
/* PCRE 캡처 그룹 접근 가능 */
printf(" capture[0]: offset=%llu len=%u\n",
captured[0].from, captured[0].to - captured[0].from);
}
return CH_CALLBACK_CONTINUE;
}
/* Chimera 컴파일 및 스캔 */
ch_database_t *ch_db = NULL;
ch_compile_error_t *ch_err = NULL;
/* PCRE 역참조 패턴 — 순수 Hyperscan으로는 불가능 */
const char *pcre_pat = "(\\w+)\\s+\\1"; /* 반복 단어 탐지 */
unsigned int ch_flags = CH_FLAG_SINGLEMATCH;
ch_compile(pcre_pat, ch_flags, 0, CH_MODE_NOGROUPS,
NULL, &ch_db, &ch_err);
ch_scratch_t *ch_scratch = NULL;
ch_alloc_scratch(ch_db, &ch_scratch);
const char *data = "hello hello world";
ch_scan(ch_db, data, strlen(data), 0, ch_scratch,
ch_on_match, NULL, NULL);
/* → "hello hello" 매칭 (역참조 \\1 동작 확인) */
Hyperscan 스캔 모드 성능 비교
| 모드 | API | 용도 | 상태 유지 | 성능 특성 | DPI 적용 |
|---|---|---|---|---|---|
| Block 모드 | hs_scan() |
단일 데이터 블록을 한 번에 스캔 | 없음 (호출마다 독립) | 가장 빠름 — 상태 관리 오버헤드 없음, SIMD 완전 활용 | UDP 패킷, DNS 쿼리, 개별 파일 스캔 |
| Stream 모드 | hs_open_stream() / hs_scan_stream() / hs_close_stream() |
TCP 스트림 등 청크 단위 데이터 | 세션별 상태 유지 (hs_stream_t) |
Block 대비 10~20% 느림 — 상태 저장/복원 비용, 메모리 사용 증가 | TCP DPI (Suricata IPS), HTTP/TLS 검사 |
| Vectored 모드 | hs_scan_vector() |
비연속 메모리 블록(scatter-gather) | 없음 (단일 호출) | Block과 유사 — 복사 없이 여러 버퍼를 연속 스캔 | skb frag_list 직접 스캔, zero-copy 파이프라인 |
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 아키텍처 스택
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 — 네트워크 네임스페이스
컨테이너 환경에서는 각 네트워크 네임스페이스가 독립적인 Netfilter 스택을 가지므로, 네임스페이스별로 별도의 NFQUEUE + DPI 인스턴스를 운영할 수 있습니다. 이를 통해 멀티테넌트 환경에서 테넌트 간 DPI 정책을 완전히 격리하면서도 공유 BPF 맵으로 전역 정책을 적용할 수 있습니다.
네임스페이스별 NFQUEUE 설정
#!/bin/bash
# 멀티테넌트 NFQUEUE 환경 구축
# 각 네트워크 네임스페이스에 독립 NFQUEUE + DPI 인스턴스 배치
# ──────────────────────────────────────────
# 1단계: 네트워크 네임스페이스 + veth 쌍 생성
# ──────────────────────────────────────────
# Tenant A 네임스페이스
ip netns add tenant-a
ip link add veth-host-1 type veth peer name veth-ns1
ip link set veth-ns1 netns tenant-a
# Host 측 veth 설정
ip addr add 10.0.1.1/24 dev veth-host-1
ip link set veth-host-1 up
# Tenant A 네임스페이스 내부 설정
ip netns exec tenant-a bash -c '
ip addr add 10.0.1.2/24 dev veth-ns1
ip link set veth-ns1 up
ip link set lo up
ip route add default via 10.0.1.1
'
# Tenant B 네임스페이스 (동일 패턴)
ip netns add tenant-b
ip link add veth-host-2 type veth peer name veth-ns2
ip link set veth-ns2 netns tenant-b
ip addr add 10.0.2.1/24 dev veth-host-2
ip link set veth-host-2 up
ip netns exec tenant-b bash -c '
ip addr add 10.0.2.2/24 dev veth-ns2
ip link set veth-ns2 up
ip link set lo up
ip route add default via 10.0.2.1
'
# Host에서 IP 포워딩 활성화
sysctl -w net.ipv4.ip_forward=1
# ──────────────────────────────────────────
# 2단계: 네임스페이스별 독립 NFQUEUE 규칙
# ──────────────────────────────────────────
# Tenant A: 모든 FORWARD 트래픽을 NFQUEUE 0으로 (NS 내부 독립)
ip netns exec tenant-a iptables -I FORWARD -j NFQUEUE \
--queue-num 0 --queue-bypass
# Tenant B: 동일하게 NFQUEUE 0 사용 (네임스페이스가 다르므로 충돌 없음)
ip netns exec tenant-b iptables -I FORWARD -j NFQUEUE \
--queue-num 0 --queue-bypass
# 핵심: 각 NS의 NFQUEUE 0은 완전히 독립된 큐
# NS 내부에서 실행하는 DPI 프로세스만 해당 큐를 수신
# ──────────────────────────────────────────
# 3단계: 네임스페이스별 독립 DPI 실행
# ──────────────────────────────────────────
# Tenant A: Suricata IPS 모드 (독립 규칙셋)
ip netns exec tenant-a suricata \
-c /etc/suricata/tenant-a.yaml \
--runmode single \
-q 0 \
--pidfile /var/run/suricata-tenant-a.pid &
# Tenant B: nDPI 기반 커스텀 DPI (독립 분류 정책)
ip netns exec tenant-b /opt/ndpi-queue/ndpi-nfqueue \
--queue-num 0 \
--config /etc/ndpi/tenant-b.conf \
--pidfile /var/run/ndpi-tenant-b.pid &
# 확인: 각 NS에서 독립 프로세스 확인
ip netns exec tenant-a ss -f netlink | grep nfqueue
ip netns exec tenant-b ss -f netlink | grep nfqueue
VRF (Virtual Routing and Forwarding) + NFQUEUE
# VRF를 사용한 라우팅 도메인 분리 + NFQUEUE DPI
# VRF는 네임스페이스와 달리 동일 NS 내에서 라우팅 테이블만 분리
# VRF 디바이스 생성
ip link add vrf-blue type vrf table 100
ip link set vrf-blue up
ip link set eth1 master vrf-blue
ip link add vrf-red type vrf table 200
ip link set vrf-red up
ip link set eth2 master vrf-red
# VRF별 NFQUEUE 규칙 (iif 기반 분리)
# Blue VRF → NFQUEUE 10 (Suricata)
iptables -I FORWARD -i eth1 -m mark ! --mark 0x100 \
-j NFQUEUE --queue-num 10 --queue-bypass
# Red VRF → NFQUEUE 20 (nDPI)
iptables -I FORWARD -i eth2 -m mark ! --mark 0x200 \
-j NFQUEUE --queue-num 20 --queue-bypass
# VRF + conntrack zone: VRF별 conntrack 격리
nft add table inet filter
nft add chain inet filter prerouting '{ type filter hook prerouting priority -200 ; }'
nft add rule inet filter prerouting iifname "eth1" ct zone set 1
nft add rule inet filter prerouting iifname "eth2" ct zone set 2
# DPI 프로세스: 각 큐에 바인딩
suricata -c /etc/suricata/vrf-blue.yaml -q 10 &
/opt/ndpi-queue/ndpi-nfqueue --queue-num 20 &
# VRF 라우팅 테이블 확인
ip route show table 100 # Blue VRF
ip route show table 200 # Red VRF
ct zone set N(nftables)으로 conntrack zone을 명시적으로 분리해야
서로 다른 VRF의 동일 5-tuple 세션이 충돌하지 않습니다.
주의 사항:
conntrack -D(세션 삭제) 시 zone 파라미터를 지정하지 않으면 모든 zone의 세션이 삭제됩니다.
멀티테넌트 환경에서는 conntrack -D -w ZONE_ID로 특정 zone만 조작해야 합니다.
또한 각 네임스페이스의 nf_conntrack_max sysctl은 독립적으로 설정해야 하며,
호스트의 전역 설정이 네임스페이스에 자동 상속되지 않습니다.
분산 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}
실전 NGFW 종합 구축 가이드
실제 프로덕션 환경에서 리눅스 기반 NGFW를 구축할 때 필요한 전체 아키텍처, 배포 스크립트, 성능 튜닝 체크리스트를 종합합니다. XDP → TC eBPF → nftables → NFQUEUE → DPI → 로깅까지 전 구간의 처리량과 설정 포인트를 다룹니다.
프로덕션 NGFW 전체 아키텍처
전체 배포 스크립트 (Production Ready)
#!/bin/bash
# NGFW Production Deployment Script
# 대상: Ubuntu 22.04+ / Debian 12+ / Rocky Linux 9+
# NIC: Intel X710 (i40e 드라이버), 4x10GbE
set -euo pipefail
##############################################
# 1. 시스템 커널 파라미터 튜닝
##############################################
cat > /etc/sysctl.d/90-ngfw.conf <<'EOF'
# 네트워크 스택 버퍼
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576
net.core.netdev_max_backlog = 65536
net.core.somaxconn = 65536
net.core.optmem_max = 2097152
# conntrack 튜닝
net.netfilter.nf_conntrack_max = 2097152
net.netfilter.nf_conntrack_buckets = 524288
net.netfilter.nf_conntrack_tcp_timeout_established = 3600
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30
net.netfilter.nf_conntrack_expect_max = 4096
# NFQUEUE 소켓 버퍼
net.netfilter.nf_conntrack_helper = 0
# TCP 최적화
net.ipv4.tcp_max_syn_backlog = 65536
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.ip_local_port_range = 1024 65535
# 포워딩 활성화
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
# ARP 테이블 확장
net.ipv4.neigh.default.gc_thresh3 = 16384
net.ipv4.neigh.default.gc_thresh2 = 8192
# Busy polling (NFQUEUE latency 감소)
net.core.busy_read = 50
net.core.busy_poll = 50
EOF
sysctl --system
##############################################
# 2. NIC 구성 (Intel X710 / i40e)
##############################################
NIC="eth0"
NUM_QUEUES=8
# RSS 큐 수 설정
ethtool -L $NIC combined $NUM_QUEUES
# Ring buffer 최대화
ethtool -G $NIC rx 4096 tx 4096
# RSS 해시 함수 — Toeplitz (5-tuple 분산)
ethtool -X $NIC hfunc toeplitz
ethtool -N $NIC rx-flow-hash tcp4 sdfn
ethtool -N $NIC rx-flow-hash udp4 sdfn
# IRQ affinity 자동 설정
for i in $(seq 0 $(($NUM_QUEUES - 1))); do
IRQ=$(grep "$NIC-TxRx-$i" /proc/interrupts | cut -d: -f1 | tr -d ' ')
if [ -n "$IRQ" ]; then
echo $((1 << $i)) > /proc/irq/$IRQ/smp_affinity
echo "IRQ $IRQ → CPU $i"
fi
done
# Interrupt coalescing (레이턴시 vs 처리량 균형)
ethtool -C $NIC rx-usecs 50 tx-usecs 50 rx-frames 64
# Flow Director 필터 (의심 트래픽 → 전용 큐)
ethtool -N $NIC flow-type tcp4 dst-port 443 action 0
ethtool -N $NIC flow-type udp4 dst-port 443 action 1 # QUIC
##############################################
# 3. nftables 룰셋 (flowtable + NFQUEUE fanout)
##############################################
cat > /etc/nftables-ngfw.conf <<'EOF'
#!/usr/sbin/nft -f
flush ruleset
table inet ngfw {
# flowtable — 확립된 세션 고속 처리 (커널 bypass)
flowtable ft_offload {
hook ingress priority 0
devices = { eth0, eth1 }
flags offload # 하드웨어 오프로드 시도
}
# conntrack zone 분리 (wan=1, lan=2)
chain prerouting {
type filter hook prerouting priority raw; policy accept;
iif eth0 ct zone set 1
iif eth1 ct zone set 2
}
chain forward {
type filter hook forward priority 0; policy drop;
# 1. 확립된 세션 → flowtable 오프로드
ct state established,related flow add @ft_offload accept
# 2. connmark 0xff → 이미 DPI 완료, bypass
ct mark 0xff accept
# 3. ICMP/ICMPv6 허용 (DPI 불필요)
meta l4proto { icmp, icmpv6 } accept
# 4. DNS/NTP — 소규모 UDP, DPI 불필요
udp dport { 53, 123 } accept
# 5. 신규 세션 → NFQUEUE fanout (8 workers)
ct state new queue num 0-7 flags bypass,fanout
# 6. DPI가 MARK 설정한 패킷 처리
ct mark 0x01 accept # DPI ACCEPT
ct mark 0x02 log prefix "[NGFW-DROP] " drop # DPI DROP
ct mark 0x03 log prefix "[NGFW-ALERT] " accept # Alert + pass
}
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
iif lo accept
tcp dport { 22, 443 } accept # 관리 포트
tcp dport 8080 accept # Policy REST API
}
chain postrouting {
type nat hook postrouting priority srcnat;
oif eth0 masquerade
}
}
EOF
nft -f /etc/nftables-ngfw.conf
nft list ruleset
##############################################
# 4. Suricata 멀티 워커 설치 및 설정
##############################################
# Suricata + Hyperscan 빌드 (성능 핵심)
apt-get install -y suricata libhyperscan-dev libndpi-dev
# 주요 suricata.yaml 설정 하이라이트
cat > /etc/suricata/suricata-nfq.yaml <<'EOF'
%YAML 1.1
---
nfq:
mode: repeat # repeat 모드: verdict 후 패킷 재순환
repeat-mark: 1
repeat-mask: 1
route-queue: 2
batchcount: 20 # batch verdict (성능 향상)
fail-open: yes # 큐 포화 시 통과 (가용성 우선)
detect:
profile: high # 고성능 프로파일
sgh-mpm-context: auto
inspection-recursion-limit: 3000
mpm-algo: hs # Hyperscan 엔진 사용
spm-algo: hs
app-layer:
protocols:
tls:
enabled: yes
ja3-fingerprints: yes # JA3/JA4 지문 추출
quic:
enabled: yes # QUIC Initial Packet 파싱
http2:
enabled: yes
outputs:
- eve-log:
enabled: yes
filetype: regular
filename: eve.json
types:
- alert
- http
- dns
- tls
- flow
- stats:
totals: yes
threads: yes
EOF
##############################################
# 5. systemd 멀티 워커 서비스
##############################################
NUM_WORKERS=8
cat > /etc/systemd/system/suricata-nfq@.service <<'EOF'
[Unit]
Description=Suricata NFQUEUE Worker %i
After=network.target nftables.service
PartOf=suricata-nfq.target
[Service]
Type=simple
ExecStart=/usr/bin/suricata \
-c /etc/suricata/suricata-nfq.yaml \
--runmode single \
-q %i \
--set logging.outputs.0.file.filename=/var/log/suricata/suricata-%i.log \
--set outputs.0.eve-log.filename=eve-%i.json
CPUAffinity=%i
LimitNOFILE=65536
LimitMEMLOCK=infinity
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=suricata-nfq.target
EOF
# 타겟 유닛 (전체 워커 일괄 관리)
cat > /etc/systemd/system/suricata-nfq.target <<'EOF'
[Unit]
Description=Suricata NFQUEUE Workers
Wants=suricata-nfq@0.service suricata-nfq@1.service suricata-nfq@2.service \
suricata-nfq@3.service suricata-nfq@4.service suricata-nfq@5.service \
suricata-nfq@6.service suricata-nfq@7.service
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now suricata-nfq.target
##############################################
# 6. XDP DDoS 방어 로드 (선택)
##############################################
# 사전 컴파일된 XDP 프로그램 로드
ip link set dev $NIC xdpgeneric obj /opt/ngfw/xdp_ddos.o sec xdp_prog
# TC eBPF flow_cache 로드
tc qdisc add dev $NIC clsact
tc filter add dev $NIC ingress bpf da obj /opt/ngfw/tc_flow_cache.o sec tc_ingress
##############################################
# 7. 로그 로테이션
##############################################
cat > /etc/logrotate.d/suricata-ngfw <<'EOF'
/var/log/suricata/eve-*.json {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
maxsize 500M
}
/var/log/suricata/suricata-*.log {
weekly
rotate 4
compress
missingok
notifempty
copytruncate
}
EOF
##############################################
# 8. 헬스체크 스크립트
##############################################
cat > /opt/ngfw/healthcheck.sh <<'SCRIPT'
#!/bin/bash
# NGFW Health Check — cron에서 1분 간격 실행
ALERT_THRESHOLD_DROP=100 # 드롭 증가율 임계값 (1분당)
ALERT_THRESHOLD_LATENCY=5000 # verdict 레이턴시 임계값 (μs)
LOG="/var/log/ngfw-health.log"
# NFQUEUE 드롭 카운터 확인
DROPS=$(awk '{sum += $6} END {print sum}' /proc/net/netfilter/nfnetlink_queue)
PREV_DROPS=$(cat /tmp/.ngfw_prev_drops 2>/dev/null || echo 0)
echo $DROPS > /tmp/.ngfw_prev_drops
DELTA=$(($DROPS - $PREV_DROPS))
if [ $DELTA -gt $ALERT_THRESHOLD_DROP ]; then
echo "$(date): ALERT — NFQUEUE drops +$DELTA/min" >> $LOG
# Slack/PagerDuty 웹훅 알림
curl -s -X POST "$WEBHOOK_URL" -d "{\"text\":\"NGFW ALERT: NFQUEUE drops +$DELTA/min\"}"
fi
# 워커 프로세스 생존 확인
for i in $(seq 0 7); do
if ! systemctl is-active --quiet suricata-nfq@$i; then
echo "$(date): ALERT — suricata-nfq@$i down, restarting" >> $LOG
systemctl restart suricata-nfq@$i
fi
done
# conntrack 테이블 사용률 확인
CT_MAX=$(sysctl -n net.netfilter.nf_conntrack_max)
CT_CUR=$(wc -l < /proc/net/nf_conntrack)
CT_PCT=$(($CT_CUR * 100 / $CT_MAX))
if [ $CT_PCT -gt 80 ]; then
echo "$(date): WARNING — conntrack ${CT_PCT}% full ($CT_CUR/$CT_MAX)" >> $LOG
fi
echo "$(date): OK — drops_delta=$DELTA conntrack=${CT_PCT}%" >> $LOG
SCRIPT
chmod +x /opt/ngfw/healthcheck.sh
# cron 등록
echo "* * * * * root /opt/ngfw/healthcheck.sh" > /etc/cron.d/ngfw-health
echo "=== NGFW deployment complete ==="
성능 튜닝 체크리스트
| 항목 | 기본값 | 권장값 | 효과 | 설정 명령 |
|---|---|---|---|---|
| RSS 큐 수 | 자동 (코어 수) | 8~16 | NIC→CPU 분산, NFQUEUE fanout 대응 | ethtool -L eth0 combined 8 |
| Ring Buffer | 256~512 | 4096 | burst 트래픽 시 패킷 드롭 방지 | ethtool -G eth0 rx 4096 tx 4096 |
| IRQ affinity | 자동 (irqbalance) | 수동 고정 | 캐시 미스 감소, 일관된 레이턴시 | echo N > /proc/irq/IRQ/smp_affinity |
| Interrupt Coalescing | rx-usecs 100 | rx-usecs 50 | verdict 레이턴시 50% 감소 | ethtool -C eth0 rx-usecs 50 |
| rmem_max | 212992 | 16777216 (16MB) | NFQUEUE 소켓 버퍼 확장 | sysctl -w net.core.rmem_max=16777216 |
| conntrack_max | 65536 | 2097152 (2M) | 대규모 세션 추적 지원 | sysctl -w net.netfilter.nf_conntrack_max=2097152 |
| conntrack buckets | 16384 | 524288 | 해시 충돌 감소, conntrack 조회 가속 | sysctl -w net.netfilter.nf_conntrack_buckets=524288 |
| NFQUEUE maxlen | 1024 | 8192~16384 | 큐 포화 드롭 방지 | nfq_set_queue_maxlen(qh, 8192) |
| NFQUEUE batch | 1 (개별 verdict) | 20 | syscall 횟수 90% 감소 | suricata.yaml: nfq.batchcount: 20 |
| busy_poll | 0 (비활성) | 50 (μs) | 인터럽트 지연 제거, 레이턴시 30% 감소 | sysctl -w net.core.busy_poll=50 |
| Hyperscan | Aho-Corasick | Hyperscan (hs) | 패턴 매칭 5~20x 가속 | suricata.yaml: mpm-algo: hs |
| flowtable offload | 비활성 | 활성 | 확립된 세션 커널 bypass → 처리량 2x | nft add flowtable ... flags offload |
| GRO/GSO | 활성 | 활성 유지 | 세그먼트 합산으로 패킷 수 감소 | ethtool -K eth0 gro on gso on |
| TCP timestamps | 활성 | 활성 유지 | PAWS 보호, RTT 측정 정확도 | sysctl -w net.ipv4.tcp_timestamps=1 |
- 단계별 활성화: XDP → TC eBPF → nftables → NFQUEUE 순서로 하나씩 활성화하며 각 단계에서 패킷 카운터와 드롭률을 확인합니다. 한 번에 전체를 켜면 장애 지점 파악이 어렵습니다.
- fail-open 필수: DPI 엔진이 크래시하거나 큐가 포화되면 전체 트래픽이 차단됩니다.
flags bypass(nftables)와NFQA_CFG_F_FAIL_OPEN을 반드시 설정하세요. - connmark bypass 패턴: DPI가 분류를 완료하면
ct mark 0xff를 설정하고, nftables에서 해당 mark는 NFQUEUE를 우회하도록 구성합니다. 이것이 전체 성능의 핵심입니다. - 워커 수 = NFQUEUE 수 = CPU 수: 워커마다 전용 CPU, 전용 NFQUEUE를 할당합니다.
taskset또는 systemdCPUAffinity로 고정합니다. - 로그 분리: 각 워커의 EVE JSON을 별도 파일로 출력하면 Filebeat 수집 시 병렬 처리가 가능하고 lock contention이 없습니다.
- 롤백 계획:
nft flush ruleset과 XDP detach 명령을 포함한 긴급 롤백 스크립트를 미리 준비합니다. 시스템 접근 불가 시at또는cron으로 자동 복구를 설정하세요. - 모니터링 경보: NFQUEUE drop_count 증가율, conntrack 사용률 80%+, Suricata 프로세스 다운에 대해 즉시 알림을 설정합니다.
nf_conntrack_max 값을 크게 설정하면 커널 메모리 사용이 증가합니다. conntrack 항목당 약 320바이트이므로 2M 항목은 약 640MB를 소비합니다.
서버 RAM이 충분한지 반드시 확인하세요. conntrack_buckets은 conntrack_max / 4 이상을 권장합니다.
SmartNIC / DPU 기반 DPI 오프로드
SmartNIC(DPU)은 NIC 자체에 ARM 코어와 전용 ASIC을 내장하여 호스트 CPU 부담을 줄입니다. TC flower, OVS 오프로드, 단순 패턴 매칭까지 NIC 하드웨어에서 처리하고 복잡한 DPI만 호스트에서 수행하는 하이브리드 구조가 차세대 NGFW의 핵심입니다.
SmartNIC DPI 오프로드 아키텍처
SmartNIC 종류 비교
| 제품 | 처리량 | ARM 코어 | DPI 오프로드 기능 | OVS 오프로드 | 가격대 |
|---|---|---|---|---|---|
| NVIDIA BlueField-3 | 400 Gbps | ARM Cortex-A78 × 16 | eSwitch TC flower, conntrack, regex (RXP 엔진), IPsec 오프로드 | 전체 데이터플레인 오프로드 | $2,000~4,000 |
| Intel IPU E2100 | 200 Gbps | ARM Neoverse N1 × 16 | P4 프로그래밍, Flow Director, ACL 오프로드, TLS 오프로드 | IDPF 드라이버 + OVS | $1,500~3,000 |
| AMD Pensando DSC-200 | 200 Gbps | ARM Cortex-A72 × 16 | P4 기반 스테이트풀 방화벽, NAT, ACL, 암호화 가속 | OVS 하드웨어 오프로드 | $1,200~2,500 |
| Xilinx Alveo SN1022 | 100 Gbps | FPGA 기반 (소프트 코어) | FPGA 커스텀 파이프라인, 유연한 패턴 매칭 로직 구현 가능 | OVS + TC offload | $2,500~5,000 |
TC flower + OVS 오프로드 코드 예제
# TC flower 하드웨어 오프로드 — SmartNIC eSwitch에서 처리
# 전제: representor 포트가 활성화된 상태
# 1. SmartNIC eSwitch 모드 설정 (NVIDIA BlueField)
devlink dev eswitch set pci/0000:03:00.0 mode switchdev
# 2. representor 포트 확인
ip link show | grep "repr"
# eth0: PF representor
# enp3s0f0_0: VF0 representor
# 3. TC flower offload — HTTP 트래픽을 DPI 큐로 리다이렉트
tc qdisc add dev eth0 ingress
tc filter add dev eth0 protocol ip parent ffff: \
flower \
ip_proto tcp dst_port 80 \
skip_sw \
action mirred egress redirect dev enp3s0f0_0
# 4. HTTPS (TLS) 트래픽 → DPI 호스트 포트로 미러링
tc filter add dev eth0 protocol ip parent ffff: \
flower \
ip_proto tcp dst_port 443 \
skip_sw \
action mirred egress mirror dev enp3s0f0_0
# 5. 이미 분류된 플로우 → 하드웨어에서 직접 포워딩 (bypass)
tc filter add dev eth0 protocol ip parent ffff: \
flower \
ct_state +est+trk \
skip_sw \
action ct zone 1 pipe \
action mirred egress redirect dev eth1
# 6. conntrack 오프로드 확인
tc filter show dev eth0 ingress
# "in_hw" 플래그가 표시되면 하드웨어 오프로드 성공
# skip_sw: 소프트웨어 처리 건너뛰기 (하드웨어 전용)
# skip_hw: 하드웨어 오프로드 건너뛰기 (소프트웨어 전용)
# 둘 다 생략하면 하드웨어 시도 후 실패 시 소프트웨어 폴백
# OVS-DPDK 오프로드 예시 (BlueField DPU)
# 1. OVS에 하드웨어 오프로드 활성화
ovs-vsctl set Open_vSwitch . other_config:hw-offload="true"
ovs-vsctl set Open_vSwitch . other_config:tc-policy="skip_sw"
# 2. 브리지 생성 + 포트 연결
ovs-vsctl add-br br-ngfw -- set bridge br-ngfw datapath_type=netdev
ovs-vsctl add-port br-ngfw pf0hpf -- set interface pf0hpf type=dpdk \
options:dpdk-devargs="0000:03:00.0,representor=[0]"
ovs-vsctl add-port br-ngfw p0 -- set interface p0 type=dpdk \
options:dpdk-devargs="0000:03:00.0"
# 3. 오프로드된 플로우 확인
ovs-appctl dpctl/dump-flows type=offloaded
# "offloaded:yes" 항목이 SmartNIC에서 처리 중인 플로우
# 4. 통계 확인
ovs-ofctl dump-flows br-ngfw | grep "n_packets"
DPDK + NFQUEUE 하이브리드 아키텍처
DPDK로 초고속 패킷 캡처를 수행하고, 의심스러운 플로우만 NFQUEUE로 전달하여 심층 검사하는 하이브리드 구조입니다. 전체 40Mpps 중 실제 DPI 대상은 5~10%(2~4Mpps)에 불과하므로 호스트 CPU 부담을 극적으로 줄입니다.
# DPDK + NFQUEUE 하이브리드 구성
# 1. DPDK hugepage 설정
echo 4096 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
mkdir -p /dev/hugepages
mount -t hugetlbfs nodev /dev/hugepages
# 2. NIC을 DPDK 드라이버로 바인딩 (포트 0만)
dpdk-devbind.py -b vfio-pci 0000:03:00.0
# 3. DPDK 애플리케이션 — 1차 분류
# 40 Mpps 수신 → 5-tuple 해시 + 알려진 프로토콜 분류
# 분류 결과:
# - 알려진 플로우 (90%): DPDK에서 직접 포워딩 → 40 Mpps
# - 미분류/의심 (10%): KNI/virtio → 커널 네트워크 스택
# - 차단 대상: DPDK에서 즉시 DROP
# 4. KNI (Kernel NIC Interface)로 커널 전달
# DPDK 앱에서 rte_kni_tx_burst()로 의심 패킷을 커널에 주입
# 커널 측 vEth0 인터페이스로 수신
# 5. 커널 측 NFQUEUE로 DPI 대상 패킷 전달
nft add table inet dpdk_dpi
nft add chain inet dpdk_dpi forward { type filter hook forward priority 0\; }
nft add rule inet dpdk_dpi forward \
iif "vEth0" \
queue num 0-3 flags bypass,fanout
# 6. Suricata가 NFQUEUE에서 심층 DPI 수행
# 의심 플로우만 처리하므로 3~4 Mpps 수준
| 구간 | 처리 방식 | 처리량 | 레이턴시 |
|---|---|---|---|
| NIC → DPDK | Poll mode (PMD), 제로카피 | 40 Mpps (line rate) | < 5 μs |
| DPDK 1차 분류 | 5-tuple 해시 + flow table | 40 Mpps | ~1 μs |
| DPDK → 직접 포워딩 | 알려진 플로우 bypass | 36 Mpps (90%) | < 10 μs |
| DPDK → KNI → 커널 | 의심 플로우 커널 주입 | 4 Mpps (10%) | ~50 μs |
| NFQUEUE → Suricata DPI | Hyperscan + nDPI L7 분류 | 3 Mpps | 100~500 μs |
| DPI 결과 → DPDK | flow table 갱신 (분류 완료) | - | < 1 ms |
AF_XDP를 사용하면 커널 주입 시 제로카피가 가능하여 레이턴시를 추가로 줄일 수 있습니다.
Grafana + Prometheus 기반 NFQUEUE 모니터링
프로덕션 NGFW 환경에서는 NFQUEUE, DPI 엔진, 시스템 리소스를 종합적으로 모니터링해야 합니다. Prometheus로 메트릭을 수집하고 Grafana 대시보드로 시각화하는 파이프라인을 구성합니다.
핵심 모니터링 메트릭
| 메트릭 | 소스 | 설명 | 경보 임계값 |
|---|---|---|---|
nfqueue_total_packets | /proc/net/netfilter/nfnetlink_queue | 큐에 입력된 전체 패킷 수 | - |
nfqueue_queue_length | /proc/net/netfilter/nfnetlink_queue | 현재 큐에 대기 중인 패킷 수 | > maxlen × 0.8 |
nfqueue_dropped | /proc/net/netfilter/nfnetlink_queue | 큐 포화 드롭 카운터 | 증가율 > 100/min |
nfqueue_user_dropped | /proc/net/netfilter/nfnetlink_queue | 유저스페이스 verdict 오류 드롭 | 증가율 > 10/min |
nfqueue_verdict_latency_us | bpftrace / Suricata stats | enqueue → verdict 지연 시간 | p99 > 5,000 μs |
dpi_patterns_matched | Suricata EVE stats | DPI 시그니처 매칭 횟수 | 급증 시 공격 의심 |
dpi_sessions_active | Suricata stats | 현재 DPI 추적 중인 세션 수 | > 500,000 |
dpi_cache_hit_ratio | TC eBPF map 통계 | flow_cache 캐시 적중률 | < 80% |
conntrack_entries | /proc/sys/net/netfilter/nf_conntrack_count | 현재 conntrack 항목 수 | > max × 0.8 |
system_cpu_usage | node_exporter | CPU 사용률 (코어별) | > 90% |
system_memory_usage | node_exporter | 메모리 사용률 | > 85% |
nic_rx_packets | node_exporter / ethtool | NIC 수신 패킷 수 | - |
nic_rx_dropped | ethtool -S | NIC 레벨 드롭 | 증가율 > 0 |
Prometheus Exporter (Python)
#!/usr/bin/env python3
# nfqueue_exporter.py — NFQUEUE 메트릭을 Prometheus로 노출
# 실행: python3 nfqueue_exporter.py (포트 9101)
from prometheus_client import start_http_server, Gauge, Counter
import time
import os
# Prometheus 메트릭 정의
QUEUE_TOTAL = Gauge('nfqueue_queue_total',
'패킷 큐 대기 수', ['queue_num'])
QUEUE_DROPPED = Counter('nfqueue_dropped_total',
'큐 포화 드롭 누적', ['queue_num'])
USER_DROPPED = Counter('nfqueue_user_dropped_total',
'유저 verdict 오류 드롭 누적', ['queue_num'])
QUEUE_SEQ = Gauge('nfqueue_seq_id',
'현재 시퀀스 ID (처리량 추정)', ['queue_num'])
CONNTRACK_CUR = Gauge('conntrack_entries_current',
'현재 conntrack 항목 수')
CONNTRACK_MAX = Gauge('conntrack_entries_max',
'최대 conntrack 항목 수')
# 이전 드롭 값 저장 (Counter 증분 계산용)
prev_drops = {}
prev_user_drops = {}
def read_nfqueue_stats():
"""
/proc/net/netfilter/nfnetlink_queue 파싱
컬럼: queue_num portid queue_total copy_mode copy_range
drop_count user_drop_count id_sequence
"""
path = '/proc/net/netfilter/nfnetlink_queue'
if not os.path.exists(path):
return
with open(path) as f:
for line in f:
fields = line.strip().split()
if len(fields) < 8:
continue
qnum = fields[0]
queue_total = int(fields[2])
drop_count = int(fields[5])
user_drop = int(fields[6])
seq_id = int(fields[7])
QUEUE_TOTAL.labels(queue_num=qnum).set(queue_total)
QUEUE_SEQ.labels(queue_num=qnum).set(seq_id)
# Counter는 증분만 추가 (재시작 시 리셋 처리)
if qnum in prev_drops:
delta = drop_count - prev_drops[qnum]
if delta > 0:
QUEUE_DROPPED.labels(queue_num=qnum).inc(delta)
prev_drops[qnum] = drop_count
if qnum in prev_user_drops:
delta = user_drop - prev_user_drops[qnum]
if delta > 0:
USER_DROPPED.labels(queue_num=qnum).inc(delta)
prev_user_drops[qnum] = user_drop
def read_conntrack_stats():
"""conntrack 항목 수와 최대값 읽기"""
try:
with open('/proc/sys/net/netfilter/nf_conntrack_count') as f:
CONNTRACK_CUR.set(int(f.read().strip()))
with open('/proc/sys/net/netfilter/nf_conntrack_max') as f:
CONNTRACK_MAX.set(int(f.read().strip()))
except FileNotFoundError:
pass
if __name__ == '__main__':
# Prometheus HTTP 서버 시작 (포트 9101)
start_http_server(9101)
print("NFQUEUE exporter listening on :9101/metrics")
while True:
read_nfqueue_stats()
read_conntrack_stats()
time.sleep(5) # 5초 간격 수집
# Prometheus 스크래핑 설정 (prometheus.yml)
scrape_configs:
- job_name: 'nfqueue'
static_configs:
- targets: ['localhost:9101']
scrape_interval: 10s
- job_name: 'suricata'
static_configs:
- targets: ['localhost:9200'] # suricata-exporter
scrape_interval: 15s
- job_name: 'node'
static_configs:
- targets: ['localhost:9100'] # node_exporter
scrape_interval: 10s
Suricata EVE JSON → Elasticsearch 파이프라인
# Filebeat 설정 — /etc/filebeat/filebeat.yml
# Suricata EVE JSON을 Elasticsearch로 전송
filebeat.inputs:
- type: log
enabled: true
paths:
- /var/log/suricata/eve-*.json
json.keys_under_root: true
json.add_error_key: true
json.overwrite_keys: true
# Suricata 모듈 활성화 (자동 파싱 + 인덱스 매핑)
filebeat.modules:
- module: suricata
eve:
enabled: true
var.paths: ["/var/log/suricata/eve-*.json"]
# Elasticsearch 출력
output.elasticsearch:
hosts: ["https://elasticsearch:9200"]
index: "suricata-%{+yyyy.MM.dd}"
username: "elastic"
password: "${ES_PASSWORD}"
ssl.certificate_authorities: ["/etc/filebeat/ca.crt"]
# ILM (Index Lifecycle Management) 정책
setup.ilm.enabled: true
setup.ilm.rollover_alias: "suricata"
setup.ilm.pattern: "{now/d}-000001"
setup.ilm.policy_name: "suricata-policy"
# 프로세서 — 추가 필드 파싱
processors:
- decode_json_fields:
fields: ["message"]
target: ""
overwrite_keys: true
- add_host_metadata: ~
- add_cloud_metadata: ~
# Logstash 대안 — /etc/logstash/conf.d/suricata.conf
# Filebeat 대신 Logstash를 사용할 경우
input {
file {
path => "/var/log/suricata/eve-*.json"
codec => json
sincedb_path => "/var/lib/logstash/sincedb_suricata"
start_position => "beginning"
}
}
filter {
# 이벤트 타입별 분류
if [event_type] == "alert" {
mutate { add_tag => ["suricata_alert"] }
}
if [event_type] == "tls" {
# JA3 지문 추출
mutate { add_field => { "ja3_hash" => "%{[tls][ja3][hash]}" } }
}
if [event_type] == "flow" {
# 플로우 기간 계산
ruby {
code => "
if event.get('[flow][start]') and event.get('[flow][end]')
start_t = Time.parse(event.get('[flow][start]'))
end_t = Time.parse(event.get('[flow][end]'))
event.set('flow_duration_sec', (end_t - start_t).round(3))
end
"
}
}
# GeoIP 추가 (외부 IP)
geoip {
source => "src_ip"
target => "src_geo"
}
geoip {
source => "dest_ip"
target => "dest_geo"
}
# 타임스탬프 파싱
date {
match => ["timestamp", "ISO8601"]
target => "@timestamp"
}
}
output {
elasticsearch {
hosts => ["https://elasticsearch:9200"]
index => "suricata-%{event_type}-%{+YYYY.MM.dd}"
user => "elastic"
password => "${ES_PASSWORD}"
ssl_certificate_authorities => ["/etc/logstash/ca.crt"]
}
}
- Row 1 (개요): NFQUEUE 큐별 대기 패킷 수, 초당 verdict 처리량, 전체 드롭률을 Stat/Gauge 패널로 배치합니다.
- Row 2 (레이턴시): verdict 레이턴시 히스토그램(p50/p95/p99)을 Heatmap 또는 Histogram 패널로 표시합니다.
- Row 3 (DPI): Suricata alert 발생률(시간별), 탐지된 프로토콜 분포(파이 차트), 상위 시그니처 TOP 10을 표시합니다.
- Row 4 (시스템): CPU 코어별 사용률, 메모리, conntrack 사용률, NIC rx/tx 패킷률을 Graph 패널로 표시합니다.
- Alert 규칙:
rate(nfqueue_dropped_total[5m]) > 10,conntrack_entries_current / conntrack_entries_max > 0.8, Suricata 프로세스 다운 탐지를 Grafana Alert로 설정합니다. - EVE JSON 인덱스: 이벤트 타입별로 인덱스를 분리하면(
suricata-alert-*,suricata-flow-*) Kibana에서 검색 성능이 향상됩니다.
커널 소스 구조
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 추출 대안 접근