Stateful Firewalls Explained: Judge the Connection, Not the Packet
This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.
In Lab #20, NAT used the kernel's connection tracker to translate addresses. This lab uses the same conntrack for the other classic job: a stateful firewall. Instead of judging each packet alone, the firewall remembers flows and lets a packet through if it belongs to an allowed conversation.
Reading guide: rfc-notes/stateful-firewall.md
Prerequisite: Lab 20: NAT — Source Address Translation
Expected time: 40–55 minutes.
The Goal
A firewall router sits between a client and a server with the FORWARD policy set to DROP, allowing only:
- packets in an ESTABLISHED or RELATED flow, and
- NEW connections that arrive from the client side.
So:
- client → server works: the SYN is NEW-from-inside (allowed), and the server's reply is ESTABLISHED (allowed),
- server → client is blocked: the server's SYN is an unsolicited NEW-from-outside — no rule allows it, so the default DROP applies.
Both directions carry server→client packets; what differs is the conntrack state.
By the end, you should be able to explain this table:
| direction | first packet's state | result |
|---|---|---|
| client → server | NEW from inside (eth1) | allowed; reply is ESTABLISHED |
| server → client | NEW from outside | dropped (default DROP) |
What You Will Learn
- The difference between a stateless filter and a stateful firewall.
- What conntrack records (both directions of a flow in one entry).
- What the ctstate values NEW / ESTABLISHED / RELATED / INVALID mean and how a policy matches them.
- The default-drop + allow-established + allow-new-from-inside idiom.
- Why the reply to an allowed connection needs no rule of its own.
This lab does not cover:
- L7 / application-aware firewalling (deep packet inspection).
- nftables syntax (the newer front-end to the same engine).
- NAT/port-forwarding (Lab 20) or full zone-based policy.
Where to Read in the RFCs
| Reference | What to focus on |
|---|---|
| RFC 2979 | Firewall requirements, default-deny |
| RFC 9293 §3.3 | The TCP state machine (which conntrack mirrors) |
| RFC 7857 | Conntrack timeouts and states |
| RFC 5737 / RFC 1918 | Confirming the addresses used here are local/documentation-only |
The Big Picture
Between the client and the server sits fw, a router. fw sets its FORWARD policy to default DROP and permits traffic by state.
client (10.0.9.2) --- eth1 [ fw ] eth2 --- server (10.0.8.2)
FORWARD policy DROP
+ ESTABLISHED,RELATED ACCEPT
+ (in eth1) NEW ACCEPT
Both hosts run an HTTP responder, so we can test reachability in both directions.
flowchart LR
C["client"] -->|"① SYN: NEW from eth1 ✔"| F["fw (FORWARD DROP)"]
F -->|"forwarded"| S["server"]
S -->|"② reply: ESTABLISHED ✔"| F
F --> C
S -.->|"③ SYN: NEW from outside ✘ dropped"| F
10.0.0.0/8 is a local, closed range.
Note: Everything here uses local/documentation address space (RFC 1918 / RFC 5737), so nothing in this lab touches the real internet.
What You Need
Recommended environment:
- Linux / WSL2 / a Linux VM
- Docker
- containerlab
Images used:
nicolaka/netshoot:latest— it bundlesiptables,conntrack,curl, andpython3.
No additional images are required.
Running the Lab
The quick path, which deploys, verifies, and tears down for you:
./scripts/labctl.sh run fw-36
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/fw-36
2. Deploy
sudo containerlab deploy -t fw-36.clab.yml
3. Install the stateful policy
docker exec clab-fw-36-fw sh -c '
iptables -P FORWARD DROP
iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -i eth1 -m conntrack --ctstate NEW -j ACCEPT
'
docker exec clab-fw-36-fw iptables -L FORWARD -n -v
4. Start the responders and test both directions
docker exec -d clab-fw-36-client python3 /responder.py client
docker exec -d clab-fw-36-server python3 /responder.py server
# client -> server (originates inside; goes through)
docker exec clab-fw-36-client curl -s --max-time 4 http://10.0.8.2/
# server -> client (new connection from outside; dropped)
docker exec clab-fw-36-server curl -s --max-time 4 http://10.0.9.2/ || echo "blocked"
5. Look at the conntrack entry
docker exec clab-fw-36-fw conntrack -L | grep 10.0.9.2
The forward direction (client→server) and the return direction (server→client) are both recorded in one entry.
Expected Output
iptables -L FORWARD:policy DROP, with ESTABLISHED,RELATED allowed and NEW-on-eth1 allowed.- client → server:
server(reachable). - server → client:
blocked(dropped). - conntrack:
src=10.0.9.2 dst=10.0.8.2 ... src=10.0.8.2 dst=10.0.9.2 ... [ASSURED](both directions in one entry).
Why It Works
A stateful firewall decides "by the connection, not by the individual packet."
- Stateless vs stateful. A stateless filter judges each packet in isolation, so letting return traffic through means explicitly opening a "return hole" — which tends to be broad. A stateful firewall tracks flows and automatically passes returns as "part of an existing conversation." That's how you can allow outbound traffic and its replies without opening any inbound holes.
- Conntrack. Each flow through the box is recorded as one entry covering both directions. The moment conntrack sees the forward direction (client→server), the return direction (server→client) is derived too. That's why a reply is immediately recognizable as ESTABLISHED.
- Matching on ctstate. The policy decides per packet based on its state: NEW (the first packet of a conversation — the SYN), ESTABLISHED (an existing flow), RELATED (an associated flow, such as ICMP errors or FTP data), and INVALID.
- Why the client gets through and the server doesn't.
- The client→server SYN is a NEW from the inside (eth1) → allowed. The server's reply is ESTABLISHED → allowed. The conversation succeeds.
- The server→client SYN is a NEW from the outside. It matches neither the inside-only NEW rule nor the ESTABLISHED rule, so the default DROP applies.
- The crucial point: in both cases, "server→client"-direction packets exist — but the conntrack state (a reply that's ESTABLISHED versus an unsolicited NEW) decides the outcome. State, not direction, is what matters.
- Default-deny. Set the default to DROP and open only what's needed (RFC 2979's deny-by-default). Returns for allowed connections pass via state, so no dedicated rules are required for them.
The key insight: track flows so that "continuations of an allowed conversation" pass automatically, and only unsolicited new connections hit the default-drop. It's the same conntrack as NAT (Lab 20), put to a different use.
Common Pitfalls
- Trying to open a hole for return traffic. Unnecessary in a stateful firewall — returns are auto-allowed by state. Opening one actually makes you less safe.
- Thinking direction decides what's allowed. It's the conntrack state that decides. In the same direction, ESTABLISHED passes and NEW is dropped.
- Defaulting to ACCEPT and blocking piecemeal. Backwards. Use default DROP and open only what's needed.
- Forgetting RELATED. ICMP errors (such as PMTUD's fragmentation-needed) and FTP data are RELATED. Dropping them causes subtle breakage.
- The conntrack module.
--ctstaterequires nf_conntrack (available in netshoot). - Confusing it with NAT. Same conntrack underneath, but NAT translates while the firewall decides admission.
Cleanup
sudo containerlab destroy -t fw-36.clab.yml --cleanup
If you used labctl.sh run fw-36, the script runs destroy for you at the end.
Check Your Understanding
- What's the difference between a stateless filter and a stateful firewall?
- How does conntrack record a single flow (forward and return)?
- What do the ctstate values NEW / ESTABLISHED / RELATED each mean?
- Why does client→server succeed while server→client is dropped? If not direction, what decides?
- Why does the return traffic of an allowed connection need no rule of its own?
- Why is defaulting to DROP (deny-by-default) the safer design?
References
- RFC 2979: Behavior of and Requirements for Internet Firewalls
- RFC 9293: Transmission Control Protocol
- RFC 7857: Updates to Network Address Translation (NAT) Behavioral Requirements
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
Verified Run Log (2026-07-07)
This lab has been confirmed reproducible on real hardware.
Environment:
- Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
- Docker 29.1.3
- containerlab 0.77.0
- client / fw / server:
nicolaka/netshoot:latest(iptables,conntrack,curl,python3)
Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run fw-36 performed deploy → verify → destroy, and verification.json returned "status": "verified".
The stateful policy (default-drop + state-based allows)
Chain FORWARD (policy DROP 0 packets, 0 bytes)
1 ACCEPT all -- * * ctstate RELATED,ESTABLISHED
2 ACCEPT all -- eth1 * ctstate NEW
FORWARD defaults to DROP. Only ESTABLISHED/RELATED flows and NEW connections from the inside (eth1) are allowed.
Inside-originated traffic passes; outside-originated new connections drop
client_to_server: ok
server_to_client: blocked
- client → server: the SYN is an inside-originated NEW (allowed by rule 2), and the reply is ESTABLISHED (allowed by rule 1) → reachable (fetched
server). - server → client: the server's SYN is an unsolicited NEW from the outside. It matches neither the inside-only NEW rule nor the ESTABLISHED rule → default DROP → blocked.
Conntrack tracks both directions in one entry
tcp 6 119 TIME_WAIT src=10.0.9.2 dst=10.0.8.2 sport=38018 dport=80
src=10.0.8.2 dst=10.0.9.2 sport=80 dport=38018 [ASSURED]
The allowed client→server flow is recorded as one entry holding both the forward direction (first line) and the return direction (second line). That's why a server→client reply passes automatically as ESTABLISHED, while a new server-initiated connection in the very same direction is NEW and gets dropped — the state, not the direction, decides the outcome.
Cleanup
containerlab destroy -t fw-36.clab.yml --cleanup
That's a stateful firewall: track flows with conntrack, let the continuations of allowed conversations through automatically, and let default-drop take care of everything unsolicited — no return holes required.
Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.
Next up, we'll keep building on the same toolkit — think port forwarding, zone-based policies, and what nftables changes about the picture.