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 bundles iptables, conntrack, curl, and python3.

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. --ctstate requires 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

  1. What's the difference between a stateless filter and a stateful firewall?
  2. How does conntrack record a single flow (forward and return)?
  3. What do the ctstate values NEW / ESTABLISHED / RELATED each mean?
  4. Why does client→server succeed while server→client is dropped? If not direction, what decides?
  5. Why does the return traffic of an allowed connection need no rule of its own?
  6. Why is defaulting to DROP (deny-by-default) the safer design?

References

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.

Read more