Network Firewall and Netfilter

A firewall rule is not a filter the kernel consults. It is an entry in a list that a hook function walks in order, taking the first match — and which list gets walked was decided by the routing lookup before any rule was read. This module is about that ordering: why INPUT and FORWARD are mutually exclusive outcomes rather than two places to put a rule, why a table is really just a priority number, and why INVALID is not a connection state at all.

Launch Simulator →

What you'll explore

  1. 01

    Five Hooks, and the Route Lookup Picks Which One

    The five hooks are an enum, and reading it settles a question people usually answer with a diagram: NF_INET_PRE_ROUTING, NF_INET_LOCAL_IN, NF_INET_FORWARD, NF_INET_LOCAL_OUT, NF_INET_POST_ROUTING, terminated by NF_INET_NUMHOOKS (include/uapi/linux/netfilter.h:43-48). What the enum does not tell you is that a packet does not choose between LOCAL_IN and FORWARD, and neither does your ruleset. The routing lookup chooses, and it has already happened. ip_rcv_finish_core() calls ip_route_input_noref() (net/ipv4/ip_input.c:317, :365), and the result installs a dst whose input function is then invoked by dst_input() (:449). If that function is ip_local_deliver(), the packet hits NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, ...) (net/ipv4/ip_input.c:254). If it is ip_forward(), the packet hits NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, ...) (net/ipv4/ip_forward.c:162). One call site each, mutually exclusive, both downstream of a decision no firewall rule participated in. This is why a rule in the wrong chain is not a weak rule but an unreachable one. What runs AT a hook is an array, walked in priority order. nf_hook_slow() iterates e->num_hook_entries calling nf_hook_entry_hookfn() for each, and switches on the verdict: NF_ACCEPT continues to the next registered function, NF_DROP frees the skb and returns an error, NF_QUEUE hands it to userspace, NF_STOLEN stops without freeing (net/netfilter/core.c:619-651). Only after every registered function has accepted does it return 1. So an ACCEPT from your filter rules is not a final verdict on the packet — it is permission to proceed to the next thing registered at that same hook.

  2. 02

    A Table Is a Priority; a Policy Is a Rule You Cannot See

    An iptables table is not a container the kernel searches. It is a hook registration with a priority, and the priorities are a compile-time enum: FIRST INT_MIN, RAW_BEFORE_DEFRAG -450, CONNTRACK_DEFRAG -400, RAW -300, SELINUX_FIRST -225, CONNTRACK -200, MANGLE -150, NAT_DST -100, FILTER 0, SECURITY 50, NAT_SRC 100, SELINUX_LAST 225, CONNTRACK_HELPER 300, CONNTRACK_CONFIRM INT_MAX and LAST INT_MAX (include/uapi/linux/netfilter_ipv4.h:30-46). Read the whole enum rather than the famous half of it: SELINUX_FIRST sits between RAW and CONNTRACK, so a reader doing the arithmetic on an abbreviated ladder gets the wrong neighbours. The filter table declares .priority = NF_IP_PRI_FILTER, i.e. zero (net/ipv4/netfilter/iptable_filter.c:28), and registers ipt_do_table as its hook function (:86). Table ordering is therefore arithmetic on that enum, not a rule of thumb to memorise. The same file settles which hooks a table may even use: FILTER_VALID_HOOKS is (1 << NF_INET_LOCAL_IN) | (1 << NF_INET_FORWARD) | (1 << NF_INET_LOCAL_OUT) (:19-21). Three of the five. The filter table cannot be registered at PREROUTING or POSTROUTING at all, which is why 'put the DROP in PREROUTING' is not a tuning choice but a category error in iptables. Be precise about whose restriction that is, though: it is this table's, not netfilter's. An nftables base chain carries its own hook and priority, read from the ruleset in nft_chain_parse_hook() (net/netfilter/nf_tables_api.c:2342-2343) and refused only when the chain type's mask lacks that hook (:2357) — and nft_chain_filter_ipv4's hook_mask carries all five (net/netfilter/nft_chain_filter.c:26-34), so `type filter hook prerouting` loads there. Inside a table, the walk is linear and first-match-wins. ipt_do_table() starts at get_entry(table_base, private->hook_entry[hook]) and enters a do/while loop: a non-matching entry advances via ipt_next_entry(e) and continues; a matching one runs its matches, bumps counters, and executes the target; XT_CONTINUE advances to the next entry while any other verdict breaks out of the loop entirely (net/ipv4/netfilter/ip_tables.c:223, :275-354). First match ends the walk. That is the whole reason insert-versus-append is a correctness question and not a style preference. And the policy is the end of that same list. iptable_filter_table_init() reaches into the freshly allocated table and overwrites one built-in entry's verdict — 'Entry 1 is the FORWARD hook' — setting it to -NF_ACCEPT - 1 or NF_DROP - 1 depending on a module parameter (:45-47). That parameter is declared `static bool forward __read_mostly = true` under the comment 'Default to forward because I got too much mail already' (:33-35). A stock kernel's FORWARD policy is ACCEPT, and it is ACCEPT because of a maintainer's overfull inbox — the comment says so itself, and the pinned tree is a depth-1 clone that dates nothing, so no year is claimed here.

  3. 03

    INVALID Is Not a Connection State

    Read the state enum and count: IP_CT_ESTABLISHED, IP_CT_RELATED, IP_CT_NEW, IP_CT_IS_REPLY as a threshold, then the reply aliases IP_CT_ESTABLISHED_REPLY and IP_CT_RELATED_REPLY, IP_CT_NUMBER as the count, and IP_CT_UNTRACKED = 7 (include/uapi/linux/netfilter/nf_conntrack_common.h:7-35). There is no IP_CT_INVALID. A case-insensitive grep for it across include/ and net/netfilter/ in the pinned tree returns nothing. The INVALID you match on in a rule is not a state conntrack can be in; it is a bit the MATCH synthesises when conntrack has nothing to say. conntrack_mt() calls nf_ct_get(skb, &ctinfo) and then branches three ways: an entry gives XT_CONNTRACK_STATE_BIT(ctinfo), the explicit untracked case gives XT_CONNTRACK_STATE_UNTRACKED, and everything else — meaning no entry — gives XT_CONNTRACK_STATE_INVALID (net/netfilter/xt_conntrack.c:162-177). The bit arithmetic is the proof. XT_CONNTRACK_STATE_BIT(ctinfo) is (1 << ((ctinfo) % IP_CT_IS_REPLY + 1)), so a real state can never occupy bit 0, and XT_CONNTRACK_STATE_INVALID is defined as exactly (1 << 0) (include/uapi/linux/netfilter/xt_conntrack.h:13-14). INVALID is deliberately the one bit no connection state can produce: it encodes the absence of tracking, not a kind of tracking. Note the naming, because it misleads in a way worth inoculating against: the file is xt_conntrack.c and yet no symbol inside it carries that prefix — xt_ names the extension framework the file belongs to, not its functions. The real match is conntrack_mt(), behind the revision wrappers conntrack_mt_v1(), conntrack_mt_v2() and conntrack_mt_v3() in net/netfilter/xt_conntrack.c (:243, :251, :259), which exist because the userspace matchinfo struct grew twice and the kernel kept all three ABIs. Contrast net/netfilter/xt_hashlimit.c, which really does export a bare hashlimit_mt() (:828), so the prefix cannot be inferred in either direction and a symbol has to be grepped rather than guessed. The practical consequence of all this is the one that bites: with conntrack unloaded, no rule can match ESTABLISHED, because there is no entry to read a state from. A stateless ruleset does not get a permissive default — it gets a vocabulary in which the only state word that still matches anything is INVALID.

  4. 04

    Conntrack Brackets the Filter Table

    This section is about hook ordering only; the translation itself, the conntrack table and port exhaustion are configs/nat-conntrack. What a firewall author needs from NAT is where it sits relative to filtering, and the registrations answer that exactly. Conntrack registers four hook operations, not one, and the four are two different FUNCTIONS at two different priorities. PRE_ROUTING takes ipv4_conntrack_in and LOCAL_OUT takes ipv4_conntrack_local, both at NF_IP_PRI_CONNTRACK, which is -200; POST_ROUTING and LOCAL_IN both take nf_confirm at NF_IP_PRI_CONNTRACK_CONFIRM, which is INT_MAX (net/netfilter/nf_conntrack_proto.c:235-260, with the values at include/uapi/linux/netfilter_ipv4.h:36, :44). Only the first pair calls nf_conntrack_in() (net/netfilter/nf_conntrack_proto.c:207, :229); nf_confirm commits the entry instead, which is why the two ends of the bracket are not the same work. The filter table sits between them at 0 (include/uapi/linux/netfilter_ipv4.h:39). So state lookup happens strictly before your filter rules and confirmation strictly after all of them. Your rules read a connection's state; they are not what records it. The NAT table's own hooks are the mirror image of filter's. nf_nat_ipv4_table declares valid_hooks of PRE_ROUTING, POST_ROUTING, LOCAL_OUT and LOCAL_IN (net/ipv4/netfilter/iptable_nat.c:22-27) — and FORWARD is absent, which is the detail that catches people routing through a box. The ops array pairs each hook with a direction: PRE_ROUTING and LOCAL_OUT take NF_IP_PRI_NAT_DST (-100, destination translation, before filtering at 0), POST_ROUTING and LOCAL_IN take NF_IP_PRI_NAT_SRC (100, source translation, after filtering) (:32-56). Read in order for a forwarded packet: conntrack at -200, DNAT at -100, your filter rules at 0, SNAT at 100, conntrack confirm at INT_MAX. Two consequences follow directly and both are routinely got backwards. A filter rule in FORWARD sees the POST-DNAT destination, because -100 precedes 0, so you match the internal address a DNAT rule produced rather than the public one the sender used. And a filter rule never sees the post-SNAT source, because 100 follows 0.

  5. 05

    Both Walk Rules Linearly; the Difference Is Inside a Rule

    The usual summary — iptables scans linearly, nftables uses sets — is half right in a way that misplaces the benefit. nft_do_chain() walks the chain linearly, with for (; !rule->is_last ; rule = nft_rule_next(rule)), evaluating each expression in turn (net/netfilter/nf_tables_core.c:253, :278-292) — a sequential walk exactly like ipt_do_table()'s. Switching representation does not make the chain walk sublinear. What nftables adds is a pluggable lookup INSIDE a rule. nft_lookup_eval() resolves a match through set->ops->lookup(net, set, key) (net/netfilter/nft_lookup.c:95, :55), an indirect call into whichever backend the kernel chose for that set's shape: nft_set_bitmap.c, nft_set_hash.c, nft_set_pipapo.c (with an AVX2 variant) or nft_set_rbtree.c, all present in this tree. So ten thousand addresses become one rule holding one set lookup instead of ten thousand rules; the walk gets shorter because there is less to walk, not because walking got cheaper. The same restructuring is available to iptables through ipset, which is why the fix for a long chain is a set in either representation. Two structural differences are worth holding beyond that. Policy is a field in nftables and a rule in iptables: nft_do_chain() consults nft_base_chain(basechain)->policy only after the walk falls through with no verdict (:335-349), where struct nft_base_chain carries policy beside its embedded nf_hook_ops (include/net/netfilter/nf_tables.h:1236-1245) — against iptables encoding the policy as a built-in entry's verdict that the walk reaches by running off the end (net/ipv4/netfilter/iptable_filter.c:45-47). And the jump stacks differ in kind: nftables' is a fixed array of NFT_JUMP_STACK_SIZE, which is 16 (include/net/netfilter/nf_tables.h:22), overflowing to a WARN_ON_ONCE and NF_DROP (net/netfilter/nf_tables_core.c:320-321), while iptables sizes its stack per-table as private->stacksize and also drops on overflow (net/ipv4/netfilter/ip_tables.c:331-334). Both fail closed on deep nesting; only one has a constant you can read off a header.

Validated against ONE pinned tree, and for the first time in M5 no new clone was needed for it. Linux v6.12.93 @ 1d3a00d3b was already pinned for TCP and NAT, and its sparse checkout already contained both net/netfilter/ and net/ipv4/netfilter/, so every claim in this module is cited to the same tree the TCP and NAT modules already read — no cone extension, no re-resolve to a newer kernel. Nothing here is cited to a wiki or a man page: documentation is not a pinned artifact, and where documentation and source would disagree there is no documentation here to disagree with. THE SEAM IS THAT THERE ARE THREE WALKERS, NOT ONE, and conflating them is the single most common error in how firewalling gets explained. The HOOK layer is generic and protocol-independent: nf_hook_slow() (net/netfilter/core.c:619) walks an array of registered hook functions in priority order and knows nothing about rules, tables or chains. The TABLE layer is iptables-specific: ipt_do_table() (net/ipv4/netfilter/ip_tables.c:223) is itself just one registered hook function, and it walks a rule list. The nftables layer is a third, separate walker: nft_do_chain() (net/netfilter/nf_tables_core.c:253) is another registered hook function walking its own representation. So 'the firewall' is not a subsystem a packet enters — it is one entry in a hook array, and iptables and nftables are peers that register into the same array rather than one replacing the other. SCOPE: this module is the FIREWALL side of netfilter and deliberately stops where configs/nat-conntrack begins. The conntrack table itself, its sizing and timeouts, SNAT/DNAT translation, port exhaustion, helpers and hairpin NAT all belong to configs/nat-conntrack and are not re-taught here. What this module takes from conntrack is only what a firewall rule can see of it: the hook priorities that put conntrack before and after filtering, and the match bit that reports its absence. v6.12.93 @ 1d3a00d3b — net/netfilter/ (generic hook walk, nftables chain walk, conntrack match, hashlimit) + net/ipv4/netfilter/ (iptables rule walk, filter and nat tables) — net/netfilter/core.c and friends. Every simulated behavior cites a real source function, and a server-side correlation engine computes how each layer's state cascades into the next. See the methodology →

This module is part of zerohop Pro — it unlocks alongside the full kata library, progress tracking, and weak-area analysis.