🔥

[3/5] UFW에서 막았는데 왜 통신됐을까 — nftables로 다시 만든 신뢰 경계

Description
Docker·Tailscale과 UFW의 실제 chain 평가를 추적하고 native nftables의 기본 DROP·상태 기반 화이트리스트로 전환한 과정
URL
기간
Aug 11, 2025 → Oct 12, 2025
분야
보안
상태
초안
시리즈
Security Foundations
요약
UFW 상태와 실제 패킷 흐름이 달랐던 원인을 Netfilter hook, chain priority, verdict와 packet mark 관점에서 분석하고 최종 ruleset을 공개한다.
원작성일
Aug 26, 2026
태그
Linux
Docker
Pentesting
🔥
재현 결과: UFW forwarding policy가 DROP인 상태에서도 Moonlight 통신이 성공했다.
분석 대상: UFW, Docker, Tailscale, native nftables의 hook·priority·verdict

문제 재현

UFW의 기본 정책을 DROP으로 설정한 뒤에도 두 흐름은 예상과 다르게 동작했다.
Docker 컨테이너의 0.0.0.0:80→80, 0.0.0.0:443→443 published port는 UFW 규칙에서 예상한 경로로 차단되지 않았다. Docker가 NAT와 FORWARD 규칙을 직접 생성하기 때문이다.
Tailscale–Moonlight 경로도 UFW forwarding 차단 후 계속 동작했다. UFW 상태 화면에 표시되지 않는 chain까지 포함해 실제 Netfilter ruleset을 확인해야 했다. ,
Docker 공식 문서도 Docker와 UFW가 서로 다른 방식으로 방화벽 규칙을 다루기 때문에 published container traffic이 UFW의 INPUT·OUTPUT 경로보다 먼저 NAT에서 전환될 수 있다고 설명한다. Docker packet filtering and firewalls
이 서버에서는 UFW 외에도 Docker와 Tailscale이 Netfilter 규칙을 생성한다. 최종 허용·차단 결과는 전체 ruleset의 hook, priority, chain 순서로 확인해야 한다.

방화벽 구성 요소

당시 시스템은 세 층이 섞여 있었다.
  1. Docker는 iptables backend를 사용하며 DOCKER-USER, DOCKER-FORWARD 등의 chain을 만들었다.
  1. Ubuntu의 iptables 명령은 iptables-nft 호환 backend를 통해 nf_tables 커널 인프라에 규칙을 표현할 수 있었다.
  1. 별도의 native nft 문법으로 table inet filter를 작성했다.
Docker의 backend는 iptables 계열이며, 사용자 정책은 native nft 문법의 table inet filter에 작성했다. 두 종류의 chain은 같은 Netfilter hook에서 평가된다.
Docker의 최신 native nftables backend에는 DOCKER-USER chain이 없다. 별도 nftables table과 base-chain priority를 사용하는 현재 방식은 이 서버의 당시 iptables backend 구성과 다르다. Docker with iptables, Docker with nftables

Netfilter hook별 처리 대상

hook
처리 대상
이 서버의 예
prerouting
라우팅 결정 전의 모든 수신 패킷
Docker published port의 DNAT
input
Linux 서버 자신이 목적지인 패킷
Tailnet → Linux 관리 접속
forward
Linux를 통과해 다른 장비로 가는 패킷
MacBook → Windows VM
output
Linux 프로세스가 생성한 패킷
Linux → 패키지 저장소
postrouting
인터페이스로 나가기 직전의 패킷
SNAT·masquerade
Windows 분석망의 패킷은 목적지에 따라 INPUT과 FORWARD로 나뉜다.
  • Windows VM이 Linux 서버의 SSH나 서비스에 접근하면 INPUT이다.
  • Windows VM이 Linux를 경유해 Tailnet·WAN·Docker로 가면 FORWARD다.
  • Linux가 Windows VM에 먼저 연결하면 OUTPUT이다.
INPUT policy를 DROP으로 해도 FORWARD가 열려 있으면 VM은 다른 네트워크로 이동할 수 있다. 반대로 FORWARD를 막아도 Linux 자체 서비스의 INPUT이 열려 있으면 VM은 게이트웨이를 공격할 수 있다. 그래서 두 경계를 따로 설계했다.

base chain priority와 verdict

nftables base chain은 같은 hook에 여러 개 존재할 수 있고 priority가 낮은 chain부터 평가된다. 앞 chain에서 accept되어도 뒤 priority의 base chain이 패킷을 다시 보고 drop할 수 있다. 반면 drop verdict는 즉시 적용되어 뒤 chain으로 가지 않는다. nftables: Configuring chains
다음 priority -5 base chain을 적용하면 Docker chain보다 먼저 평가된다.
chain forward { type filter hook forward priority -5; policy drop; }
priority -5 chain이 Docker의 일반 filter priority보다 먼저 실행되고 policy drop에 도달하면 패킷은 Docker chain으로 진행하지 않는다. 앞 chain의 accept 이후에도 뒤 priority의 base chain은 같은 패킷을 다시 평가할 수 있다.
최종 ruleset은 다음 순서로 verdict를 전달한다.
  • 서비스가 만든 앞단 chain에서는 “허용 후보”를 packet mark로 표시한다.
  • 마지막 native inet filter에서 state·mark·명시적 서비스 규칙을 확인한다.
  • 어느 조건에도 맞지 않으면 마지막 base chain의 policy drop이 verdict를 내린다.

필터링 요구사항

출발지
목적지
정책
Tailnet
Windows 10.10.0.20
Sunshine/Moonlight 포트와 SSH만 허용
Windows 분석망
Linux 호스트
신규 연결 차단
Windows 분석망
WAN
일반 라우팅 차단
Windows 분석망
Tailnet
명시한 포트 외 차단
Windows 분석망
Docker foxirain4
TCP 31234 신규 연결만 허용
이미 허용된 세션의 응답
원래 연결의 반대 방향
established·related 허용
NIST SP 800-125B는 VM 보호에서 network segmentation, firewall traffic control, monitoring을 별도 설계 요소로 다룬다. 이 서버에서는 Windows VM의 INPUT·FORWARD 트래픽을 Linux에서 필터링한다. NIST SP 800-125B

/etc/nftables.conf

서버에 저장된 설정 전체다. interface 변수, Docker packet mark, INPUT, FORWARD, Sunshine/Moonlight, gdbserver 규칙을 포함한다.
#!/usr/sbin/nft -f define IF_INTERNAL = "enx00e04c637a20" # VM 1:1 내부망 define IF_WAN = "enp1s0" # 외부 NIC define IF_TS = "tailscale0" # Tailscale NIC define IF_foxirain4 = "foxirain4" define VM_IP = 10.10.0.20 # Docker 브리지 세트 define DOCKER_BR = { "docker0", "br-042225748a2d", "foxirain4" } # 마크 네임스페이스: 상위 바이트만 사용 define MARK_NS_MASK = 0xFF000000 define MARK_ALLOW = 0x01000000 define MARK_KEEP_MASK = 0x00FFFFFF # 당시 방화벽에 허용한 Sunshine/Moonlight 포트 세트 define S_TCP = { 47984-47990, 48010 } define S_UDP = { 47998-48010 } flush chain ip filter DOCKER-USER table ip filter { chain DOCKER-USER { # Docker published port의 DNAT 흐름 ct status dnat oifname $DOCKER_BR meta mark set (meta mark & $MARK_KEEP_MASK) | $MARK_ALLOW # Docker bridge에서 WAN으로 나가는 egress iifname $DOCKER_BR oifname $IF_WAN meta mark set (meta mark & $MARK_KEEP_MASK) | $MARK_ALLOW # foxirain4에서 Windows 분석망으로 나가는 흐름 iifname $IF_foxirain4 oifname $IF_INTERNAL meta mark set (meta mark & $MARK_KEEP_MASK) | $MARK_ALLOW } } table inet filter { chain input { type filter hook input priority filter + 1; policy drop; jump IN-STATE jump IN-LO jump IN-SSH jump IN-TS iif $IF_INTERNAL counter drop comment "block VM->server (host services)" } chain IN-STATE { ct state { established, related } counter accept return } chain IN-LO { iif "lo" counter accept return } chain IN-SSH { iif $IF_WAN tcp dport 22 ct state new meter ssh4_per_ip { ip saddr limit rate 6/minute burst 3 packets } counter accept comment "SSH v4" iif $IF_WAN tcp dport 22 ct state new meter ssh6_per_ip { ip6 saddr limit rate 6/minute burst 3 packets } counter accept comment "SSH v6" return } chain IN-TS { iif $IF_TS counter accept comment "allow tailscale mgmt to server" return } chain forward { type filter hook forward priority filter + 1; policy drop; jump FWD-STATE jump FWD-DOCKER jump FWD-TS jump FWD-VM } chain FWD-STATE { ct state { established, related } counter accept return } chain FWD-DOCKER { meta mark & 0xFF000000 == 0x01000000 counter accept comment "marked by DOCKER-USER" iifname $IF_INTERNAL oifname $IF_foxirain4 ip saddr 10.10.0.20 tcp dport 31234 ct state new counter accept comment "foxirain5 -> foxirain4 gdbserver" return } chain FWD-TS { iif $IF_TS oif $IF_INTERNAL ip daddr $VM_IP tcp dport $S_TCP counter accept comment "TS->VM TCP (Moonlight)" iif $IF_TS oif $IF_INTERNAL ip daddr $VM_IP udp dport $S_UDP counter accept comment "TS->VM UDP (Moonlight)" iif $IF_TS oif $IF_INTERNAL ip daddr $VM_IP tcp dport 22 counter accept comment "TS->VM SSH" iif $IF_INTERNAL oif $IF_TS ip saddr $VM_IP tcp dport $S_TCP counter accept comment "VM->TS TCP (Sunshine)" iif $IF_INTERNAL oif $IF_TS ip saddr $VM_IP udp dport $S_UDP counter accept comment "VM->TS UDP (Sunshine)" iif $IF_INTERNAL oif $IF_TS counter drop comment "block VM->TS other ports" iif $IF_TS oif $IF_INTERNAL counter drop comment "block TS->VM other ports" return } chain FWD-VM { iif $IF_INTERNAL counter drop comment "block VM->* routed" return } chain output { type filter hook output priority 0; policy accept; } }

INPUT chain

input policy drop ├─ IN-STATE : 이미 허용된 세션의 응답 ├─ IN-LO : loopback ├─ IN-SSH : WAN SSH, IP별 6/minute burst 3 ├─ IN-TS : tailscale0에서 Linux로 들어오는 관리 트래픽 └─ internal : Windows 분석망에서 Linux 호스트로 오는 신규 연결 DROP
Windows VM이 Linux 서버의 서비스를 새로 탐색하거나 접속하려 하면 ingress가 enx00e04c637a20이므로 마지막 drop에 걸린다. Linux가 먼저 Windows에 연결한 세션의 응답은 IN-STATE에서 통과한다.
tailscale0에서 Linux 호스트로 들어오는 트래픽은 인터페이스 단위로 허용한다. enx00e04c637a20에서 Linux 호스트로 시작하는 Windows VM의 신규 연결은 차단한다.

FORWARD chain

FWD-STATE → FWD-DOCKER → FWD-TS → FWD-VM → policy drop
  1. FWD-STATE가 기존 허용 세션의 응답을 처리한다.
  1. FWD-DOCKER가 Docker mark와 TCP 31234 예외를 처리한다.
  1. FWD-TS가 Tailnet ↔ Windows의 명시적 서비스 포트를 처리한다.
  1. FWD-VM이 분석 NIC에서 시작한 나머지 라우팅을 모두 버린다.
  1. 어떤 사용자 chain도 허용하지 않은 패킷은 base chain policy drop으로 끝난다.
connection tracking의 established는 양방향으로 유효한 패킷을 본 연결이고, related는 기존 연결로 인해 예상된 별도 연결을 뜻한다. nftables conntrack metadata

Docker chain–native nftables packet mark 연동

define MARK_ALLOW = 0x01000000 define MARK_KEEP_MASK = 0x00FFFFFF meta mark set (meta mark & $MARK_KEEP_MASK) | $MARK_ALLOW
상위 8비트만 허용 표식으로 사용하고 하위 24비트는 보존했다. Docker·Tailscale 등 다른 구성 요소가 사용하는 mark 전체를 덮어쓰지 않기 위해서다.
DOCKER-USER에서 표식을 남긴 뒤 마지막 FWD-DOCKER가 다음 조건을 검사한다.
meta mark & 0xFF000000 == 0x01000000 accept
이 방식은 “앞 chain의 accept가 뒤 chain의 drop을 이긴다”고 가정하지 않는다. 앞에서는 정보를 남기고, 최종 정책 chain이 그 정보를 읽어 verdict를 결정한다.

흐름별 verdict

흐름
결과
결정 지점
Tailnet → Windows SSH 22
ALLOW
FWD-TS
Tailnet → Windows 스트리밍 포트
ALLOW
FWD-TS
Tailnet → Windows 임의 포트
DROP
FWD-TS
Windows → Tailnet 동일 포트 세트의 신규 연결
ALLOW
FWD-TS 역방향 예외
Windows → Tailnet 다른 포트
DROP
FWD-TS
Windows → Linux 호스트 신규 연결
DROP
INPUT internal drop
Windows → WAN
DROP
FWD-VM
Windows → foxirain4 TCP 31234
ALLOW
FWD-DOCKER
Windows → foxirain4 다른 포트
DROP
FWD-VM
Docker bridge → WAN
ALLOW
DOCKER-USER mark → FWD-DOCKER
foxirain4 → Windows 분석망
ALLOW
DOCKER-USER mark → FWD-DOCKER

현재 ruleset의 과도한 허용 범위

최종 코드를 다시 읽으면 다음 범위가 명확하다.
  • VM → Tailnet의 역방향 스트리밍 규칙은 목적지 IP가 없어 동일 목적 포트를 연 Tailnet 장비가 대상이 될 수 있다.
  • gdbserver 예외는 목적지 10.20.0.10을 명시하지 않아 foxirain4의 TCP 31234 리스너 전체가 대상이다.
  • foxirain4 → 분석 NIC의 mark는 목적지·포트가 없어 Docker에서 Windows 방향 신규 흐름을 넓게 허용한다.
  • Tailnet → Linux INPUT은 tailscale0 전체를 관리망으로 신뢰한다.
  • OUTPUT policy accept이므로 Linux가 시작하는 연결은 제한하지 않는다.
  • Docker bridge → WAN mark가 있으므로 Docker망은 인터넷 격리망이 아니다.
현재 ruleset에는 목적지 IP 또는 포트가 생략된 규칙이 남아 있다. gdbserver 목적지 IP, Docker → Windows 목적 포트, VM → Tailnet 목적지 주소를 추가하면 허용 범위를 줄일 수 있다.
예를 들어 Windows → gdbserver 규칙은 다음처럼 좁힐 수 있다.
iifname "enx00e04c637a20" oifname "foxirain4" ip saddr 10.10.0.20 ip daddr 10.20.0.10 tcp dport 31234 ct state new accept

정리

INPUT과 FORWARD의 기본 policy는 DROP이다. 신규 연결은 interface·source·destination·port 조건으로 허용하고, 응답은 established,related로 처리한다.
Docker의 DOCKER-USER는 허용 대상에 packet mark를 설정한다. native inet filter의 FWD-DOCKER가 해당 mark와 서비스 규칙을 확인해 accept한다. 목적지 IP와 포트가 생략된 세 규칙은 별도 절에 현재 허용 범위로 기록했다.

Sources

작업 기록

공식 문서


시리즈 이동

전체 시리즈