The idea: remember what went out
A firewall between an office and the Internet usually wants one simple thing: allow new connections out, let their replies back in, drop the rest. The hard part is the middle. A reply is just a packet from the Internet. How does the firewall know it answers something the laptop sent?
A stateless ACL cannot know. It looks at one packet at a time and only sees the header: interface, protocol, ports, TCP flags. A stateful firewall keeps a table of every flow it has let out. In Linux that table is conntrack (part of netfilter), and nftables or iptables rules can ask it one question: what is this packet's ct state?
Stateless ACL: judged by the header alone
Without state, the only way to let replies in is to open holes by header. TCP replies carry the ACK flag, so the ACL accepts "any TCP with ACK set" (Cisco's old established keyword does exactly this). DNS replies come from port 53, so it accepts "any UDP from port 53". Normal traffic works.
But the attacker writes the header. A lone ACK sent to port 22 passes rule 2, and the laptop answers with a RST, so the scanner learns the host is up (that is nmap -sA). A UDP packet with source port 53 reaches any UDP port, here the mDNS responder that was meant for the LAN (Demo: same probes vs a stateless ACL). And anything the ACL forgot, such as ICMP errors, is dropped even when it matters.
The conntrack table and the reversed tuple
Each entry is keyed by the flow's tuple: protocol, source address and port, destination address and port, as the first packet had them. conntrack -L also prints the reply tuple, which is the same with source and destination swapped. When a packet arrives, conntrack checks both. Matching the original tuple means "more from the side that started"; matching the reversed one means "a reply". No rule has to name port 443 or the server's address: the entry already says who may answer.
The lookup happens before any rule runs (in the prerouting hook). Watch entry #1 in Demo: TCP out and back, stateful: SYN creates it in SYN_SENT and marked [UNREPLIED], the SYN-ACK matches it reversed (SYN_RECV), the last ACK makes it ESTABLISHED and [ASSURED], and the FIN exchange walks it through FIN_WAIT, LAST_ACK and TIME_WAIT.
ct state: NEW, ESTABLISHED, RELATED, INVALID
| ct state | meaning | on the canvas |
|---|---|---|
NEW | no entry yet: the first packet of a flow. conntrack prepares a tentative entry. | the laptop's SYN and DNS query; the scanner's SYN and UDP |
ESTABLISHED | matches an entry, in either direction. The first reply already counts. | SYN-ACK, GET, 200 OK, FIN, DNS reply |
RELATED | a new packet that belongs to a known flow: an ICMP error quoting its header, or an FTP data connection announced by a helper | ICMP "fragmentation needed" |
INVALID | conntrack cannot place it: a SYN-ACK with no SYN, an ICMP error quoting an unknown flow, a TCP segment far out of window | ICMP sent with no connection open |
One detail: with net.netfilter.nf_conntrack_tcp_loose = 1 (the default) a lone ACK is picked up as NEW, not INVALID, so that connections survive a firewall reboot. The ruleset still drops it, because rule 3 accepts NEW only from lan (Demo: probes from outside, stateful).
RELATED: why ICMP errors must get in
When a 1500-byte packet meets a link with a smaller MTU and has "don't fragment" set, the router sends back ICMP type 3 code 4, "fragmentation needed", with the MTU and the start of the packet that did not fit. The sender lowers its path MTU and resends smaller segments. Conntrack reads the quoted header, finds the entry, and marks the ICMP RELATED, so rule 1 lets it in (Demo: ICMP frag needed is RELATED).
The stateless ACL has no rule for it and drops it. The laptop never learns, keeps sending big packets that vanish, and the connection hangs once the server sends a full-size segment: a Path MTU black hole (Demo: stateless ACL drops ICMP (PMTU)). See traceroute for other ICMP errors on the path.
Timeouts: an entry lives only so long
conntrack does not see the applications, so it cannot know when a flow is over. Every entry has a timer that restarts with each packet, and its length depends on the protocol and the state:
sysctl (net.netfilter.) | default | used for |
|---|---|---|
nf_conntrack_tcp_timeout_syn_sent | 120 s | SYN sent, no answer yet |
nf_conntrack_tcp_timeout_syn_recv | 60 s | SYN-ACK seen, waiting for the last ACK |
nf_conntrack_tcp_timeout_established | 432000 s (5 days) | open connection, even when idle |
nf_conntrack_tcp_timeout_fin_wait / last_ack / time_wait | 120 / 30 / 120 s | closing |
nf_conntrack_udp_timeout | 30 s | UDP; 120 s (udp_timeout_stream) once traffic flows both ways for a while |
So a reply that comes after the timer ran out is dropped like any stranger (Demo: late DNS reply after the timeout). The same thing bites long idle TCP connections behind firewalls with short timeouts, which is why database drivers and SSH send keepalives. The stateless ACL happily delivers the late reply, but only because it would deliver any packet from port 53.
Dropped packets never confirm an entry
The tentative entry of a NEW packet goes into the table only when the packet leaves netfilter accepted. A probe that hits policy drop leaves nothing behind, so a scan cannot fill the table with its own entries. The table is still finite (nf_conntrack_max), and a flood of accepted new connections can fill it; then the kernel logs "table full, dropping packet". The NAT page uses the same table to remember address rewrites, and Docker and Kubernetes rely on it for their DNAT rules.
Writing the rules
table inet filter {
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "lan" oifname "wan" ct state new accept
}
}
The same with iptables: iptables -P FORWARD DROP, then -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT, -A FORWARD -m conntrack --ctstate INVALID -j DROP, -A FORWARD -i lan -o wan -m conntrack --ctstate NEW -j ACCEPT. The established rule comes first because almost every packet is part of a flow already: one lookup and done. To let a server inside accept connections, add a NEW rule from wan to its address and port, for example ip daddr 203.0.113.20 tcp dport 443 ct state new accept. Opening the TCP handshake page next to this one shows the same SYN, SYN-ACK, ACK from the endpoints' side.
What the page leaves out
There is no NAT here: the office has public addresses. The INPUT and OUTPUT chains (traffic to and from the firewall itself), IPv6, connection marks and zones, the raw table with notrack, and conntrack helpers (FTP, SIP) that create RELATED expectations are not shown. TCP window tracking, which makes out-of-window segments INVALID, is reduced to the SYN-ACK case. UDP entries never reach the 120 s stream timeout, every action takes one second, and the table shows at most five entries.