Netfilter, conntrack, NAT, and nftables

Trace netfilter hooks, connection tracking, filtering and address translation.

16 min readChapter 54 of 168Intermediate → advanced
In this chapter

Before you start#

Read netdev and NAPI, routing and neighbours, and TCP. Linux v6.12 is the historical source baseline. Hook placement, priority, protocol family, namespace, module configuration and offload affect actual behavior. The lab only reads existing state; it does not change firewall rules or generate traffic.

Learning goals#

Trace a conditional packet through netfilter hook dispatch, distinguish connection tracking from NAT and filtering, explain nftables expressions and transactional ruleset updates, and identify verdict ownership and failure paths.

Source walkthrough#

Netfilter, conntrack, NAT, and nftables
  1. Prerouting and conntrack

    Optional DNAT can establish a first-packet binding.

  2. Route decision

    Translated destination can select local input or forwarding.

  3. Forward policy

    Registered hooks evaluate in priority order.

  4. Postrouting

    Optional SNAT applies; later packets can use existing state.

Conceptual model. Conditional Linux v6.12 source relationships; illustrative model, not a captured execution.

Netfilter provides hook points where registered functions can inspect or act on packets. A hook is identified by protocol family and point, with callbacks ordered by priority. IPv4 and IPv6 paths have corresponding hooks but separate protocol code and configuration; bridge-family and ingress hooks are separate contexts. A packet does not traverse every hook. An inbound packet for the local host can traverse prerouting and input; a forwarded packet can traverse prerouting, forwarding and postrouting; a locally generated packet can traverse output and postrouting. These are simplified conditional paths: early drops, routing decisions, encapsulation, bridging, offloads and configuration can alter which functions see a packet. See net/ipv4/ip_input.c, net/ipv4/ip_output.c, and the corresponding IPv6 code.

nf_hook_slow, Linux v6.12, iterates registered nf_hook_entries while the caller holds the required RCU read lock. The file header states that the code is GPL; it does not carry an SPDX identifier in the inspected header. Its verdict switch includes:

c
		case NF_ACCEPT:
			break;
		case NF_DROP:

NF_ACCEPT breaks out of the switch and continues the loop to the next registered hook, not necessarily straight to the application. The NF_DROP branch frees the skb with a netfilter drop reason, obtains an error from the verdict and returns an error. NF_QUEUE hands the packet to queue machinery and may continue only for the specified return; NF_STOLEN indicates that ownership has been taken by another path. These cases have different ownership: freeing an skb again after a drop or stolen transfer can be a double free. RCU protects the hook-entry view during dispatch; it is not an exclusive lock on all packet or connection state.

Callers usually reach nf_hook_slow through the NF_HOOK wrapper. In include/linux/netfilter.h, Linux v6.12, SPDX GPL-2.0 (lines omitted):

c
	struct nf_hook_entries *hook_head = NULL;
	int ret = 1;
	/* ... */
	if (hook_head) {
		struct nf_hook_state state;

		nf_hook_state_init(&state, hook, pf, indev, outdev,
				   sk, net, okfn);

		ret = nf_hook_slow(skb, &state, hook_head, 0);
	}
	rcu_read_unlock();

	return ret;

The wrapper starts with ret = 1 and calls nf_hook_slow only when the lookup for that family and hook point returns a non-empty list; a return of 1 means no hook consumed the packet, so the caller continues with its okfn. The omitted lines include the protocol-family switch and a static-key shortcut that can skip the lookup. The excerpt does not show which entries a namespace has registered, so it establishes the dispatch entry point, not the path a particular packet will take.

Hook dispatch, simplified
Hook dispatch, simplified1. NF_HOOK caller to nf_hook: NF_HOOK(pf, hook, skb, okfn). 2. nf_hook to nf_hook_slow: hook list non-empty: nf_hook_slow(skb, state, entries, 0). 3. nf_hook_slow to Hook callback: call entry s with the skb. 4. Hook callback to nf_hook_slow (return): NF_ACCEPT: continue at entry s+1. 5. Hook callback to nf_hook_slow (return): NF_DROP: nf_hook_slow frees the skb and returns an error. 6. nf_hook_slow to nf_hook (return): all entries accept: return 1. 7. nf_hook to NF_HOOK caller (return): NF_HOOK runs okfn when the result is 1.NF_HOOK callernf_hooknf_hook_slowHook callbackNF_HOOK(pf, hook,skb, okfn)hook list non-empty:nf_hook_slow(skb,state, entries, 0)call entry s withthe skbNF_ACCEPT: continueat entry s+1NF_DROP:nf_hook_slow freesthe skb and returnsan errorall entries accept:return 1NF_HOOK runs okfnwhen the result is 1
  1. NF_HOOK callernf_hook

    NF_HOOK(pf, hook, skb, okfn)

  2. nf_hooknf_hook_slow

    hook list non-empty: nf_hook_slow(skb, state, entries, 0)

  3. nf_hook_slowHook callback

    call entry s with the skb

  4. Hook callbacknf_hook_slow

    Return. NF_ACCEPT: continue at entry s+1

  5. Hook callbacknf_hook_slow

    Return. NF_DROP: nf_hook_slow frees the skb and returns an error

  6. nf_hook_slownf_hook

    Return. all entries accept: return 1

  7. nf_hookNF_HOOK caller

    Return. NF_HOOK runs okfn when the result is 1

Time runs downward. Dashed arrows show returns.

Read the text version
  1. NF_HOOK caller → nf_hook: NF_HOOK(pf, hook, skb, okfn)
  2. nf_hook → nf_hook_slow: hook list non-empty: nf_hook_slow(skb, state, entries, 0)
  3. nf_hook_slow → Hook callback: call entry s with the skb
  4. Hook callback → nf_hook_slow (return): NF_ACCEPT: continue at entry s+1
  5. Hook callback → nf_hook_slow (return): NF_DROP: nf_hook_slow frees the skb and returns an error
  6. nf_hook_slow → nf_hook (return): all entries accept: return 1
  7. nf_hook → NF_HOOK caller (return): NF_HOOK runs okfn when the result is 1
Source map. Source map of Linux v6.12 NF_HOOK and nf_hook_slow; NF_QUEUE, NF_STOLEN and the static-key shortcut are omitted. Not a captured trace.

A hook's priority matters because defragmentation, conntrack, NAT and policy can register at different priorities. A rule seeing an address before destination NAT can see different fields from a rule later in the path. Which hook applies also depends on whether the packet is locally generated, locally delivered or forwarded. An nftables chain with a hook and priority is attached to a particular family and point; an ordinary regular chain is reached by jumps or calls rather than being automatically invoked for all packets.

Conntrack and NAT state#

Connection tracking represents a flow using tuples: addresses, ports or protocol-specific identifiers, protocol, direction and the relevant network namespace and zone. struct nf_conn has reference-counted state, timeout and protocol-related information; an skb can carry a conntrack reference and direction information. A conntrack entry is not a TCP socket and does not prove a handshake or remote application identity. State labels such as new, established or related reflect conntrack's interpretation and direction; they are not interchangeable with every transport's internal state. See net/netfilter/nf_conntrack_core.c.

nf_conntrack_in examines packet and hook context, performs relevant tracking work and attaches or uses state as appropriate. Fragment reassembly must occur at the appropriate point before a transport tuple can be interpreted when needed; it is not safe to assume every isolated fragment contains ports. Conntrack zones allow overlapping tuples to be tracked separately where configured. Entries have timeouts and references; removing a ruleset entry does not imply that every existing connection tracking object instantly disappears. A packet can be untracked, invalid, dropped, or unable to allocate state; rules must define what those cases mean.

NAT is a transformation of addresses and/or ports associated with tracked flows. The first packet reaching an applicable NAT chain can establish a binding for the connection; subsequent packets use conntrack/NAT state for consistent forward and reverse transformations rather than selecting a new independent NAT mapping by re-evaluating the same first-packet rule for every packet. nf_nat_setup_info sets up NAT information under its direction and state rules. NAT is not authentication and is not automatically a firewall: filtering policy is a separate decision. NAT port allocation, tuple collisions, zone selection, protocol support and errors matter.

Worked forwarded-flow example#

Consider a synthetic IPv4 router with forwarding enabled, configured hooks and no earlier drop. A client 10.0.0.2:40000 sends to public destination 203.0.113.10:80. A prerouting destination-NAT rule creates a binding that changes the destination to backend 10.1.0.8:8080. Route lookup then uses the translated destination to select the backend-facing interface. A forward-chain policy decides whether the packet is permitted. In postrouting, a source-NAT rule can map the source to 10.1.0.1:50000; the backend sees that source and the translated destination. Conntrack associates the original and reply directions so return traffic can be translated consistently.

This diagram assumes a suitable conntrack zone, NAT rules at appropriate hooks, a valid route, enabled forwarding, available NAT mapping and no earlier policy rejection. The precise tuple values and rule priorities are teaching values, not observed traffic. If prerouting drops the first packet, route lookup and later hooks need not occur. If a NAT binding cannot be established, the packet may fail instead of being transmitted with an imagined mapping. A local packet to the router follows the input decision rather than the forwarding chain; a locally generated packet starts in output rather than prerouting. A bridge can use separate bridge-family handling, and optional bridge-netfilter integration changes which IP hooks are visited. Never claim all packets visit all hooks.

nftables evaluation and transactions#

nftables configuration is carried over netlink as structured objects and expressions. Tables contain chains, sets, maps and related objects. A base chain attaches to a hook, family and priority; regular chains provide reusable control flow. Expressions read packet or metadata fields, compare values, perform lookups or write a verdict. Registers hold intermediate values and verdict state during evaluation. A set or map can supply efficient membership or value lookup, but a set's contents and lifetime are still controlled by the ruleset transaction and object references. See net/netfilter/nf_tables_api.c and net/netfilter/nf_tables_core.c.

In nft_do_chain, Linux v6.12, GPL-2.0-only, the evaluator initializes a rule verdict and iterates expressions:

c
	regs.verdict.code = NFT_CONTINUE;
	for (; !rule->is_last ; rule = nft_rule_next(rule)) {

NFT_CONTINUE is the starting verdict state for evaluating rules; the loop advances through the rule blob until its terminal marker or a verdict changes control flow. The following code calls optimized expression evaluators where applicable and otherwise calls the expression's operation, checking the verdict after each expression. A failed match can break the current rule and continue to another; accept, drop, queue, jump, return and other results have specific semantics. A value loaded into a register is not itself permission to access arbitrary packet memory: expression implementations validate their access and context.

The evaluator also chooses its rule blob from the namespace's generation cursor before the loop starts. In net/netfilter/nf_tables_core.c, Linux v6.12, GPL-2.0-only (surrounding code omitted):

c
	bool genbit = READ_ONCE(net->nft.gencursor);
	struct nft_rule_blob *blob;
	struct nft_traceinfo info;

	info.trace = false;
	if (static_branch_unlikely(&nft_trace_enabled))
		nft_trace_init(&info, pkt, basechain);
do_chain:
	if (genbit)
		blob = rcu_dereference(chain->blob_gen_1);
	else
		blob = rcu_dereference(chain->blob_gen_0);

	rule = (struct nft_rule_dp *)blob->data;

genbit is read once per call. A jump to another chain returns to the do_chain label, which selects that chain's blob with the same genbit, so one packet evaluation stays within the generation it began with. The excerpt does not show how a transaction fills the inactive blob, when old blobs are freed, or how the loop handles each verdict.

Ruleset updates are submitted in netlink batches and validated before commit. The implementation constructs transaction state, checks objects and references, and commits a new generation so readers can evaluate a coherent ruleset view under the relevant RCU/generation scheme. Invalid attributes, unsupported expressions, missing objects, authorization failures or allocation errors can abort the batch; clients must check netlink acknowledgements rather than assuming the desired rules became active. Atomicity applies to the nftables ruleset transaction in its defined scope, not to simultaneous changes made by unrelated subsystems, packets already in flight, conntrack state or every userspace operation on the machine. The generation cursor in nft_do_chain selects the appropriate rule blob for the read-side view.

A flowtable or hardware/software offload can allow later packets in an eligible flow to bypass portions of the ordinary forwarding path. Which packets and hooks are bypassed depends on the configured flowtable, device, driver and protocol. A flowtable hit is transmitted toward the output device and bypasses later classic IP forwarding hooks in the documented path; ingress placement and priority matter. A counter on a normal chain therefore need not count every packet that a connection ultimately carries. Do not infer that all traffic bypasses policy after one packet, or that an offloaded flow has the same observability as the slow path. See net/netfilter/nf_flow_table_core.c and kernel flowtable documentation.

Errors, references, and inspection#

If a hook returns NF_DROP, the shown core frees the skb and returns a negative error; a later layer must not treat it as an accepted packet. If a queue target sends a packet to userspace, queue availability, verdict timing and configured failure policy affect what happens. A queued skb and its associated state need to live until the verdict or cleanup. A ruleset update can remove a chain while packet processing is concurrent; RCU and the nftables generation/transaction mechanisms coordinate reader and object lifetime. A packet already evaluated under an old generation is not retroactively re-evaluated just because a new transaction commits.

Conntrack and NAT are similarly stateful across packets. If a new NAT rule is installed, an existing tracked flow can continue using its existing binding, so testing a change may require a genuinely new flow under an appropriately isolated setup. Removing all host conntrack entries would affect unrelated traffic and is outside this exercise. An apparent counter mismatch can reflect hook placement, rule order, offload, packets dropped earlier, different namespaces or concurrent traffic. Use counters as scoped evidence, not as proof of a universal packet path.

Following a tuple through both directions#

Return to the synthetic client 10.0.0.2:40000, public destination 203.0.113.10:80, backend 10.1.0.8:8080, and source-NAT address 10.1.0.1:50000. The original direction is identified by the original tuple before translation. After both configured transformations, the backend sees 10.1.0.1:50000 -> 10.1.0.8:8080. A reply from the backend is recognized through the reply-direction tuple and transformed back so the client sees a reply associated with 203.0.113.10:80. The example deliberately assumes that the return path passes through the same applicable conntrack/NAT state. Asymmetric routing, another namespace or zone, exhausted port space, or an earlier drop can invalidate that assumption.

The distinction between selecting a NAT binding and applying it matters to debugging. In nf_nat_setup_info, the implementation derives a current tuple from conntrack reply information, obtains a unique tuple under the requested range and manipulation type, and, if it changes, alters the reply tuple and sets the relevant source- or destination-NAT status. An allocation or extension failure can return a drop verdict. This is not an instruction to manually edit a conntrack tuple while packets are using it: the function's ownership, locking and initialization conditions are part of the contract. A filter chain's accept verdict is a separate decision from the existence of this binding.

The start of nf_nat_setup_info shows the state checks that run before any tuple is chosen. In net/netfilter/nf_nat_core.c, Linux v6.12, GPL-2.0-only (surrounding code omitted):

c
	/* Can't setup nat info for confirmed ct. */
	if (nf_ct_is_confirmed(ct))
		return NF_ACCEPT;

	WARN_ON(maniptype != NF_NAT_MANIP_SRC &&
		maniptype != NF_NAT_MANIP_DST);

	if (WARN_ON(nf_nat_initialized(ct, maniptype)))
		return NF_DROP;

A confirmed conntrack entry is not given new NAT setup: the function returns NF_ACCEPT without changing the binding. A second setup for a manipulation type that is already initialized is a warned error that returns NF_DROP. The excerpt does not include get_unique_tuple, the reply-tuple update or the source-hash insertion, which decide the actual mapping.

Tuples before and after translation
Tuples before and after translationOriginal tuple: 10.0.0.2:40000 to 203.0.113.10:80, before translation → Backend view: 10.1.0.1:50000 to 10.1.0.8:8080 after DNAT and SNAT (DNAT then SNAT rewrite the forward packet). Backend reply: 10.1.0.8:8080 to 10.1.0.1:50000, as the backend answers → Client view: Reply appears from 203.0.113.10:80 to 10.0.0.2:40000 (Reply-direction state reverses both rewrites)DNAT then SNAT rewritethe forward packetReply-direction statereverses both rewritesOriginal tuple10.0.0.2:40000 to203.0.113.10:80, beforetranslationBackend reply10.1.0.8:8080 to10.1.0.1:50000, as thebackend answersBackend view10.1.0.1:50000 to10.1.0.8:8080 after DNAT andSNATClient viewReply appears from203.0.113.10:80 to10.0.0.2:40000
Original tuple10.0.0.2:40000 to 203.0.113.10:80, before translation
Backend view10.1.0.1:50000 to 10.1.0.8:8080 after DNAT and SNAT
Backend reply10.1.0.8:8080 to 10.1.0.1:50000, as the backend answers
Client viewReply appears from 203.0.113.10:80 to 10.0.0.2:40000
Read the text version
  • Original tuple: 10.0.0.2:40000 to 203.0.113.10:80, before translation → Backend view: 10.1.0.1:50000 to 10.1.0.8:8080 after DNAT and SNAT (DNAT then SNAT rewrite the forward packet)
  • Backend reply: 10.1.0.8:8080 to 10.1.0.1:50000, as the backend answers → Client view: Reply appears from 203.0.113.10:80 to 10.0.0.2:40000 (Reply-direction state reverses both rewrites)
Conceptual model. Conceptual model with synthetic addresses, following Linux v6.12 conntrack direction handling. Not observed traffic.

Ruleset updates and packet observations#

An nftables batch can create related sets, maps, chains and rules together, but each reference must be valid under the transaction's validation rules. The kernel must also handle failed allocations and roll back pending changes. A client should wait for and inspect its acknowledgements; a syntactically valid netlink send is not proof that the complete ruleset was committed. Readers use generation-specific rule blobs under RCU. This permits a packet evaluator to use an appropriate consistent view while an update proceeds, but it does not rewind a packet that already passed a hook or erase an existing conntrack binding.

For a practical diagnostic, first identify the packet's namespace, family, direction, hook and priority. Then ask whether an earlier rule or hook can drop or redirect it, whether conntrack was enabled and found an existing entry, whether NAT has already changed the address at that observation point, and whether an eligible flowtable or hardware path can avoid a later software counter. A counter increasing proves only that packets matched that counter's placement and semantics. A counter staying at zero can mean no match, a different hook, an earlier drop, offload, or a different namespace. Reading an unavailable ruleset without privilege should remain an explicitly limited observation, not a reason to change host policy.

Hooks, priorities, and a concrete counter#

A chain's priority orders its callback relative to other callbacks at the same hook; it does not make a prerouting chain run after a postrouting chain. In the example, a counter before destination NAT may match 203.0.113.10:80, while a later rule can see 10.1.0.8:8080 after translation. The exact priorities and chain families must be specified before asserting which value a particular expression reads. A rule that accepts a packet at one hook does not guarantee that a later hook, route check or device will also accept it. Conversely, a packet dropped at an earlier hook never reaches a later counter on that path.

For an existing tracked connection, a rule update and a connection-state update have distinct lifetimes. A new nftables generation can change the rule evaluated by a later packet, but a NAT binding already associated with a connection can remain in effect until that state is removed or expires under its rules. This is why testing a policy change with a reused flow can produce a different result from creating a new isolated flow. An administrator must not flush host conntrack or firewall state merely to make this lesson easier: the read-only lab reports only the namespace and permissions it can actually inspect.

The nftables evaluator's register file is local to its packet evaluation; sets and maps are referenced ruleset objects with their own control-plane lifetime. An expression can load a transport field, compare it against a constant or use it as a set key, then change a verdict or continue. Bounds and protocol checks matter because a fragment or packet of another protocol may not contain the expected transport header. Configuration validation and packet-time failure paths both matter: a successful transaction does not mean every future packet satisfies a rule's assumptions.

Practice#

Source-only route:

sh
rg -n -A35 'int nf_hook_slow\(' "$KERNEL_SRC/net/netfilter/core.c"
rg -n -A55 'nft_do_chain\(' "$KERNEL_SRC/net/netfilter/nf_tables_core.c"
rg -n -A35 'nf_conntrack_in\(' "$KERNEL_SRC/net/netfilter/nf_conntrack_core.c"
rg -n -A35 'nf_nat_setup_info\(' "$KERNEL_SRC/net/netfilter/nf_nat_core.c"

Confirm the checkout tag is v6.12. Draw separate ownership branches for accept, drop, queue and stolen; then annotate the synthetic DNAT/route/forward/SNAT path with its assumptions. Locate the evaluator's generation choice and expression loop rather than inferring a runtime rule from source alone.

Read-only observation:

sh
if command -v nft >/dev/null 2>&1; then
  nft list ruleset || echo 'ruleset unavailable or permission denied'
else
  echo 'nft unavailable; use the source-only route'
fi

A ruleset can be empty, hidden by namespace, or unavailable to an unprivileged user. A permission error is an expected observation; do not retry with sudo merely for the lesson. If output is available, identify family, base-chain hook and priority without changing any rule. Do not flush, add, delete or disable host firewall or conntrack state. Deliver the synthetic tuple and hook diagram plus the read-only result or limitation.

Knowledge check#

  1. What happens after NF_ACCEPT in the shown loop, and what happens to an skb on NF_DROP?
  2. Why does a forwarded packet have a different hook path from a locally delivered packet?
  3. Why do later packets in a NATed flow not necessarily create a fresh mapping on each packet?
  4. What does the nftables generation/transaction mechanism protect, and what does it not make atomic?
  5. Why might a flowtable make a slow-path chain counter incomplete?

Answers#

  1. Accept continues to the next registered hook in that loop; drop frees the skb and returns an error in the shown implementation.
  2. Routing selects local delivery or forwarding, so the applicable input versus forward path differs; later hooks depend on the actual path and policy.
  3. Conntrack stores a binding for the flow and applies the corresponding forward/reverse translations; later packets use that state under the relevant path.
  4. It coordinates a coherent ruleset generation for readers in the transaction's scope; it does not atomically update unrelated subsystems or retroactively change packets already evaluated.
  5. Eligible offloaded packets can bypass portions of the ordinary path on which that counter resides.

Checkpoint#

Save the tuple transformation, hook conditions and inspection result. Pass when you can identify skb ownership for verdicts, separate conntrack/NAT/filter state and explain transaction scope. If nft is absent or denied, use the source-only route and record that limitation. This is practice, not certification.

Sources#

A little further beneath the surface.

Check your understanding with the checkpoint. Marking a chapter as read saves a bookmark, not a proficiency assessment.