Day 22 — Conntrack: stateful firewalls
Today’s mission: see how the kernel tracks per-connection state, why “established,related accept” is the most-used firewall rule in the world, and how NAT is built on top of conntrack. Total time: ~110 minutes.
What conntrack does
Along the way we’ll open up the four pieces of machinery the whole subsystem leans on:
- the conntrack tuple and how a reply is recognized,
- the packed
skb->_nfctword that ties a packet to its connection, - the expectation mechanism behind RELATED, and
- the refcount-plus-timeout model that makes entry confirmation and aging concrete.
A stateful firewall decides whether to allow a packet based not just on the packet itself, but on which connection it belongs to. “Allow inbound responses to outbound connections, drop unsolicited inbound” is the canonical use case — it requires the kernel to remember outgoing connections so it can recognize the replies.
The Linux kernel’s connection tracker is conntrack (netfilter/nf_conntrack). For every packet it inspects, conntrack:
- Computes a tuple key from the packet’s L3 + L4 headers.
- Looks up an existing tracked connection (
struct nf_conn). - If found, updates the connection state and tags the skb with an
nf_connpointer and anIP_CT_*info value. - If not found, creates a NEW entry (provisional — confirmed only on ACCEPT).
Subsequent rules can reference state via ct state established,related,new,invalid matchers — those are the ct state matchers from Day 21. NAT rules use conntrack to remember mappings so reply traffic can be reverse-mapped automatically.

Three ideas show up over and over in this chapter, and they all turn out to be the same idea wearing different hats: “looks up an existing tracked connection,” “matches the entry by the reverse 5-tuple,” and “tag the skb with the connection.” Before we walk the state machine, let’s build those ideas properly — because once you see the tuple and the packed _nfct word, the rest of conntrack is mostly bookkeeping.
The conntrack tuple: how a reply is recognized
Recall from Day 13 that the TCP stack stores established sockets in the ehash, keyed by the 4-tuple (saddr, sport, daddr, dport). Conntrack has a richer, bidirectional cousin of that key called the tuple, and understanding it is the spine of everything below.
A conntrack tuple is not the same thing as the TCP 4-tuple. The TCP key identifies one socket in one direction. A conntrack tuple identifies one direction of one flow for any protocol — and crucially, conntrack stores two of them per connection so it can recognize traffic going either way.
Here is the actual struct (include/net/netfilter/nf_conntrack_tuple.h:37):
struct nf_conntrack_tuple {
struct nf_conntrack_man src; /* source addr + an L4 "id" (port/icmp-id/key) */
struct {
union nf_inet_addr u3; /* destination address (v4 or v6) */
union {
__be16 all;
struct { __be16 port; } tcp;
struct { __be16 port; } udp;
struct { u_int8_t type, code; } icmp; /* <-- not a port! */
struct { __be16 port; } dccp;
struct { __be16 port; } sctp;
struct { __be16 key; } gre; /* <-- a GRE key */
} u;
u_int8_t protonum; /* L3 protocol number (TCP=6, UDP=17, ...) */
u_int8_t dir; /* which direction this tuple is */
} dst;
};
Read the union slowly, because it’s the whole point. The “L4 id” is not always a port. TCP/UDP/DCCP/SCTP use a 16-bit port; ICMP uses {type, code}; GRE uses a key. That is why a ping is a trackable “connection” even though ICMP has no ports: the echo’s identifier is the tuple’s L4 id, so conntrack can pair an echo-request with its echo-reply. The src half (struct nf_conntrack_man) carries the source address plus the matching source-side id in its own union nf_conntrack_man_proto.
Two tuples per connection, and the reverse is the inverse
Every nf_conn stores two tuples in an array — this is the tuplehash[2] you’ll see named in the struct:
struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX]; /* [ORIGINAL] and [REPLY] */
The direction enum is just three values (include/uapi/linux/netfilter/nf_conntrack_tuple_common.h:12):
enum ip_conntrack_dir {
IP_CT_DIR_ORIGINAL, /* 0 — the way the connection was opened */
IP_CT_DIR_REPLY, /* 1 — the way replies come back */
IP_CT_DIR_MAX /* 2 — array size */
};
The reply tuple is the inverse of the original: swap source and destination addresses, swap the source/destination L4 ids (ports), and for ICMP map the echo-request type to the echo-reply type. The kernel builds it with nf_ct_invert_tuple (net/netfilter/nf_conntrack_core.c:429, EXPORT_SYMBOL_GPL at :466). When a brand-new connection is created, init_conntrack (:1763) computes the reply tuple by inverting the original:
/* net/netfilter/nf_conntrack_core.c:1780, inside init_conntrack() */
if (!nf_ct_invert_tuple(&repl_tuple, tuple))
return NULL;
Both tuples are hashed into the same global table (nf_conntrack_hash). When a packet arrives, conntrack computes its tuple and looks it up:
- If it matches a stored ORIGINAL tuple → this packet is going the forward direction.
- If it matches a stored REPLY tuple → this packet is a reply.
That single mechanism — two tuples, one table — is what lets one entry recognize traffic in both directions. And it is exactly the same machinery NAT reuses for reverse-mapping replies (we’ll get there).
To recover the nf_conn from whichever tuple matched, the kernel uses the dir byte baked into the tuple:
/* include/net/netfilter/nf_conntrack.h */
static inline struct nf_conn *
nf_ct_tuplehash_to_ctrack(const struct nf_conntrack_tuple_hash *hash)
{
return container_of(hash, struct nf_conn,
tuplehash[hash->tuple.dst.dir]); /* dir picks the slot */
}

Direction is folded into the state value
Here’s the elegant part. The per-packet state (NEW/ESTABLISHED/…) and the direction (forward/reply) are not stored separately — the direction is folded into the state value itself. The macro that decodes it (include/uapi/linux/netfilter/nf_conntrack_tuple_common.h:44):
#define CTINFO2DIR(ctinfo) ((ctinfo) >= IP_CT_IS_REPLY ? IP_CT_DIR_REPLY : IP_CT_DIR_ORIGINAL)
Any ctinfo value ≥ IP_CT_IS_REPLY means “this is the reply direction.” That is the structural reason the state enum is ordered the way it is — IP_CT_IS_REPLY is the threshold value 3, and the reply variants are exactly forward_state + IP_CT_IS_REPLY. The state value does double duty as a direction flag, which is why it can live in just a few bits. Which brings us to where that value actually lives.
How an skb carries its conntrack: the packed skb->_nfct word
The chapter keeps saying “tag the skb with the connection.” Recall from Day 1 that the sk_buff carries a pile of metadata pointers alongside the packet bytes. Conntrack adds one more such field — but it pulls a packing trick worth understanding, because that trick is why the state enum is only 3 bits wide.
The field is a single unsigned long (include/linux/skbuff.h:933, documented at :822 as “Associated connection, if any (with nfctinfo bits)”):
unsigned long _nfct;
One machine word holds two things at once:
- the high bits are a
struct nf_conn *pointer, and - the low 3 bits hold the
ip_conntrack_infovalue (the per-packet state).
A pointer to a heap object is always aligned, so its low bits are guaranteed zero and free for reuse. The masks (include/linux/netfilter/nf_conntrack_common.h:24):
#define NFCT_INFOMASK 7UL /* low 3 bits */
#define NFCT_PTRMASK ~(NFCT_INFOMASK) /* everything else = the pointer */
Two accessors unpack the word (include/linux/skbuff.h:4987 and :5005):
static inline struct nf_conntrack *skb_nfct(const struct sk_buff *skb)
{
return (void *)(skb->_nfct & NFCT_PTRMASK); /* mask off low bits -> the pointer */
}
static inline void skb_set_nfct(struct sk_buff *skb, unsigned long nfct)
{
skb->slow_gro |= !!nfct;
skb->_nfct = nfct; /* pointer | ctinfo, packed together */
}
So a conntrack-tagged skb answers two questions with one field: which connection (the nf_conn you get from skb_nfct()), and what this packet is to that connection (the low bits — NEW / ESTABLISHED / … possibly +REPLY). The nftables ct state matcher from Day 21 reads exactly these low bits.
This packing is the reason enum ip_conntrack_info must fit in 3 bits (values 0..7), and the reason IP_CT_IS_REPLY = 3 works as a direction divider: the enum was designed to be stuffed into a pointer’s spare low bits.

Clearing conntrack off an skb is just the inverse: drop the reference and zero the word. That’s nf_reset_ct (include/linux/skbuff.h:5140):
nf_conntrack_put(skb_nfct(skb)); /* drop our reference to the nf_conn */
skb->_nfct = 0; /* pointer AND state cleared in one store */
Keep that two-line teardown in mind — it ties directly into the refcount cleanup we cover at the end of the chapter.
The states
enum ip_conntrack_info (include/uapi/linux/netfilter/nf_conntrack_common.h:7):
IP_CT_ESTABLISHED // 0: packet is part of an existing connection
IP_CT_RELATED // 1: related to an existing connection (e.g., FTP data, ICMP error)
IP_CT_NEW // 2: first packet of a new connection (the SYN, in TCP)
IP_CT_IS_REPLY // 3: flag/threshold: >= this means the reply direction
IP_CT_ESTABLISHED_REPLY // 3: ESTABLISHED in the reply direction (= ESTABLISHED + IS_REPLY)
IP_CT_RELATED_REPLY // 4: RELATED in the reply direction
// (there is no NEW in the reply direction; IP_CT_NEW_REPLY exists only as a
// userspace-compatibility alias. In the kernel value 7 is IP_CT_UNTRACKED.)
Notice how the reply variants are literally forward + IP_CT_IS_REPLY — the very arithmetic CTINFO2DIR exploits. There is no IP_CT_INVALID enumerator — malformed or out-of-state packets (e.g., a TCP segment that isn’t part of any known sequence) simply get no conntrack entry. The INVALID classification that surfaces to userspace is the bitmask NF_CT_STATE_INVALID_BIT (bit 0, in the same header), not an ip_conntrack_info value.
How NEW becomes ESTABLISHED
For TCP:
- Outbound SYN — conntrack creates entry, marks NEW. The reply tuple is computed by inverting the SYN’s tuple (
nf_ct_invert_tuple), so the entry already knows what a reply will look like. - Inbound SYN-ACK — conntrack matches the reply tuple, marks ESTABLISHED (in the reply direction, so the info value is
≥ IP_CT_IS_REPLY). - All subsequent packets in either direction — ESTABLISHED.
For UDP:
- Outbound packet — NEW.
- Inbound reply (matching the inverted reply tuple) — ESTABLISHED.
- Subsequent packets in either direction — ESTABLISHED.
For ICMP echo (ping):
- Outbound echo request — NEW. The tuple’s L4 id is the ICMP
id, and the reply tuple maps echo-request type → echo-reply type. - Echo reply matching that inverted tuple — ESTABLISHED.
This is the payoff of the tuple section: “matches the reverse 5-tuple” is not hand-waving — it’s a literal hash lookup of the packet’s tuple against the entry’s pre-computed, inverted reply tuple.
What “RELATED” means: conntrack expectations
RELATED is one of the three headline states, but its mechanism is the most-skipped. The machinery behind it is called an expectation.
Some protocols negotiate auxiliary connections in their control stream. FTP-active opens a separate data connection from server to client; SIP negotiates RTP media streams; PPTP sets up a GRE tunnel. The data/media flow has a completely different tuple than the control flow, so to a stateless view it would look like an unsolicited NEW inbound connection — and a default-drop firewall would kill it.
A conntrack helper solves this. A helper is a per-protocol module that parses the L7 control stream (e.g. the FTP PORT/PASV command), figures out the tuple of the connection it predicts will soon appear, and pre-registers an expectation — a struct nf_conntrack_expect (include/net/netfilter/nf_conntrack_expect.h:18):
struct nf_conntrack_expect {
struct hlist_node lnode, hnode;
possible_net_t net;
struct nf_conntrack_tuple tuple; /* the tuple we expect to see */
struct nf_conntrack_tuple_mask mask;
refcount_t use;
unsigned int flags;
/* ... and a link back to the master connection ... */
};
When a later packet’s tuple matches a pending expectation, conntrack creates the new entry, sets its ->master pointer to the original (control) connection, and tags the packet IP_CT_RELATED instead of IP_CT_NEW. That master link is the literal meaning of “related” (include/net/netfilter/nf_conntrack.h, in struct nf_conn at :74):
/* If we were expected by an expectation, this will be it */
struct nf_conn *master;
That is why ct state related accept is needed for FTP-active, SIP/RTP, and PPTP/GRE: the second flow has a different tuple and would otherwise look like a NEW unsolicited connection.
ICMP errors are the other RELATED source. An ICMP Destination-Unreachable or Time-Exceeded message embeds the original offending packet’s headers in its payload. Conntrack reads those embedded headers, reconstructs the original tuple, finds the connection it belongs to, and marks the error RELATED (IP_CT_RELATED is documented in the enum as “Like NEW, but related to an existing connection, or ICMP error”). That is exactly why traceroute and PMTUD survive a default-drop firewall — the ICMP errors they depend on come back tagged RELATED.
Expectations are also why helpers are now opt-in — see the Conntrack helpers section below for the security rationale.

The state machine

For every incoming packet, the conntrack machinery in nf_conntrack_in (net/netfilter/nf_conntrack_core.c:2013):
- Build a tuple from the packet’s L3 + L4 headers (
nf_ct_get_tuple). - Look up the tuple in the global conntrack hash (
__nf_conntrack_find_get). The hash is the globalnf_conntrack_hash(the netns is part of the key, not a separate per-netns table). Both the ORIGINAL and REPLY tuples of every entry live in this one table. - Three outcomes:
- Hit (forward direction) — the packet’s tuple matched a stored ORIGINAL tuple. The info value depends on the entry’s
status: onceIPS_SEEN_REPLYis set (traffic seen both ways) it’sESTABLISHED; an expected (helper-predicted) flow isRELATED; otherwise — e.g. a retransmitted SYN before any reply — it’s stillNEW. - Hit (reverse direction) — the packet’s tuple matched a stored REPLY tuple: tag with the entry, set
info = ESTABLISHED + IS_REPLY(i.e. an info value≥ IP_CT_IS_REPLY, soCTINFO2DIRreports reply). - Miss — create a new entry via
init_conntrackand__nf_conntrack_alloc. The reply tuple is computed up front by inverting the original. Markinfo = NEW. The entry is unconfirmed at this point.
- Hit (forward direction) — the packet’s tuple matched a stored ORIGINAL tuple. The info value depends on the entry’s
The unconfirmed entry is not in the global hash yet, and (in modern kernels, including the 7.1 target) it is not parked on any list either — it is held alive solely by the in-flight skb’s reference (skb->_nfct). It’s confirmed only when the packet completes the netfilter pipeline with NF_ACCEPT; the confirm callback (__nf_conntrack_confirm) inserts the entry into the global hash. If the packet is dropped, the unconfirmed entry is freed, so port-scanning probes don’t fill the table. (Older kernels kept a per-CPU “unconfirmed” list; commit 8a75a2c17410 removed it — the skb reference is all that holds an unconfirmed entry now.)
This two-phase commit is critical for performance and security:
- Performance: scans of closed ports don’t blow up the conntrack table.
- Security: an unconfirmed entry survives only as long as its skb, so a bogus-NEW flood can pin at most one entry per in-flight packet; durable (confirmed) entries are capped by
nf_conntrack_max.
We’ll make “the unconfirmed entry is freed” exact once we have the refcount model, just below.
The refcount and timeout model
The two-phase commit and the aging behavior both rest on two fields of nf_conn: an embedded refcount and a timeout. Day 1 introduced the idea of refcounting an skb-adjacent object (recall skb->users / dataref); nf_conn has its own specific counting rule. And Day 7 taught the jiffies/HZ clock and the time_after deadline pattern for neighbour aging — timeout is the same idea.
The refcount rule
nf_conn is refcounted through its embedded ct_general (a struct nf_conntrack). The rule is stated in the struct’s own comment (include/net/netfilter/nf_conntrack.h:74):
struct nf_conn {
/* Usage count in here is 1 for hash table, 1 per skb,
* plus 1 for any connection(s) we are `master' for
*/
struct nf_conntrack ct_general;
spinlock_t lock;
/* jiffies32 when this ct is considered dead */
u32 timeout;
/* ... */
struct nf_conntrack_tuple_hash tuplehash[IP_CT_DIR_MAX];
unsigned long status;
/* ... */
struct nf_conn *master; /* the expectation link from the RELATED section */
struct nf_ct_ext *ext; /* extensions: NAT info, helper data, ... */
};
So the count is: +1 for being in the hash table, +1 per in-flight skb currently referencing it, +1 for each connection it is master of. nf_ct_put drops one reference; the entry is freed when the count hits zero.
Why the two-phase commit is leak-free
Now “the unconfirmed entry is freed on DROP” is exact:
- An UNCONFIRMED entry is not in the hash, so it lacks the
+1 for hash tablereference. It is held alive only by the in-flight skb’s reference (+1 per skb). - If a rule DROPs the packet, the skb is freed; freeing it runs the
nf_reset_ct-style teardown (nf_conntrack_put(skb_nfct(skb))) we saw earlier, dropping that one reference. The count hits zero, and the entry vanishes — no pollution of the global table. - CONFIRM is the moment the entry earns its durable hash reference.
__nf_conntrack_confirm(net/netfilter/nf_conntrack_core.c:1207,EXPORT_SYMBOL_GPLat:1352, registered as the.confirmcallback at:2733) inserts the entry intonf_conntrack_hash. At that instant the entry sets itsIPS_CONFIRMEDstatus bit (IPS_CONFIRMED_BIT = 3,include/uapi/linux/netfilter/nf_conntrack_common.h) and takes the+1 for hash tablereference.

timeout is a deadline, not a duration
ct->timeout is not “seconds remaining.” It is an absolute future timestamp in jiffies — the moment the entry is considered dead. When an entry is confirmed, the per-proto timeout is added to the current time (net/netfilter/nf_conntrack_core.c:1304):
ct->timeout += nfct_time_stamp; /* relative timeout -> absolute jiffies deadline */
“Is this entry dead?” is then the same jiffy comparison Day 7 used for neighbours — compute the remaining time and check its sign (:657):
s32 timeout = READ_ONCE(ct->timeout) - nfct_time_stamp; /* <= 0 means expired */
Two mechanisms act on that deadline:
- The GC worker periodically scans hash buckets and reaps expired entries. It’s a delayed work item (
struct conntrack_gc_workat:66,gc_workerat:1517), andnf_ct_gc_expired(:719) is what tears down an entry it finds past its deadline. early_dropevicts an LRU-ish entry when the table is full, so a flood can’t wedge the box.
Every accepted packet refreshes the deadline by rewriting ct->timeout to a new future jiffies value (the per-proto timeout again). That is why an idle flow ages out only after the full proto-specific timeout elapses with no traffic — exactly the behavior the force-expire lab demonstrates below. The IPS_SEEN_REPLY_BIT = 1 status bit (set once traffic is seen both ways) and IPS_CONFIRMED_BIT = 3 are part of the same status bitfield the entry carries.
Hash table sizing
sysctl net.netfilter.nf_conntrack_max # capacity (entries)
sysctl net.netfilter.nf_conntrack_buckets # hash table size (rounded to power of 2)
Rule of thumb: buckets = max / 4 to max / 8. Defaults are conservative; busy gateways set both higher (millions of entries on big NAT boxes).
When nf_conntrack_max is reached, new connections fail to track, and depending on the policy (nf_conntrack_tcp_loose, etc.) may also be dropped. The per-CPU counters including drop and early_drop (early eviction of LRU when full — the same early_drop from the timeout section) are reported by conntrack -S. They are also exposed in /proc/net/stat/nf_conntrack, but only when the kernel is built with CONFIG_NF_CONNTRACK_PROCFS=y — that procfs view is deprecated and off on many modern kernels (including the 7.1 target), so prefer conntrack -S.
Conntrack helpers
Each L7 protocol with auxiliary connections has a helper module. As we saw in the expectation section, a helper parses the control stream and registers nf_conntrack_expect entries so the predicted data flow gets tagged RELATED instead of NEW:
nf_conntrack_ftp: parses FTP control commands (PORT, PASV) and creates expectation entries for data connections.nf_conntrack_sip: same for SIP (creates RTP stream expectations).nf_conntrack_pptp: PPTP control + GRE tunnel expectations.nf_conntrack_irc: DCC.- …and many more.
Helpers are no longer auto-loaded by default (security: ALG-style helpers were a CVE source — the spoofing risk from the expectation section). You explicitly enable them per-rule in nftables:
sudo nft add table inet ct_lab
sudo nft 'add chain inet ct_lab prerouting { type filter hook prerouting priority 0 ; }'
sudo nft 'add ct helper inet ct_lab ftp { type "ftp" protocol tcp ; }'
sudo nft 'add rule inet ct_lab prerouting tcp dport 21 ct helper set "ftp"'
# cleanup
sudo nft delete table inet ct_lab
That ct helper set "ftp" is the explicit opt-in binding: only flows matching this rule get the FTP helper, so an attacker can’t trigger expectation creation on arbitrary ports.
NAT is built on conntrack
When you write iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -j MASQUERADE, the actual mechanics reuse the two-tuple machinery directly:
- Outbound packet hits PRE_ROUTING. Conntrack creates NEW entry with original tuple
{src=10.0.0.5:1234, dst=8.8.8.8:80}. - POST_ROUTING runs. NAT rule matches; kernel picks an available source IP+port from the egress interface (e.g.,
203.0.113.1:50000). - Conntrack stores the NAT mapping by rewriting the reply tuple: instead of the plain inverse, the “expected reply” tuple becomes
{src=8.8.8.8:80, dst=203.0.113.1:50000}. TheIPS_SRC_NATstatus bit records that this entry needs source-NAT. - When the reply arrives at PRE_ROUTING, conntrack finds the entry by that reply tuple (same hash lookup as always) and automatically reverse-maps the destination back to
10.0.0.5:1234.
This is why you only write the SNAT rule once. The reply-recognition mechanism you already understand — match an incoming packet’s tuple against a stored reply tuple — is precisely what NAT bends to its purpose. The conntrack runs at PRE_ROUTING / LOCAL_OUT priority NF_IP_PRI_CONNTRACK = -200 (before the NAT and filter hooks from Day 20), which is why the entry exists by the time the NAT rule needs it.

Today’s experiment
Required setup: the userspace
conntracktool is not part of a default install. Everyconntrack ...command in this chapter (this experiment and the “Force entries to expire” section below) needs it:sudo apt-get install -y conntrack # Debian/Ubuntu # Fedora/RHEL: sudo dnf install -y conntrack-toolsThe force-expire section also uses
nc(netcat); install it withsudo apt-get install -y netcat-openbsdifwhich nccomes up empty.
# Inspect entries
sudo conntrack -L | head
# Live event monitoring
sudo conntrack -E &
# Generate flows
ping -c 3 8.8.8.8 &
curl -sI https://example.com > /dev/null
# Stats (per-CPU drop / early_drop counters)
sudo conntrack -S
# Same counters via procfs, only if CONFIG_NF_CONNTRACK_PROCFS=y (often off):
[ -r /proc/net/stat/nf_conntrack ] && head -5 /proc/net/stat/nf_conntrack \
|| echo "(no procfs view; needs CONFIG_NF_CONNTRACK_PROCFS=y — use 'conntrack -S')"
# Per-state count — only TCP has a state column ($4 is the state for tcp rows;
# for udp/icmp $4 is 'src=...', which would pollute the histogram), so filter -p tcp:
sudo conntrack -L -p tcp 2>/dev/null | awk '{print $4}' | sort | uniq -c | sort -rn
sudo killall conntrack # stop the -E monitor
In the conntrack -L output, each entry prints both tuples back to back — you are literally looking at tuplehash[ORIGINAL] followed by tuplehash[REPLY]. On a NAT box the reply half shows the rewritten address; on a plain host it’s the clean inverse.
On a busy host the per-state histogram is dominated by ESTABLISHED, with a handful of SYN_SENT/TIME_WAIT/CLOSE_WAIT:
42 ESTABLISHED
3 TIME_WAIT
1 SYN_SENT
(udp and icmp flows have no state column — only a timeout — which is exactly why we filter to -p tcp.)
Watch the table fill and entries age out. Default UDP timeout is 30s; TCP ESTABLISHED is 5 days (yes, days — long-lived connections shouldn’t get garbage-collected). Remember those numbers are the relative timeout that gets added to nfct_time_stamp to form the absolute ct->timeout deadline.
Trace conntrack in BPF
sudo bpftrace -e '
fentry:nf_conntrack_in {
printf("ct_in skb=%p hook=%d pf=%d\n",
args->skb, args->state->hook, args->state->pf);
}
fentry:__nf_conntrack_confirm {
printf("confirm skb=%p\n", args->skb);
}'
You’ll see one nf_conntrack_in per packet (ct lookup happens early at PRE_ROUTING and LOCAL_OUT) and one __nf_conntrack_confirm per accepted packet (at the end of the pipeline). The gap between those two counts is the two-phase commit in action: every packet gets a lookup, but only survivors get confirmed into the hash.
nf_conntrackis a module, sobpftrace -l "fentry:nf_conntrack_in"lists nothing unqualified — use the module formfentry:nf_conntrack:nf_conntrack_in/fentry:nf_conntrack:__nf_conntrack_confirmto list them, or thekprobe:nf_conntrack_in/kprobe:__nf_conntrack_confirmequivalents. The-erun-form above attaches fine either way because bpftrace resolves the module at attach time.
Force entries to expire
# Lower TCP ESTABLISHED timeout temporarily (default 432000s / 5 days)
old_timeout=$(cat /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established)
trap 'sudo sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=$old_timeout; kill $srv $cli 2>/dev/null || true' EXIT
sudo sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=60
# Open a connection, leave idle
nc -l 9999 & srv=$!
nc localhost 9999 & cli=$!
sudo conntrack -L | grep 9999 # see the entry
# The entry only ages out after the (lowered) idle timeout, so actually wait:
echo "waiting ~65s for the idle entry to age out..."; sleep 65
sudo conntrack -L | grep 9999 # now gone (no output)
What you are watching: with no traffic on the idle connection, nothing refreshes ct->timeout, so the GC worker’s READ_ONCE(ct->timeout) - nfct_time_stamp eventually goes negative and nf_ct_gc_expired reaps the entry. Always restore the timeout. It is global state, and lowering it can break legitimate long-lived idle connections on the host.
What to read in the kernel
-
net/netfilter/nf_conntrack_core.c:2013—nf_conntrack_in. The main entry point, registered as a Netfilter hook at PRE_ROUTING and LOCAL_OUT priorityNF_IP_PRI_CONNTRACK = -200. Read top to bottom (~80 lines for the function plus its helpers). Trace: tuple → lookup → either tag-existing or alloc-new → usually return ACCEPT. Conntrack can still drop invalid or stressed packets; filter rules are just the common place where policy drops happen. -
include/net/netfilter/nf_conntrack.h:74—struct nf_conn. The per-connection record. Read the refcount-rule comment at the very top (“1 for hash table, 1 per skb, plus 1 for any connection we are master for”). Only ~10-14 direct fields (a handful gated on config options like zones, NAT, and marks). Important:ct_general(the embedded refcount),timeout(absolute jiffies deadline),tuplehash[2](forward and reverse tuples),status(bitfield withIPS_CONFIRMED,IPS_SEEN_REPLY,IPS_SRC_NAT/IPS_DST_NAT),master(the expectation link), andext(a pointer to dynamically-allocated extensions for NAT info, helper data, etc.). -
include/net/netfilter/nf_conntrack_tuple.h:37—struct nf_conntrack_tuple. The tuple key. Note the union over L3 (IPv4 vs IPv6 addresses) and L4 (port pairs vs ICMP{type,code}vs GRE key). See alsonf_ct_invert_tuple(net/netfilter/nf_conntrack_core.c:429) for how the reply tuple is built by inversion. -
include/uapi/linux/netfilter/nf_conntrack_tuple_common.h:12/:44—enum ip_conntrack_dirand theCTINFO2DIRmacro. The two-line read that explains why direction is folded into the state value. -
include/uapi/linux/netfilter/nf_conntrack_common.h:7—enum ip_conntrack_info. The states. Quick read; note the reply variants areforward + IP_CT_IS_REPLY, and theIPS_*status bits below the enum. -
include/linux/skbuff.h:933—unsigned long _nfct, plusskb_nfct()(:4987) andskb_set_nfct()(:5005). The pointer+state packing trick;NFCT_PTRMASK/NFCT_INFOMASKlive atinclude/linux/netfilter/nf_conntrack_common.h:24. -
include/net/netfilter/nf_conntrack_expect.h:18—struct nf_conntrack_expect. The expectation record behind RELATED. -
net/netfilter/nf_conntrack_proto_tcp.c— TCP-specific state tracking. Implements the proper TCP state machine: SYN, SYN-ACK, ACK, FIN, etc., with sequence-number windowing checks. Read the comments at the top — it’s a paper. -
net/netfilter/nf_conntrack_proto_udp.c— UDP-specific (much simpler — just timestamps). -
net/netfilter/nf_conntrack_helper.c— helper registration. The plumbing for FTP, SIP, etc. -
net/netfilter/nf_nat_core.c— NAT built on conntrack. Reads conntrack entries, applies translations. -
Documentation/networking/nf_conntrack-sysctl.rst— every conntrack sysctl explained. -
External:
man conntrackandconntrack-toolspackage — userspace inspection and manipulation.
There are no Dumb Questions
Q: If a ping has no ports, how is it a “connection”?
A: The conntrack tuple’s L4 id is a union, not always a port. For ICMP the id is
{type, code}plus the echo identifier carried insrc. The reply tuple maps echo-request type → echo-reply type, so the echo and its reply hash to a matched pair just like a TCP SYN/SYN-ACK do. That’s whyconntrack -Llistsicmpflows.Q: How does one entry recognize traffic in both directions if the hash key is one tuple?
A: Every
nf_connregisters two tuples into the same global hash —tuplehash[ORIGINAL]andtuplehash[REPLY], where the reply isnf_ct_invert_tuple()of the original. An incoming packet’s tuple is looked up once; whichever stored tuple it matches tells you both the connection (nf_ct_tuplehash_to_ctrack) and the direction (thedst.dirbyte).Q: Where does the state (NEW/ESTABLISHED) actually live — in the entry or on the packet?
A: The entry holds long-lived state in its
statusbitfield. The per-packet classification lives in the low 3 bits ofskb->_nfct, packed alongside thenf_connpointer.skb_nfct()masks off those bits to get the pointer; the bits themselves are theip_conntrack_infovalue thect statematcher reads.
Bullet Points
- Conntrack tracks per-connection state in a global hash. The key is a tuple (
struct nf_conntrack_tuple): src+dst addresses, an L4 id that is a port for TCP/UDP but{type,code}for ICMP and a key for GRE, and a protocol number. - Every
nf_connstores two tuples —tuplehash[ORIGINAL]andtuplehash[REPLY], the reply being the inverse (nf_ct_invert_tuple). Both go in the same table; matching the reply tuple is how a response is recognized (and how NAT reverse-maps). - An skb carries its connection in one packed word,
skb->_nfct: high bits =nf_conn *, low 3 bits =ip_conntrack_info. That packing is why the state enum fits in 3 bits and whyIP_CT_IS_REPLY = 3doubles as a direction divider (CTINFO2DIR). - States: NEW, ESTABLISHED, RELATED, plus reply-direction variants (
= forward + IP_CT_IS_REPLY) — and INVALID (a matcher classification viaNF_CT_STATE_INVALID_BIT, not anip_conntrack_infoenum value). - RELATED is implemented by expectations (
struct nf_conntrack_expect): a helper parses the control stream, pre-registers the predicted tuple, and the matching flow is born with->masterset. ICMP errors are RELATED too (they embed the offending packet). Helpers are opt-in via nftct helper. - Hooked at PRE_ROUTING (priority -200) and LOCAL_OUT to capture both received and locally-generated traffic.
- Two-phase commit: an entry is created unconfirmed on the first packet, held alive only by the skb’s reference (refcount rule: 1 for hash + 1 per skb + 1 per master). On
NF_ACCEPT,__nf_conntrack_confirminserts it into the hash and takes the durable reference; on DROP the skb frees, refcount hits zero, the entry vanishes. Bogus packets don’t fill the table. ct->timeoutis an absolute jiffies deadline, not a duration. Each accepted packet refreshes it; the GC worker reaps entries whosetimeout - nowgoes negative;early_dropevicts when full.nf_conntrack_maxcaps total entries;nf_conntrack_bucketssizes the hash.- NAT is built on conntrack — it rewrites the entry’s reply tuple, so reply reverse-mapping happens automatically via the normal tuple lookup.
- Inspect:
conntrack -L,conntrack -E(events),conntrack -S(stats).
Check question
A SYN packet arrives. Conntrack creates a NEW entry. Then a netfilter rule decides DROP. What happens to the conntrack entry?
Click to reveal answer
Answer: The unconfirmed entry is freed, not added to the global table. Conntrack entries created in PRE_ROUTING (or LOCAL_OUT) are unconfirmed — they are not in the hash while the rest of the netfilter pipeline runs, so they lack the “+1 for hash table” reference from the nf_conn refcount rule. The entry is held alive by exactly one reference: the in-flight skb’s. When a rule DROPs the packet, the skb is freed, which drops that reference (nf_conntrack_put(skb_nfct(skb)) — the same teardown nf_reset_ct does); the refcount hits zero and the entry is freed. Only when a packet is ACCEPTED at the end does __nf_conntrack_confirm insert the entry into the global hash and take the durable reference (setting IPS_CONFIRMED). This is what prevents a port-scanning attacker from filling your conntrack table with NEW entries that never see a reply: scans of closed ports don’t leave permanent state. Bulk teardown of unconfirmed entries is nf_ct_iterate_destroy.
Tomorrow
Day 23: traffic control. The qdisc subsystem that controls packet pacing on egress.