Network Address Translation NAT
Network Address Translation (NAT) is the kernel facility that rewrites the IP addresses and transport-layer ports inside packets as they pass through a forwarding host, so that a flow appears to come from — or go to — a different address than it really does. In Linux this is not a separate subsystem but a consumer of two others: it is implemented as a set of netfilter hook functions that act on packets, and every rewrite is recorded on the flow’s connection-tracking entry so that all later packets of the same flow — including the replies coming back the other way — are translated consistently and automatically. The two canonical directions are Source NAT (SNAT), which rewrites the source address on the way out (a home or cloud gateway hiding many private hosts behind one public IP), and Destination NAT (DNAT), which rewrites the destination on the way in (port forwarding, load balancing). The whole edifice exists because the 32-bit IPv4 address space is too small to give every host a globally-unique address (per RFC 3022); NAT’s cost is the loss of end-to-end addressing. All facts here are pinned to the 6.12 LTS kernel (
net/netfilter/nf_nat_core.c), spot-checked identical against 6.18 LTS, as of 2026-06-06.
NAT is one face of the netfilter family. The framework that provides the hook points is The Netfilter Framework and Hooks; the stateful flow table that NAT binds to is Connection Tracking conntrack; the two userspace rule languages that request NAT are iptables and x_tables (legacy) and nftables and the nf_tables Subsystem (modern). This note explains the translation mechanism itself — what gets rewritten, where, and how the binding is stored — and leans on those siblings for the machinery underneath rather than re-deriving it.
Mental Model: NAT Is a Mapping Bound to a Flow, Not a Rule Applied to a Packet
The single most important idea is that NAT is per-flow, not per-packet. A NAT rule does not fire on every packet. Instead, the first packet of a new connection is run past the NAT rules; whatever address/port rewrite they request is computed once, recorded on the connection’s conntrack entry, and from then on every packet of that flow is translated by replaying the stored mapping — without consulting the rules again. The netfilter NAT-HOWTO states this directly: “The first packet of a flow is used to look up for a matching rule which sets up the NAT binding for this flow”; subsequent packets use the established binding (nftables wiki).
This is why NAT needs conntrack. To translate the reply direction, the kernel must remember that “this incoming packet from R:80 to 1.2.3.4:34000 is actually the reply to the flow whose real source was 10.0.0.5:34000,” and rewrite the destination back to 10.0.0.5:34000. That memory is the conntrack entry. Without flow tracking there is no way to reverse a translation on the return path, and no way to keep two different inside hosts that happened to pick the same source port from colliding.
flowchart LR subgraph inside["Inside (private)"] H["Host 10.0.0.5:34000"] end subgraph GW["NAT gateway"] CT["conntrack entry<br/>ORIG: 10.0.0.5:34000 -> R:80<br/>REPLY: R:80 -> 1.2.3.4:34000<br/>status: IPS_SRC_NAT"] end subgraph outside["Outside (Internet)"] R["Server R:80"] end H -->|"1. out: src rewritten<br/>10.0.0.5 -> 1.2.3.4"| R R -->|"2. reply: dst rewritten back<br/>1.2.3.4 -> 10.0.0.5"| H CT -.->|"binds both directions"| GW
How a SNAT mapping lives on a conntrack entry. What it shows: the gateway rewrites the source on egress (arrow 1) and the same conntrack entry tells it to undo that on the matching reply (arrow 2) by rewriting the destination back to the real inside host. The insight: NAT stores the mapping on the flow, so the forward and reverse rewrites are two halves of one binding — translate the original direction’s chosen field, and invert it for the reply. The IPS_SRC_NAT status bit on the entry records that source-NAT is in effect.
The organising fact: NAT is conntrack — there is no NAT table
The sentence above (“NAT needs conntrack”) understates it. The accurate statement is stronger and it is the one fact from which the rest of this note follows: Linux does not store NAT mappings anywhere. The mapping is the conntrack entry’s reply tuple.
Every tracked flow is represented by a struct nf_conn holding an array of exactly two tuples, ct->tuplehash[IP_CT_DIR_ORIGINAL] and ct->tuplehash[IP_CT_DIR_REPLY], both inserted into the same hash table. For an untranslated flow, the reply tuple is simply the original inverted: swap source and destination. A NAT binding is created by making the reply tuple something other than the exact inverse of the original. That is literally what nf_nat_setup_info() does — its decisive three lines at net/netfilter/nf_nat_core.c:810–817 are:
if (!nf_ct_tuple_equal(&new_tuple, &curr_tuple)) {
struct nf_conntrack_tuple reply;
/* Alter conntrack table so will recognize replies. */
nf_ct_invert_tuple(&reply, &new_tuple);
nf_conntrack_alter_reply(ct, &reply);
...There is no nat_table[], no mapping list, no separate lookup structure. nf_conntrack_alter_reply() overwrites the flow’s reply tuple, and from that moment the existence of the translation and the ability to undo it on the return path are the same object. That is why the sibling note Connection Tracking conntrack is not “background reading” for this one; it is the storage layer.
A conntrack tuple is a five-field structure plus bookkeeping, declared at include/net/netfilter/nf_conntrack_tuple.h:37–76:
| Field | Type | What it holds | NAT rewrites it? |
|---|---|---|---|
src.u3 | union nf_inet_addr | Source address (IPv4 or IPv6) | Yes, for SNAT |
src.u.all | __be16 | Source transport identifier — tcp.port, udp.port, sctp.port, dccp.port, icmp.id, gre.key | Yes, for NAPT |
dst.u3 | union nf_inet_addr | Destination address | Yes, for DNAT |
dst.u.all | __be16 | Destination transport identifier | Yes, for DNAT with a port |
dst.protonum | u8 | IP protocol number (6, 17, 1, 47, …) | No — the protocol is never translated |
dst.dir | u8 | IP_CT_DIR_ORIGINAL (0) or IP_CT_DIR_REPLY (1). Deliberately placed after the __nfct_hash_offsetend marker so it is excluded from the hash | N/A — it identifies which half of the entry matched |
| (zone) | struct nf_conntrack_zone | Which conntrack zone the entry lives in; lets identical tuples coexist in different zones | N/A |
The conntrack tuple, the unit NAT operates on. What it shows: the “5-tuple” is really src-addr, src-port, dst-addr, dst-port and protocol, with the transport identifier being a union that stretches to cover ICMP query IDs and GRE keys — which is how a stateful NAT can multiplex ping and PPTP, not just TCP and UDP. The insight to take: dir sits behind a hash-offset marker precisely so that both directions hash on the same field set, which is what lets one table answer “have I seen this flow?” from either side.
Drawn with real addresses, a source-NAT binding is one entry whose two tuples disagree about the inside address:
| Source addr:port | Dest addr:port | Proto | |
|---|---|---|---|
| ORIGINAL tuple (what the inside host sent) | 10.0.0.5:34000 | 198.51.100.7:443 | TCP |
| REPLY tuple before NAT (plain inversion) | 198.51.100.7:443 | 10.0.0.5:34000 | TCP |
REPLY tuple after nf_conntrack_alter_reply() | 198.51.100.7:443 | 203.0.113.10:34000 | TCP |
One SNAT binding as it is actually stored. What it shows: the whole translation is the single changed cell — the reply tuple’s destination now names the gateway’s public address instead of the inside host’s private one. The insight to take: nothing records “rewrite 10.0.0.5 to 203.0.113.10”. The kernel derives the forward rewrite by comparing the two tuples and the reverse rewrite by inverting that comparison, which is why the forward and reverse translations can never drift out of sync — they are the same datum read two ways.
On the wire this produces the following, with the hooks that perform each rewrite:
sequenceDiagram autonumber participant H as Inside host<br/>10.0.0.5 participant PRE as Gateway PREROUTING<br/>prio -100 (dstnat) participant CT as conntrack entry<br/>(the binding) participant POST as Gateway POSTROUTING<br/>prio +100 (srcnat) participant S as Server<br/>198.51.100.7 Note over H,S: first packet of the flow H->>PRE: SYN 10.0.0.5:34000 -> 198.51.100.7:443 PRE->>CT: nf_conntrack_in(): no entry, create unconfirmed nf_conn<br/>ctinfo = IP_CT_NEW PRE->>PRE: nf_nat_inet_fn(): HOOK2MANIP -> MANIP_DST,<br/>no dnat rule matches, nothing to do PRE->>POST: routing decision, forward POST->>POST: nf_nat_inet_fn(): HOOK2MANIP -> MANIP_SRC,<br/>masquerade rule fires POST->>CT: nf_nat_setup_info(): get_unique_tuple()<br/>then nf_conntrack_alter_reply()<br/>status |= IPS_SRC_NAT, then IPS_SRC_NAT_DONE POST->>POST: nf_nat_packet(): statusbit = IPS_SRC_NAT,<br/>dir = ORIGINAL so no XOR, bit is set -> rewrite source POST->>S: SYN 203.0.113.10:34000 -> 198.51.100.7:443 Note over CT: __nf_conntrack_confirm() inserts BOTH tuples<br/>into the hash on the way out Note over H,S: the reply S->>PRE: SYN-ACK 198.51.100.7:443 -> 203.0.113.10:34000 PRE->>CT: nf_conntrack_in(): matches the REPLY tuple<br/>ctinfo = IP_CT_ESTABLISHED_REPLY, dir = REPLY PRE->>PRE: nf_nat_packet(): HOOK2MANIP -> MANIP_DST so statusbit = IPS_DST_NAT,<br/>dir == REPLY so statusbit ^= IPS_NAT_MASK -> IPS_SRC_NAT,<br/>which IS set -> rewrite destination PRE->>H: SYN-ACK 198.51.100.7:443 -> 10.0.0.5:34000 Note over PRE: this un-SNAT happens BEFORE the routing decision,<br/>which is why the reply routes to the inside host
One flow, both directions, with the exact status-bit arithmetic. What it shows: the binding is computed once, on the first packet, at POSTROUTING; every subsequent packet in either direction is handled by nf_nat_packet() replaying it, and the reverse direction is obtained by a single XOR against IPS_NAT_MASK rather than by a second rule lookup. The insight to take: step 12 is the load-bearing one. A source-NAT’d flow’s reply is destination-NAT’d, and it is destination-NAT’d at PREROUTING — so PREROUTING’s “before routing” position is not only about DNAT rules you wrote, it is also what makes reply traffic for every SNAT’d flow routable back to the inside host. That is the real reason the hook-to-manipulation mapping is hard-coded rather than configurable.
What “stateless NAT” would mean, and why Linux netfilter has none
Because the binding is the conntrack entry, a netfilter nat chain cannot exist without connection tracking: registering a chain of type nat is what installs nf_nat_inet_fn at the hook, and that function’s first act is nf_ct_get(skb, &ctinfo) — if it returns NULL, the packet is accepted untranslated. “Stateless NAT” is therefore not a mode you can switch netfilter into; it is a different mechanism entirely, and one that netfilter’s NAT engine does not provide.
If you genuinely want a translation with no per-flow state — a 1:1 prefix map where 10.0.0.0/24 always becomes 203.0.113.0/24 by arithmetic on the low bits, with no table lookup and no memory per connection — you must rewrite the headers as payload, outside the NAT machinery: an nftables payload-set statement (ip saddr set …) in a non-nat chain, a tc-pedit action, or an XDP program rewriting bytes in the driver. All three share the same bargain: no table means no memory ceiling and no port-exhaustion failure mode, and no automatic reverse translation — you must write the inverse rule yourself, and you cannot multiplex many inside hosts onto one outside address, because without remembering which port belonged to whom there is no way to demultiplex the replies. That is the whole trade. Netfilter’s NF_NAT_RANGE_NETMAP flag (added in v5.8; absent from include/uapi/linux/netfilter/nf_nat.h at v5.7) offers the 1:1 prefix-mapping semantics of stateless NAT — nft_nat_eval() calls nft_nat_setup_netmap() when the flag is set (nft_nat.c:116) — but it still runs through nf_nat_setup_info() two lines later and still creates a conntrack entry. It is prefix-preserving NAT, not stateless NAT.
Basic NAT vs NAPT (the address-only vs address+port distinction)
Two flavors exist. Basic NAT translates addresses only — a one-to-one map of inside address to outside address (RFC 2663: “translation in Basic NAT is limited to IP addresses alone”). NAPT (Network Address Port Translation) — what everyone actually means by “NAT” today, and what a home router does — also rewrites the transport identifier (the TCP/UDP port, or the ICMP query ID), so that many inside hosts can share one outside address by multiplexing them across the port space (RFC 3022: “translation in NAPT is extended to include IP address and Transport identifier such as TCP/UDP port or ICMP query ID”). Linux’s NAT engine does NAPT by default: if the source-port rewrite is needed to avoid a collision, it picks a free port for you. The port-rewriting machinery is detailed below.
Mechanical Walk-Through: How the NAT Engine Works
All of Linux NAT — whether requested by an iptables rule, an nftables snat/dnat/masquerade statement, or a conntrack-netlink call — funnels through one function in net/netfilter/nf_nat_core.c: nf_nat_setup_info() (6.12 LTS). Everything else is a frontend that builds an nf_nat_range2 (the min/max address and port to map into) and calls it. The proof that the two rule languages converge here is in the source: nft_nat_eval() ends with regs->verdict.code = nf_nat_setup_info(ct, &range, priv->type) (nft_nat.c), and the legacy iptables nat table registers ipt_do_table at the NAT hooks (iptable_nat.c), whose SNAT/DNAT targets call the same core. There is exactly one NAT engine; the frontends only differ in how the rule is expressed.
The hooks: where NAT runs in the packet pipeline
NAT is installed at four of netfilter’s five hook points, and which manipulation each hook performs is fixed by the hook’s position relative to the routing decision:
- PRE_ROUTING (and LOCAL_OUT for locally-generated packets) → destination manipulation (DNAT). This must happen before routing, because rewriting the destination changes where the packet should go.
- POST_ROUTING (and LOCAL_IN for packets delivered locally) → source manipulation (SNAT). This happens after routing, “just before it is finally sent out,” so that “anything else on the Linux box itself — routing, packet filtering — will see the packet unchanged” (netfilter NAT-HOWTO).
| Netfilter hook | enum nf_inet_hooks value | HOOK2MANIP() result | NAT priority constant | Value | Which packets reach it |
|---|---|---|---|---|---|
NF_INET_PRE_ROUTING | 0 | NF_NAT_MANIP_DST | NF_IP_PRI_NAT_DST | −100 | Everything arriving from the wire, before the routing decision |
NF_INET_LOCAL_IN | 1 | NF_NAT_MANIP_SRC | NF_IP_PRI_NAT_SRC | +100 | Packets routed to this host |
NF_INET_FORWARD | 2 | — (no NAT hook here) | — | — | Packets routed through this host |
NF_INET_LOCAL_OUT | 3 | NF_NAT_MANIP_DST | NF_IP_PRI_NAT_DST | −100 | Packets generated by local sockets |
NF_INET_POST_ROUTING | 4 | NF_NAT_MANIP_SRC | NF_IP_PRI_NAT_SRC | +100 | Everything on its way out, after routing |
NF_INET_INGRESS | 5 (aliased to NF_INET_NUMHOOKS) | — (no NAT) | — | — | Earliest hook, pre-conntrack; used by nftables ingress chains |
Where NAT is installed and what it may do there. What it shows: four of the five classic hooks carry NAT, and each one’s manipulation type is fixed by geometry, not by configuration — destination before routing, source after. FORWARD gets no NAT hook at all, which is the single most common iptables misconception. The insight to take: the priority column tells you the ordering relative to everything else on the same hook. −100 puts DNAT after mangle (−150) and after conntrack (−200), and +100 puts SNAT after filter (0) — so your filter rules always see pre-SNAT source addresses and post-DNAT destination addresses, which is exactly why a firewall rule for a port-forwarded service is written against the internal address.
Placed on the full netfilter priority ladder from include/uapi/linux/netfilter_ipv4.h:30–46, NAT’s two slots sit here:
flowchart TB WIRE["packet arrives from the wire"] --> ING["NF_INET_INGRESS<br/>nftables ingress chains<br/>(no conntrack yet)"] ING --> PRE0["PRE_ROUTING, in priority order:"] PRE0 --> P1["-450 NF_IP_PRI_RAW_BEFORE_DEFRAG"] P1 --> P2["-400 CONNTRACK_DEFRAG<br/>IP fragment reassembly"] P2 --> P3["-300 RAW<br/>(NOTRACK lives here)"] P3 --> P4["-200 CONNTRACK<br/>nf_conntrack_in(): find or create the nf_conn"] P4 --> P5["-150 MANGLE"] P5 --> P6["-100 NAT_DST <-- DNAT applied here<br/>and SNAT'd replies un-translated here"] P6 --> ROUTE{"routing decision<br/>uses the POST-DNAT destination"} ROUTE -->|"for this host"| LIN["LOCAL_IN<br/>... 0 FILTER ... +100 NAT_SRC ..."] ROUTE -->|"not for this host"| FWD["FORWARD<br/>0 FILTER<br/>(NO NAT HOOK)"] LOCALSK["local socket sends"] --> LOUT["LOCAL_OUT<br/>-100 NAT_DST"] FWD --> POST0["POST_ROUTING, in priority order:"] LOUT --> POST0 POST0 --> Q1["+100 NAT_SRC <-- SNAT / MASQUERADE applied here<br/>and DNAT'd replies un-translated here"] Q1 --> Q2["+300 CONNTRACK_HELPER<br/>ALGs parse the payload"] Q2 --> Q3["INT_MAX CONNTRACK_CONFIRM<br/>__nf_conntrack_confirm() inserts both tuples"] Q3 --> OUT["out the egress interface"] LIN --> DELIVER["deliver to socket"]
The netfilter hook chain with NAT’s two slots marked. What it shows: conntrack runs at −200, a full 100 priority units before NAT at −100, so by the time a NAT rule is evaluated the packet already has an nf_conn attached — that ordering is what makes “look up the binding, or create one” possible in a single pass. And confirmation is last of all, at INT_MAX, so the entry is not visible to other CPUs until the packet has survived the whole traversal. The insight to take: the gap between creating an unconfirmed entry at −200 and inserting it at INT_MAX is the window in which two CPUs can independently choose the same translated tuple — the race this note’s Production Notes section traces to a five-second DNS timeout.
This mapping is hard-coded. In nf_nat_inet_fn() the kernel computes maniptype = HOOK2MANIP(state->hook) — PRE_ROUTING/LOCAL_OUT yield NF_NAT_MANIP_DST, POST_ROUTING/LOCAL_IN yield NF_NAT_MANIP_SRC. The legacy table confirms the priorities: iptable_nat.c registers PRE_ROUTING and LOCAL_OUT at NF_IP_PRI_NAT_DST and POST_ROUTING and LOCAL_IN at NF_IP_PRI_NAT_SRC. The nftables side enforces the same constraint at rule-load time: nft_nat_validate() rejects a dnat statement unless its chain hooks PRE_ROUTING or LOCAL_OUT, and a snat unless it hooks POST_ROUTING or LOCAL_IN. You cannot DNAT in postrouting; the kernel will not let you.
First packet: computing and storing the binding
When the first packet of a IP_CT_NEW flow reaches a NAT hook, nf_nat_inet_fn() checks nf_nat_initialized(ct, maniptype). If no binding exists yet, it runs the registered NAT rule entries; one of them calls nf_nat_setup_info() with the requested range. That function does the real work:
- It takes the conntrack entry’s reply tuple and inverts it to get the current original tuple (
nf_ct_invert_tuple(&curr_tuple, &ct->tuplehash[IP_CT_DIR_REPLY].tuple)). - It calls
get_unique_tuple()to choose a new tuple that satisfies the requested range and does not collide with any existing flow (the port-allocation logic, below). - If the new tuple differs from the current one, it rewrites the conntrack reply tuple so the entry will recognize replies:
nf_ct_invert_tuple(&reply, &new_tuple); nf_conntrack_alter_reply(ct, &reply). This is the act that binds the NAT mapping to the flow — the conntrack entry’s reply side now describes the translated addresses, so returning packets will match. - It sets the status bit on the entry:
ct->status |= IPS_SRC_NAT(orIPS_DST_NAT), and laterIPS_SRC_NAT_DONE/IPS_DST_NAT_DONE. These bits are hownf_nat_packet()later knows which direction to mangle. - For source NAT it inserts the entry into a secondary hash,
nf_nat_bysource, keyed on the original source tuple. This lets a new connection from the same inside source reuse the same outside source mapping (find_appropriate_src()), so a given inside host is seen by the server consistently — useful for “some Internet Banking sites” that pin clients to a source IP, per the in-code comment.
The translation request itself is carried in a struct nf_conn_nat extension attached to the conntrack entry (nf_ct_nat_ext_add()); NAT state is literally stored on the nf_conn, which is the concrete meaning of “the NAT mapping recorded in nf_conn.”
Every packet: replaying the mapping in both directions
Once the binding exists, actual byte-level rewriting happens in nf_nat_packet(). It reads the manip type for the hook and the flow’s direction:
if (mtype == NF_NAT_MANIP_SRC) statusbit = IPS_SRC_NAT;
else statusbit = IPS_DST_NAT;
if (dir == IP_CT_DIR_REPLY) statusbit ^= IPS_NAT_MASK; // invert for replies
if (ct->status & statusbit) verdict = nf_nat_manip_pkt(skb, ct, mtype, dir);The XOR on the reply direction is the crux of two-way translation: a flow with IPS_SRC_NAT set will, on its original direction, have its source rewritten at POST_ROUTING; on its reply direction the bit flips so that the same POST_ROUTING/PRE_ROUTING traversal rewrites the destination back to the real inside host. One stored binding, applied symmetrically. nf_nat_manip_pkt() then edits the IP header (and recomputes the checksum) and the L4 header (port/ID and its checksum) in the [[struct sk_buff|skb]].
Port allocation and collision handling
The interesting part of get_unique_tuple() is choosing a free port when NAPT is in play. nf_nat_l4proto_unique_tuple() (6.12 LTS) shows the default source-port ranges when no explicit range is given: ports below 512 map into 1–511, ports 512–1023 map into 600–1023, and ports >= 1024 map into 1024–65535 — preserving the privileged/unprivileged split. It then searches for a free port, capped at NF_NAT_MAX_ATTEMPTS = 128 tries, because — as the in-code comment warns — “we are in softirq; doing a search of the entire range risks soft lockup when all tuples are already used.” If the first offset finds nothing, it picks a new random start and tries again with an ever-smaller window. “Free” is checked by nf_nat_used_tuple(), which inverts the proposed tuple and asks conntrack whether the reply already exists (nf_conntrack_tuple_taken()) — two flows must not produce an identical reply tuple, or returning packets could not be told apart. If, after all attempts, no unique tuple is found, the packet is allowed through and a final duplicate is caught at __nf_conntrack_confirm() time, where the packet is dropped — the worst case is a dropped packet, never a silent mis-translation.
Those constants are verified, not remembered. Re-read at the v6.12 tag during this revision: NF_NAT_MAX_ATTEMPTS is #defined to 128 at nf_nat_core.c:30, and the three default buckets are at lines 646–658 of the same file, written as min/range_size pairs rather than as min/max — min = 1, range_size = 511 - min + 1 (so 1–511), min = 600, range_size = 1023 - min + 1 (so 600–1023), and min = 1024, range_size = 65535 - 1024 + 1 (so 1024–65535), guarded by the in-code comment “Loose convention: >> 512 is credential passing”. The previous flag on this claim is resolved.
Two further details of the same function are worth having, because both have operational consequences. First, the search start is already random for source NAT, with or without --random-fully. The offset selection at lines 668–674 reads:
if (range->flags & NF_NAT_RANGE_PROTO_OFFSET)
off = (ntohs(*keyptr) - ntohs(range->base_proto.all));
else if ((range->flags & NF_NAT_RANGE_PROTO_RANDOM_ALL) ||
maniptype != NF_NAT_MANIP_DST)
off = get_random_u16();
else
off = 0;maniptype != NF_NAT_MANIP_DST is true for every SNAT, so the second branch fires unconditionally on the source-NAT path. This condition has been in the tree since at least v4.19 (checked at v4.19, v5.0, v5.1, v5.4, v5.10, v5.15, v6.1, v6.6 and v6.12). What --random-fully still changes is not this line but get_unique_tuple()’s step 1, which it skips — see the mapping-behaviour discussion below.
Second, the retry loop shrinks its window. After attempts failures the function halves attempts, draws a fresh random offset and goes round again, stopping when attempts >= range_size || attempts < 16. Combined with the 128-attempt cap this bounds the softirq-context work at roughly 128 + 64 + 32 + 16 probes in the worst case rather than a 64,000-entry linear scan — the explicit reason being, per the comment at lines 680–685, that “we are in softirq; doing a search of the entire range risks soft lockup when all tuples are already used.”
Third, and newest: since v6.5 the last quarter of those attempts becomes destructive. NF_NAT_HARDER_THRESH is NF_NAT_MAX_ATTEMPTS / 4 = 32 (nf_nat_core.c:31), and once fewer than 32 attempts remain, nf_nat_used_tuple_harder() will evict a colliding entry — but only under four simultaneous conditions (nf_nat_core.c:346–399): the protocol is TCP, our own new connection is in TCP_CONNTRACK_SYN_SENT, the colliding entry matched in the reply direction, and nf_seq_has_advanced() confirms our sequence number is beyond the old entry’s — plus the victim must not be hardware- or software-offloaded (IPS_OFFLOAD | IPS_HW_OFFLOAD). This is the kernel deliberately reclaiming TIME_WAIT ports under pressure, and it exists at v6.5 but not at v6.4. A fourth, even newer refinement, nf_nat_used_tuple_new(), is a v6.12 addition — absent at v6.11 — which additionally checks for “rare clash in reverse direction” when reusing the original tuple.
flowchart TB START["get_unique_tuple(orig, range, ct, maniptype)"] --> S1{"maniptype == SRC<br/>AND not PROTO_RANDOM_ALL?"} S1 -->|"no (DNAT, or --random-fully)"| S2 S1 -->|"yes"| T1{"is orig_tuple already<br/>inside the requested range?"} T1 -->|"yes"| T2{"nf_nat_used_tuple_new()<br/>says it is free?"} T2 -->|"yes"| DONE1["keep the original source port<br/>(port preservation)"] T2 -->|"no"| S2 T1 -->|"no"| T3{"find_appropriate_src():<br/>same inside src IP+port+proto<br/>already mapped somewhere?"} T3 -->|"yes, and in range"| T4{"nf_nat_used_tuple()<br/>says it is free?"} T4 -->|"yes"| DONE2["REUSE the existing outside src<br/>= Endpoint-Independent Mapping"] T4 -->|"no"| S2 T3 -->|"no"| S2 S2["step 2: find_best_ips_proto()<br/>pick the outside IP by jhash2 over src<br/>(mixed with dst unless PERSISTENT)"] --> S3{"already unique<br/>and in range?"} S3 -->|"yes"| DONE3["done, no port rewrite needed"] S3 -->|"no"| S4["step 3: nf_nat_l4proto_unique_tuple()<br/>pick a port from the bucket,<br/>random start, up to 128 tries,<br/>halving window on failure"] S4 --> S5{"free tuple found?"} S5 -->|"yes"| DONE4["use it"] S5 -->|"no"| GIVEUP["give up; packet proceeds and is<br/>dropped at __nf_conntrack_confirm()<br/>unless the clash can be resolved"]
The source-port and address selection algorithm, in the order the kernel actually tries things. What it shows: three ranked strategies — preserve the original port, reuse this host’s existing mapping, then search — with a bounded search at the end and a packet drop as the terminal failure. The insight to take: step 1 is where Linux’s RFC-compliant mapping behaviour comes from, and --random-fully turns step 1 off. Randomizing ports and preserving a stable external mapping are directly in tension, and the flag chooses the former.
Mapping behaviour: what RFC 4787 asks for and what Linux does
RFC 4787 (Best Current Practice, 2007) is the document that made NAT behaviour specifiable rather than folklore. It defines three mapping behaviours in terms of an inside endpoint X:x sending to two different outside endpoints Y1:y1 and Y2:y2, producing outside mappings X1':x1' and X2':x2':
| Behaviour | RFC 4787 definition | Consequence for peer-to-peer |
|---|---|---|
| Endpoint-Independent Mapping | “X1':x1' equals X2':x2' for all values of Y2:y2” — the same inside socket always appears as the same outside socket, whoever it talks to | A STUN server can tell the host its own outside address, and that address is usable by a different peer. This is what makes ICE work |
| Address-Dependent Mapping | “X1':x1' equals X2':x2' if and only if Y2 equals Y1” | The address STUN reports is only valid for talking to the STUN server. Hole punching fails |
| Address and Port-Dependent Mapping | equal “if and only if Y2:y2 equals Y1:y1” | Worse still; forces a TURN relay |
The RFC’s first and strongest requirement is unambiguous: “REQ-1: A NAT MUST have an ‘Endpoint-Independent Mapping’ behavior”, justified because “in order for UNSAF methods to work, REQ-1 needs to be met. Failure to meet REQ-1 will force the use of a UDP relay.” The RFC is also careful to note that this “makes no difference to the security properties of the NAT” — filtering behaviour, which packets are let in, is a separate axis.
Linux’s engine tries to satisfy REQ-1, and the mechanism is find_appropriate_src() plus same_src() at nf_nat_core.c:463–501. The secondary nf_nat_bysource hash is keyed by hash_by_src() over {tuple->src, protonum, net_mix, zone} — the source side only, with the destination deliberately excluded — and same_src() compares exactly three things: protocol number, source address, and source port. When a second connection from the same inside socket to a different server arrives, that lookup finds the existing mapping and reuses it. Endpoint-independent, by construction. Two qualifications, both important and both easy to miss:
- It is best-effort, not guaranteed.
find_appropriate_src()only returns the old mapping if it is stillnf_in_range()of the current rule’s range andnf_nat_used_tuple()says it is free. When the mapping cannot be reused the kernel falls through to a fresh search, and the NAT silently degrades to address-dependent behaviour for that flow. - It is disabled by
--random-fully. The step-1 guard ismaniptype == NF_NAT_MANIP_SRC && !(range->flags & NF_NAT_RANGE_PROTO_RANDOM_ALL), so a masquerade rule carrying--random-fullyskips the reuse path entirely and picks a fresh random port per flow. That is a real trade-off and it is not usually presented as one:--random-fullyis recommended everywhere as the fix for the conntrack insertion race (below), and the cost is REQ-1 conformance for any application on that gateway doing NAT traversal.
The choice of outside address, when the SNAT range spans several, is governed by the same tension and by NF_NAT_RANGE_PERSISTENT. find_best_ips_proto() picks the address with jhash2() over the source address, seeded with range->flags & NF_NAT_RANGE_PERSISTENT ? 0 : (__force u32)tuple->dst.u3.all[max] ^ zone->id — i.e. without persistent, the destination address is mixed into the hash, so the same client can egress from different public IPs depending on where it is going; with persistent, only the source is hashed and the client’s public address is stable. The in-code comment names the motivation directly: “The consistency means that servers see the same client coming from the same IP (some Internet Banking sites like this), even across reboots.” In RFC 6888’s vocabulary for carrier-grade NAT this is the difference between arbitrary pooling and “REQ-2: A CGN MUST have a default ‘IP address pooling’ behavior of ‘Paired’” — so --persistent is, in effect, the knob that makes a Linux SNAT gateway CGN-conformant on that requirement.
Two more RFC requirements are worth checking Linux against, because the answer is instructive rather than flattering. REQ-5 states that “a NAT UDP mapping timer MUST NOT expire in less than two minutes.” Linux’s default nf_conntrack_udp_timeout is 30 seconds (Documentation/networking/nf_conntrack-sysctl.rst, and read as 30 from /proc/sys/net/netfilter/nf_conntrack_udp_timeout on the host this note was revised on) — four times shorter than the floor the BCP sets. The saving grace is nf_conntrack_udp_timeout_stream, default 120 seconds — exactly the RFC’s two-minute floor — which the documentation says “will be used in case there is an UDP stream detected,” meaning once traffic has been seen in both directions. So a Linux NAT conforms to REQ-5 for established two-way UDP flows and does not for a flow that has only ever sent. REQ-9, “a NAT MUST support ‘Hairpinning’”, is the requirement behind the hairpin-NAT section below, and Linux does not satisfy it out of the box — hairpinning must be configured with an explicit extra rule.
Configuration: Expressing NAT in iptables and nftables
The same engine, two languages. Both put NAT rules in a chain of type nat hooked at the right point. Before the syntax, the taxonomy: there are five named NAT operations in Linux, and every one of them is a different way of filling in the same struct nf_nat_range2 before calling nf_nat_setup_info().
| Operation | Manip type | Valid hooks | Where the new address comes from | nf_nat_range2 flags set | Kernel entry point |
|---|---|---|---|---|---|
| SNAT | NF_NAT_MANIP_SRC | POSTROUTING, LOCAL_IN | A literal address (or range) you wrote in the rule | MAP_IPS, plus PROTO_SPECIFIED if you gave a port range | nf_nat_setup_info() directly |
| MASQUERADE | NF_NAT_MANIP_SRC | POSTROUTING only | The egress interface’s current primary address, read per-flow with inet_select_addr(out, nh, RT_SCOPE_UNIVERSE) | caller’s flags | MAP_IPS | nf_nat_masquerade_ipv4() (nf_nat_masquerade.c:28) |
| DNAT | NF_NAT_MANIP_DST | PREROUTING, LOCAL_OUT | A literal address (or range), optionally with a port | MAP_IPS, PROTO_SPECIFIED | nf_nat_setup_info() directly |
| REDIRECT | NF_NAT_MANIP_DST | PREROUTING, LOCAL_OUT | “This machine”: the ingress interface’s primary address at PREROUTING, or INADDR_LOOPBACK (127.0.0.1) at LOCAL_OUT | caller’s flags | MAP_IPS | nf_nat_redirect_ipv4() (nf_nat_redirect.c:50–79) |
| NETMAP | either | per the direction | A 1:1 prefix remap — the host bits of the original address are preserved and only the network bits are replaced | NETMAP (bit 6), added v5.8 | nft_nat_setup_netmap() then nf_nat_setup_info() (nft_nat.c:116–125) |
The five NAT operations. What it shows: SNAT/DNAT take an address from the rule; MASQUERADE and REDIRECT compute one from an interface at packet time; NETMAP computes one arithmetically from the original. The insight to take: the three computed forms are conveniences with costs. MASQUERADE re-reads the interface address on every new flow and ties the binding’s lifetime to the link (see below); REDIRECT’s LOCAL_OUT case silently becomes 127.0.0.1, which is why a transparent proxy that works for forwarded traffic can behave differently for traffic the box originates itself.
The full nf_nat_range2 flag set, from include/uapi/linux/netfilter/nf_nat.h, is the complete vocabulary of what a NAT rule can ask for:
| Flag | Bit | Meaning | Userspace spelling |
|---|---|---|---|
NF_NAT_RANGE_MAP_IPS | 0 | Rewrite the address, not just the port | implied by any --to-source / --to-destination |
NF_NAT_RANGE_PROTO_SPECIFIED | 1 | A port/ID range was given; use it instead of the default buckets | the :port-port suffix |
NF_NAT_RANGE_PROTO_RANDOM | 2 | Randomize port selection | --random / random |
NF_NAT_RANGE_PERSISTENT | 3 | When mapping onto a range of addresses, hash only the source address, not source-⊕-destination — so one client always gets the same public IP | --persistent / persistent |
NF_NAT_RANGE_PROTO_RANDOM_FULLY | 4 | Fully randomize port selection | --random-fully / fully-random |
NF_NAT_RANGE_PROTO_OFFSET | 5 | Start the port search at a fixed offset from base_proto rather than at random | nftables offset |
NF_NAT_RANGE_NETMAP | 6 | 1:1 prefix remap (v5.8+) | nftables netmap |
NF_NAT_RANGE_PROTO_RANDOM_ALL | 2|4 | The OR of the two randomize flags; used as a single test in get_unique_tuple() | — |
The NAT range flags. What it shows: PROTO_RANDOM and PROTO_RANDOM_FULLY are distinct bits that the core almost always tests together as PROTO_RANDOM_ALL. The insight to take: PROTO_RANDOM_ALL does not only make port selection random — in get_unique_tuple() it disables step 1 entirely, the step that tries to preserve or reuse an existing source mapping. Setting --random-fully therefore changes the NAT’s observable mapping behaviour, not just its entropy, which is discussed under mapping behaviour below.
iptables (legacy x_tables frontend)
All NAT lives in the nat table, selected with -t nat. The targets and their valid chains (from iptables-extensions(8)):
# SNAT — gateway with a static public IP 203.0.113.10, hide 10.0.0.0/24 behind it.
# SNAT is valid in POSTROUTING (and INPUT). -o names the egress interface.
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j SNAT --to-source 203.0.113.10
# MASQUERADE — same idea but the public address is dynamic (DHCP / PPPoE).
# Valid in POSTROUTING only; uses the egress interface's *current* address.
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
# DNAT — port forwarding: inbound :80 on the public side to an internal server.
# DNAT is valid in PREROUTING (and OUTPUT). -i names the ingress interface.
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j DNAT --to-destination 10.0.0.20:8080
# REDIRECT — DNAT to "this machine" on a different port (transparent proxy).
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 3128Line by line: --to-source ipaddr[-ipaddr][:port-port] and --to-destination specify the address (and optional port) range to map into. --random / --random-fully randomize the source port selection (the latter using a PRNG, kernel ≥ 3.14), defending against off-path port-prediction; --persistent “gives a client the same source-/destination-address for each connection.” Per the man page, MASQUERADE “should only be used with dynamically assigned IP (dialup) connections: if you have a static IP address, you should use the SNAT target,” because masquerade pays a per-packet cost to look up the interface address and, critically, “connections are forgotten when the interface goes down” — the right behavior when the next dialup may bring a different address (iptables-extensions).
nftables (modern nf_tables frontend)
In nftables you create the chain with an explicit hook and priority, then add snat/dnat/masquerade/redirect statements (nftables wiki):
nft add table ip nat
# Source NAT lives in a postrouting chain at priority 100 (srcnat).
nft 'add chain ip nat postrouting { type nat hook postrouting priority 100; }'
nft add rule ip nat postrouting ip saddr 10.0.0.0/24 oif "eth0" snat to 203.0.113.10
nft add rule ip nat postrouting oif "eth0" masquerade # dynamic egress addr
# Destination NAT lives in a prerouting chain at priority -100 (dstnat).
nft 'add chain ip nat prerouting { type nat hook prerouting priority -100; }'
nft 'add rule ip nat prerouting iif "eth0" tcp dport { 80, 443 } dnat to 10.0.0.20'
nft add rule ip nat prerouting tcp dport 22 redirect to 2222The priority numbers matter: -100 (dstnat) places DNAT before the routing decision in prerouting, 100 (srcnat) places SNAT late in postrouting, matching the iptables NF_IP_PRI_NAT_DST / NF_IP_PRI_NAT_SRC constants exactly. The random,persistent,fully-random flags can be appended (masquerade random,persistent). Unlike iptables, nftables requires no separate nat table to pre-exist — but the chain must be type nat, because that chain type is what registers the flow-binding NAT hook (nf_nat_inet_fn) rather than a plain packet filter.
Measured, not assumed. On the Fedora 44 host this note was revised on (2026-09-12), iptables --version prints:
iptables v1.8.11 (nf_tables)
The parenthesized backend name is the whole answer: this iptables binary is the nf_tables-backed compatibility front end, so every iptables -t nat command above is translated into an nftables ruleset and the two “different” languages in this section program the same objects in the same kernel subsystem. The legacy x_tables binary still exists (iptables-legacy, which prints (legacy)), and container base images in particular still ship it; the two backends maintain separate rulesets that the kernel evaluates independently, which is why mixing them on one host produces rules that are invisible to whichever tool you happen to be holding. The check is one command and it is worth making a reflex.
Uncertain
Verify: whether the same is true across the distributions a reader is likely to use in the 6.12/6.18 era. Reason: which backend
iptablespoints at is a distribution packaging decision, not a kernel fact, and only one host (Fedora 44) could be measured here. Debian and RHEL switched to thenftbackend years ago; Alpine and several minimal container images do not. To resolve: runiptables --versionon the target system and read the backend in parentheses. uncertain
Failure Modes and Hard Cases
The state NAT rides on: ctinfo and the IPS_* bits
Two small enumerations carry almost all of NAT’s decision-making, and both are worth having in front of you when reading a conntrack -L dump or a stack trace.
The first is enum ip_conntrack_info (include/uapi/linux/netfilter/nf_conntrack_common.h:7–27), the per-packet answer to “how does this packet relate to a flow?” It is deliberately arithmetic rather than a flat list:
ctinfo value | Numeric | Meaning | CTINFO2DIR() | What NAT does |
|---|---|---|---|---|
IP_CT_ESTABLISHED | 0 | Part of an established connection, original direction | IP_CT_DIR_ORIGINAL | Replay the stored binding forward |
IP_CT_RELATED | 1 | Related to an existing connection (an expectation, or an ICMP error), original direction | IP_CT_DIR_ORIGINAL | Translate per the master flow, or fix up the embedded header for ICMP errors |
IP_CT_NEW | 2 | First packet of a flow, or a retransmission of it. Only ever original direction | IP_CT_DIR_ORIGINAL | This is the packet that creates the binding via nf_nat_setup_info() |
IP_CT_IS_REPLY | 3 | Not a state — the threshold. “>= this indicates reply direction” | — | — |
IP_CT_ESTABLISHED_REPLY | 3 | IP_CT_ESTABLISHED + IP_CT_IS_REPLY | IP_CT_DIR_REPLY | Replay the binding inverted (the statusbit ^= IPS_NAT_MASK line) |
IP_CT_RELATED_REPLY | 4 | IP_CT_RELATED + IP_CT_IS_REPLY | IP_CT_DIR_REPLY | as above |
(no IP_CT_NEW_REPLY) | — | The header comments it explicitly: “No NEW in reply direction” | — | A flow cannot be born from its reply side; that is what makes direction meaningful |
Per-packet conntrack state. What it shows: the enum encodes direction as an offset of 3 rather than as a separate field, so CTINFO2DIR(ctinfo) is a comparison against IP_CT_IS_REPLY, not a struct member lookup. The insight to take: IP_CT_NEW is the only state in which NAT rules are evaluated at all. Everything else replays. If a rule change does not seem to take effect, it is because the flows it should affect are not NEW — conntrack -F (flush) is the crude fix and the reason NAT rule changes do not apply retroactively.
The second is the IPS_* status bit set on the nf_conn itself, of which six concern NAT directly:
| Bit | Name | Value | Set by / meaning |
|---|---|---|---|
| 4 | IPS_SRC_NAT | 0x10 | Source NAT is in effect for this flow. Set in nf_nat_setup_info() the moment the reply tuple is altered |
| 5 | IPS_DST_NAT | 0x20 | Destination NAT is in effect |
| — | IPS_NAT_MASK | 0x30 | IPS_SRC_NAT | IPS_DST_NAT — the value XORed against on the reply direction |
| 7 | IPS_SRC_NAT_DONE | 0x80 | The source binding has been computed; nf_nat_initialized() tests this, not IPS_SRC_NAT, to decide whether to run the rules again |
| 8 | IPS_DST_NAT_DONE | 0x100 | as above, for destination |
| 6 | IPS_SEQ_ADJUST | 0x40 | A helper changed the payload length, so TCP sequence numbers need adjusting on every subsequent packet. Set by nf_nat_setup_info() when the flow has a helper |
| 12 | IPS_NAT_CLASH | 0x1000 | Re-purposed from the obsolete IPS_UNTRACKED bit: “tags a conntrack entry that clashed with an existing entry on insert” |
The NAT-relevant status bits. What it shows: there are two bits per direction — “NAT applies” and “NAT has been set up” — and they are not redundant. IPS_SRC_NAT says what to do to packets; IPS_SRC_NAT_DONE says whether the rules still need consulting. A flow that matched no NAT rule still gets _DONE set, via nf_nat_alloc_null_binding(), so the rules are not re-evaluated on every packet of an untranslated flow either. The insight to take: IPS_NAT_CLASH reusing bit 12 is a small archaeology lesson — IPS_UNTRACKED is marked “Obsolete and not used anymore”, and rather than grow the field the maintainers recycled the bit, guarded by #ifdef __KERNEL__ so userspace never sees the new meaning.
NAT needs conntrack — and inherits its limits
Because every binding lives on a conntrack entry, NAT inherits conntrack’s failure modes. If the conntrack table fills (nf_conntrack: table full, dropping packet), new NATed flows are dropped. If a UDP flow’s conntrack entry times out while still in use, the next packet creates a new entry and may be assigned a different source port, breaking the peer’s expectation. And any packet conntrack marks INVALID (out-of-window TCP, untrackable fragment) gets no NAT applied — nf_nat_inet_fn() returns NF_ACCEPT early if nf_ct_get() returns NULL, so such a packet leaves with its un-translated (often private) source address and is dropped upstream. Diagnose with conntrack -L and nstat//proc/net/stat/nf_conntrack. See Connection Tracking conntrack for the table-sizing and timeout details.
ALGs: NAT breaks protocols that carry addresses in their payload
NAT rewrites L3/L4 headers, but some application protocols embed IP addresses and ports inside the application payload. The textbook case is FTP in active mode: the client sends a PORT a,b,c,d,p1,p2 command containing its own IP and a port, telling the server where to open the data connection. After SNAT, that embedded private address is wrong, and the server tries to connect to an unroutable 10.x host. The fix is an Application-Level Gateway (ALG) — in Linux, a conntrack helper plus a NAT helper. RFC 2663 defines an ALG as an “application specific translation agent” that lets an application “connect to its counterpart running on a host in a different realm transparently.” The kernel’s nf_nat_ftp.c does exactly this: when conntrack’s FTP helper parses a PORT/PASV/EPRT/EPSV line, nf_nat_ftp() rewrites the embedded address/port in the payload, fixes up TCP sequence numbers for the length change (via the seqadj extension), and installs an expectation for the upcoming data connection with exp->expectfn = nf_nat_follow_master, so the data connection is NATed to match its control connection. SIP, PPTP (the GRE special-case in get_unique_tuple() keys off ct->master), and H.323 need similar helpers. ALGs are fragile, are a frequent source of CVEs, and many are disabled by default — which is exactly why modern peer-to-peer apps prefer ICE over relying on an in-network ALG.
Hairpin NAT (NAT loopback / NAT reflection)
A subtle case: an inside host tries to reach an inside server via the gateway’s public IP. The naive port-forward DNATs the destination to the internal server, but leaves the source as the inside client. The server then sees a packet from 10.0.0.5 to itself (10.0.0.6) — both on the same subnet — and replies directly to 10.0.0.5, bypassing the gateway. The client, which sent its packet to the public IP, receives a reply from 10.0.0.6 and rejects it as unsolicited; the connection hangs. The fix, hairpin NAT, is to also SNAT the hairpinned traffic to the gateway’s inside address, so the server’s reply is forced back through the gateway, which then un-translates both ends. It requires a DNAT for the forward path and a MASQUERADE/SNAT in POSTROUTING for traffic originating inside and destined to the public IP (OneUptime, NAT hairpinning). The general lesson: whenever a reply would not naturally return through the NAT device, you must SNAT the request so it does.
sequenceDiagram autonumber participant C as Inside client<br/>10.0.0.5 participant GW as Gateway<br/>inside 10.0.0.1 / outside 203.0.113.10 participant S as Inside server<br/>10.0.0.6:8080 rect rgb(255, 235, 235) Note over C,S: BROKEN: port-forward only, no hairpin SNAT C->>GW: SYN 10.0.0.5:40000 -> 203.0.113.10:80 GW->>GW: PREROUTING dnat to 10.0.0.6:8080<br/>source left as 10.0.0.5 GW->>S: SYN 10.0.0.5:40000 -> 10.0.0.6:8080 S-->>C: SYN-ACK 10.0.0.6:8080 -> 10.0.0.5:40000<br/>sent DIRECTLY, same L2 subnet Note over C: client sent to 203.0.113.10:80,<br/>reply arrives from 10.0.0.6:8080.<br/>No matching socket. RST or silent hang. end rect rgb(235, 245, 235) Note over C,S: FIXED: add POSTROUTING masquerade for hairpinned traffic C->>GW: SYN 10.0.0.5:40000 -> 203.0.113.10:80 GW->>GW: PREROUTING dnat to 10.0.0.6:8080 GW->>GW: POSTROUTING masquerade: source -> 10.0.0.1<br/>(second binding on the SAME conntrack entry:<br/>both IPS_DST_NAT and IPS_SRC_NAT set) GW->>S: SYN 10.0.0.1:40000 -> 10.0.0.6:8080 S->>GW: SYN-ACK 10.0.0.6:8080 -> 10.0.0.1:40000<br/>forced back through the gateway GW->>GW: PREROUTING: un-DNAT source, un-SNAT destination<br/>(both bits XOR-flipped for the reply direction) GW->>C: SYN-ACK 203.0.113.10:80 -> 10.0.0.5:40000 end
Hairpin NAT, broken and fixed. What it shows: the failure is not in the translation, it is in the routing — the server’s reply never traverses the gateway, so there is nothing to un-translate it. The insight to take: the fix works because a single conntrack entry can carry both IPS_SRC_NAT and IPS_DST_NAT at once; nf_nat_packet() is called at each hook and tests one bit each time, so a doubly-translated flow is not a special case in the engine at all — it is two independent bindings on one entry, each inverted on the reply. This is also why RFC 4787’s REQ-9 (“A NAT MUST support ‘Hairpinning’”) is an operator’s job on Linux and not a default: nothing in the kernel notices the topology.
The traversal problem classes, side by side
Everything that makes NAT painful for application protocols reduces to four failure classes. They are worth separating because their symptoms look similar (a connection that hangs) and their fixes are completely different.
| Class | Root cause | Symptom | Fix at the NAT | Fix at the application |
|---|---|---|---|---|
| Mapping behaviour (not endpoint-independent) | The outside addr:port a peer learns is only valid for the endpoint it learned it from | Hole punching fails; WebRTC falls back to a relay and media latency triples | Satisfy RFC 4787 REQ-1 — on Linux, do not set --random-fully if traversal matters; keep the SNAT range wide enough that find_appropriate_src() can reuse mappings | ICE with a TURN relay as fallback |
| Hairpinning (RFC 4787 REQ-9) | An inside peer addressing another inside peer by the public address gets a reply that bypasses the NAT | Works from outside, hangs from inside; “it works on cellular but not on the office Wi-Fi” | Add the extra SNAT rule shown above, or split-horizon DNS so inside clients resolve the inside address | ICE, which will find the direct host-candidate pair anyway |
| Port exhaustion | Finite (public IP, protocol, destination) port space; get_unique_tuple() gives up after its bounded search | New connections stall or fail under load; insert_failed rises in conntrack -S | Add public addresses to the SNAT range (find_best_ips_proto() spreads across them), shorten conntrack timeouts, or — per RFC 6888 REQ-4, “a CGN MUST support limiting the number of external ports” per subscriber — cap per-client usage so one client cannot starve the rest | Connection pooling; fewer, longer-lived connections |
| ALG dependence | The protocol carries addresses in its payload, which L3/L4 rewriting does not touch | Control connection works, data connection never opens (classic active FTP, SIP, H.323, PPTP) | Load the conntrack + NAT helper for the protocol and accept the CVE surface and the expectation-table cost | Use a protocol that does not embed addresses, or do explicit traversal |
The four NAT traversal problem classes. What it shows: only one of the four (ALG dependence) is about the NAT failing to rewrite something; the other three are about the NAT rewriting correctly and the topology or the mapping policy being wrong. The insight to take: these are ranked by how well they can be fixed from the endpoint. ALG problems and hairpinning are largely solvable by the application; mapping behaviour forces a relay when the NAT will not cooperate; and port exhaustion cannot be fixed from the endpoint at all — it is the one class where the operator must act.
Loss of end-to-end addressing
NAPT’s structural cost is that “this solution has the disadvantage of taking away the end-to-end significance of an IP address” (RFC 3022). Inbound connections to an inside host are impossible without an explicit port-forward; protocols that assume a host knows its own externally-visible address break; and debugging is harder because the address a packet claims to be from is not where it originated. This is the architectural tax NAT pays for address conservation.
The Motivation: IPv4 Exhaustion and Private Addressing
NAT exists because IPv4 has only ~4.3 billion 32-bit addresses, far fewer than the number of hosts. RFC 1918 reserved three private address blocks — 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 — for hosts that “do not require access to hosts in other enterprises or the Internet at large,” with the explicit goal “to conserve the globally unique address space by not using it where global uniqueness is not required.” NAPT is the mechanism that lets an entire RFC 1918 network reach the Internet through one (or a few) public address(es): the classic SOHO scenario from RFC 2663, where users “have multiple Network nodes in their office… but have a single IP address assigned to their remote access router.” NAT bought the IPv4 Internet decades of extra life; IPv6, with a 128-bit address space, removes the need for NAT but has not removed its use (organizations still NAT IPv6 for policy reasons, though it is discouraged).
Container and Cloud NAT: The MASQUERADE That Powers Pod Egress
The most-deployed NAT rule on Earth today is almost certainly a single MASQUERADE for container egress. A container/pod sits in its own network namespace with a private address (e.g. 172.17.0.x for Docker’s default bridge, or a per-node pod CIDR for Kubernetes). When a pod talks to the Internet, its packets leave the node’s physical interface — but the cloud fabric “will reject” a packet whose source is the pod IP, because “traffic to external addresses must come from a known machine address” (Kubernetes ip-masq-agent). So the node SNATs/masquerades the pod’s source to the node’s own IP on the way out:
- Docker installs, for each bridge network, a POSTROUTING
MASQUERADErule of the form-s 172.17.0.0/16 ! -o docker0 -j MASQUERADE— traffic from the bridge subnet leaving via a different interface is masqueraded to that interface’s address. (The exact subnet/interface names are Docker-version- and config-dependent; inspect withiptables -t nat -L POSTROUTINGon a Docker host to confirm.) - Kubernetes does the same via the ip-masq-agent (or kube-proxy / the CNI plugin). The agent “configures iptables rules to hide a pod’s IP address behind the cluster node’s IP address.” It is configured with
nonMasqueradeCIDRs— the RFC 1918 ranges plus the cluster/pod CIDRs — so that intra-cluster traffic keeps its real pod source (NetworkPolicy and service routing need it) while only genuinely-external traffic is masqueraded. kube-proxy implements this with a packet mark (0x4000by default): rulesKUBE-MARK-MASQset the mark, and a singleKUBE-POSTROUTINGrule masquerades all marked packets.
The mechanism is exactly nf_nat_masquerade_ipv4() from nf_nat_masquerade.c: it calls inet_select_addr(out, nh, RT_SCOPE_UNIVERSE) to read the egress interface’s current primary address, builds a single-address range, and hands it to nf_nat_setup_info() with NF_NAT_MANIP_SRC. It also records nat->masq_index = out->ifindex on the entry, so that when that interface goes down (NETDEV_DOWN), a background worker walks the conntrack table and tears down every masqueraded flow that used it (device_cmp() / nf_nat_masq_schedule()) — the kernel-level reason masquerade “forgets connections when the interface goes down.” Both the iptables MASQUERADE target (xt_MASQUERADE.c) and the nftables masquerade statement (nft_masq.c) call this same function, restricted to POST_ROUTING. The orchestration view — how a CNI plugin and kube-proxy program these rules — lives in Kubernetes MOC; the kernel mechanism is here.
Dating the NAT Engine — What Changed and When
Netfilter NAT has a reputation for being finished, and it is not. The engine described here changed materially in four of the last six releases before v6.12, and two of those changes alter behaviour that operational advice depends on. Each row below was established by existence-checking — fetching the same file at successive release tags and recording whether the symbol is present — rather than from a changelog or from memory, and all checks were made on 2026-09-12.
| Change | First release | How it was established | Why it matters |
|---|---|---|---|
nf_ct_resolve_clash() — absorb an insert race instead of dropping | ≤ v4.18 | Present at v4.18 (2 occurrences in nf_conntrack_core.c); not bisected further back | The original mitigation for the “five-second DNS” class of bug |
Random source-port search start for all SNAT (maniptype != NF_NAT_MANIP_DST) | ≤ v4.19 | Present at v4.19, v5.0, v5.1, v5.4, v5.10, v5.15, v6.1, v6.6, v6.12 | Means --random-fully’s effect is not “make the port random” |
nf_ct_resolve_clash_harder() — insert the loser in the reply direction only | v5.6 | 0 occurrences at v5.5, 3 at v5.6 | Second-chance clash resolution; without it, more races become packet drops |
NF_NAT_RANGE_NETMAP — 1:1 prefix mapping | v5.8 | Absent from include/uapi/linux/netfilter/nf_nat.h at v5.7 | The netmap statement in nftables |
Conntrack kfuncs callable from BPF (bpf_skb_ct_lookup, bpf_xdp_ct_lookup) | v5.18 | net/netfilter/nf_conntrack_bpf.c has 0 occurrences of bpf_xdp_ct_lookup at v5.17, 5 at v5.18 | An XDP or tc-BPF program can now consult the same flow table NAT is built on |
nf_nat_used_tuple_harder() — evict TIME_WAIT TCP entries in the last 32 attempts | v6.5 | 0 occurrences at v6.4, 2 at v6.5 | Port-exhaustion hardening for TCP-churn workloads |
nf_nat_used_tuple_new() — reverse-direction clash check when reusing the original tuple | v6.12 | 0 occurrences at v6.11, 3 at v6.12 | Brand new at this note’s pinned release; pre-6.12 kernels do not have it |
nf_conntrack_helper sysctl removed (auto-helper assignment gone) | v6.0 | NF_SYSCTL_CT_HELPER / sysctl_auto_assign_helper present in nf_conntrack_standalone.c at v5.19, absent at v6.0 | The common “disable ALGs” hardening step no longer applies; helpers are explicit-only |
The NAT engine’s recent history. What it shows: three of the seven entries landed in the 6.x series, and the two most recent are both about the same problem — running out of unique tuples. The insight to take: “Linux NAT” is not one behaviour. Advice written against a 5.4 kernel about clash resolution, and advice written against a 6.4 kernel about port exhaustion, are both describing systems that no longer exist. Pin the kernel version when you write down a NAT tuning recommendation.
Verifying This Note Against a Live System — and What Could Not Be Run
Everything above is read from pinned v6.12 source. This section records what could and could not be checked against a running machine, and names the privilege each refusal required — so a reader can tell derived facts from measured ones.
The host: Fedora 44, kernel 7.1.13-200.fc44.x86_64, 32 CPUs, 125 GiB RAM, unprivileged uid in the wheel and docker groups with no elevated capabilities in this session. Twenty-two Docker bridge networks and six veth pairs are up, so the box is genuinely doing masquerade NAT for container egress — the exact workload the container section above describes. Note that 7.1 is not the 6.12 tree this note is pinned to. A live 7.1 host can corroborate the shape of an interface and the value of a default; it is not evidence about v6.12 source, and no claim here treats it as such.
Readable, and read:
| Path | Value on this host | Cross-check |
|---|---|---|
/proc/sys/net/netfilter/nf_conntrack_max | 262144 | Matches the doc’s “set to nf_conntrack_buckets by default” |
/proc/sys/net/netfilter/nf_conntrack_buckets | 262144 | The doc’s hard ceiling — “never fewer than 1024 and never more than 262144” — reached because 125 GiB ÷ 16384 far exceeds it |
/proc/sys/net/netfilter/nf_conntrack_count | 253 at time of reading | Agrees with the entries column of /proc/net/stat/nf_conntrack |
/proc/sys/net/netfilter/nf_conntrack_udp_timeout | 30 | Documented default 30 s; below RFC 4787 REQ-5’s two-minute floor |
/proc/sys/net/netfilter/nf_conntrack_udp_timeout_stream | 120 | Documented default 120 s; exactly REQ-5’s floor |
/proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established | 432000 | 5 days — the well-known default that makes idle-flow accumulation a real concern |
/proc/sys/net/netfilter/nf_conntrack_tcp_timeout_time_wait | 120 | The window nf_nat_used_tuple_harder() reclaims from |
/proc/sys/net/netfilter/nf_conntrack_icmp_timeout | 30 | ICMP query-ID mappings expire in 30 s |
/proc/sys/net/netfilter/nf_conntrack_checksum | 1 | Checksum verification on |
/proc/sys/net/ipv4/ip_local_port_range | 32768 60999 | The socket ephemeral range — deliberately narrower than NAT’s 1024–65535 default bucket, which is why a NAT gateway has more source ports to hand out than a local socket does |
/proc/net/stat/nf_conntrack | see the counter table above | 32 per-CPU rows plus a header |
iptables --version | iptables v1.8.11 (nf_tables) | Resolves the backend question for this host |
One absence is itself a finding: /proc/sys/net/netfilter/nf_conntrack_helper does not exist on this kernel (No such file or directory), while its neighbours in the same directory do. That is not a Fedora quirk — the sysctl was removed in v6.0. Existence-checked in net/netfilter/nf_conntrack_standalone.c, the NF_SYSCTL_CT_HELPER table entry with .procname = "nf_conntrack_helper" and its cnet->sysctl_auto_assign_helper binding are present at v5.19 and absent at v6.0, v6.1 and v6.12; the v6.12 Documentation/networking/nf_conntrack-sysctl.rst does not mention helpers at all. So the widely-repeated hardening advice “set net.netfilter.nf_conntrack_helper=0 to stop automatic ALG attachment” is describing a knob that no longer exists on any kernel from 6.0 onward — on those kernels helpers are never auto-assigned, and must be attached explicitly by a rule (-j CT --helper ftp in iptables, a ct helper object in nftables). Given the CVE history of ALG code, explicit-only is the right default; the point here is that the old advice is now a no-op that silently succeeds, because sysctl -w on a nonexistent key errors while sysctl -p on a config file may not.
Refused, and by what:
| Attempt | Result | The privilege that would fix it |
|---|---|---|
nft list ruleset | Operation not permitted (you must be root), then netlink: Error: cache initialization failed | CAP_NET_ADMIN — the nftables netlink socket is privileged even for reads |
iptables-save | Could not fetch rule set generation id: Permission denied (you must be root) | CAP_NET_ADMIN |
head /proc/net/nf_conntrack — the per-flow tuple dump | Permission denied | CAP_NET_ADMIN in the owning namespace. Note the aggregate file /proc/net/stat/nf_conntrack in the same tree is world-readable, which is why the counter table above exists and a per-flow table does not |
conntrack -L / conntrack -S | Command not found | Install conntrack-tools (Fedora: dnf install conntrack-tools), then run under CAP_NET_ADMIN |
ip link set dev lo xdpgeneric off (the XDP cross-check) | RTNETLINK answers: Operation not permitted | CAP_NET_ADMIN |
So: no NAT rule was installed, no ruleset was read, and no per-flow conntrack table appears in this note. The rule syntax in the configuration section is from the iptables-extensions(8) manual and the nftables wiki; the hook priorities are from the UAPI header; the counters are from the one aggregate file an unprivileged uid may read. Where a number here could only have come from a privileged tool, it is absent.
Alternatives and When to Choose Them
- SNAT vs MASQUERADE. Identical effect (rewrite source to an outside address). Use SNAT with
--to-sourcewhen the egress address is static — it is cheaper, since the address is fixed at rule-load time. Use MASQUERADE only when the address is dynamic (DHCP, PPPoE, or a cloud interface whose address you do not want to hard-code), accepting the per-packetinet_select_addrlookup and the flush-on-link-down behavior. - DNAT vs REDIRECT.
DNAT --to-destinationsends traffic to an arbitrary host/port.REDIRECTis the special case “to this box” — it rewrites the destination to the incoming interface’s primary address (or127.0.0.1for locally-generated packets), the standard transparent-proxy pattern. - iptables NAT vs nftables NAT. Same kernel engine; prefer nftables for new work (one unified ruleset, atomic updates, maps/sets for efficient many-target DNAT). See nftables and the nf_tables Subsystem vs iptables and x_tables.
- Netfilter NAT vs IPVS / XDP load balancing. For load-balancing DNAT at high scale, IPVS (a dedicated L4 balancer) or an XDP program (Katran/Cilium-style, acting before an skb exists) avoid per-connection conntrack cost. Netfilter NAT is the general-purpose, stateful choice; XDP is the line-rate, stateless-DNAT choice.
- NAT vs end-to-end (IPv6 / NAT traversal). Where end-to-end reachability matters (peer-to-peer, WebRTC), the answer is not more NAT but to traverse it — ICE — or to deploy IPv6 and skip NAT entirely.
Laid out against each other, the four ways to rewrite an address in Linux differ mainly in where the state lives:
| netfilter NAT | IPVS | XDP / eBPF | Payload rewrite (pedit, ip saddr set) | |
|---|---|---|---|---|
| Where the mapping is stored | The conntrack entry’s reply tuple | IPVS’s own connection table | A BPF map you designed, or nothing | Nowhere |
| Reverse translation | Automatic, by tuple inversion | Automatic | You write it | You write it |
| Runs at | Netfilter hooks, on an sk_buff | LOCAL_IN / FORWARD, on an sk_buff | Driver NAPI poll, before any sk_buff | tc or a netfilter hook, on an sk_buff |
| Per-flow memory | One struct nf_conn plus any extensions (nf_conn_nat, seqadj, accounting), bounded by nf_conntrack_max | One IPVS connection entry | Whatever your map holds, or zero | Zero |
| Fails when | Table full, or no unique tuple available | Table full | Map full (your policy) | Never — no state to exhaust |
| Rule expressiveness | Very high (full match language) | Scheduling algorithms, not general matching | Arbitrary verified C | Static field rewrites |
| Throughput ceiling | Lowest — 1.8 Mpps/core measured with conntrack in the XDP paper | Middle — 1.2 Mpps/core for the paper’s load-balancer test | Highest — 5.2 Mpps/core for the same test with Katran | High, but not a load balancer |
| Choose it when | You need policy, state and correctness by default | You are load-balancing L4 at moderate scale | Per-packet cost is the bottleneck and the policy fits a verified program | You want a deterministic 1:1 rewrite with no state |
Four ways to rewrite an address, compared. What it shows: the column that predicts everything else is “where the mapping is stored” — automatic reverse translation, per-flow memory, and the port-exhaustion failure mode are all consequences of keeping a table. The insight to take: netfilter NAT is not slow because it is badly written; it is slow because it is doing something the alternatives refuse to do, namely remembering every flow so that the reverse direction is free and correct. The throughput figures are from the CoNEXT ‘18 XDP paper’s 4.18-era test rig (64-byte packets, single core) and are comparative, not absolute.
Production Notes
The two failure signatures worth memorizing: a dropped first packet under load that turns out to be nf_conntrack: table full (size net.netfilter.nf_conntrack_max, watch nf_conntrack_count), and a port-exhaustion symptom where a busy SNAT gateway with few public IPs runs out of the ~64k source ports per (public-IP, protocol, destination) tuple and new connections stall while get_unique_tuple() burns its 128 attempts. Mitigations: add more public addresses to the SNAT range (the engine spreads load across them via find_best_ips_proto()), or shorten conntrack timeouts so freed ports recycle faster.
The most famous NAT-related production incident is the Kubernetes ~5-second DNS timeout, traced by Weaveworks to a kernel SNAT-insertion race in conntrack (Weave issue #3287; “Racy conntrack and DNS lookup timeouts”, 2018). When a pod fires its parallel A and AAAA UDP DNS queries at nearly the same instant, both flows reach the masquerade rule, both run nf_nat_setup_info() on different CPUs, and they can pick the same translated source tuple — one then loses the __nf_conntrack_confirm() race and is silently dropped, so the resolver waits out its ~5s retry timer. The symptom is a rising insert_failed counter in conntrack -S. The commonly-cited fix is to set the NF_NAT_RANGE_PROTO_RANDOM_FULLY flag on the masquerade rule (--random-fully in iptables, fully-random in nftables), which spreads the chosen source ports with a PRNG and makes the collision far less likely; NodeLocal DNSCache (forcing TCP or a local cache) is the orthogonal mitigation.
But that is a 2018 story, and the kernel has since moved underneath it — the correction matters. Two things in this note’s own material qualify the received advice:
--random-fullyis not what makes the port choice random. As established above,nf_nat_l4proto_unique_tuple()already drawsoff = get_random_u16()for every source-NAT flow, with or without the flag, and has done since at least v4.19. What--random-fullyactually changes is that it disablesget_unique_tuple()’s step 1 — the port-preservation and mapping-reuse path — which both spreads flows further apart in the port space and costs you RFC 4787 REQ-1 mapping behaviour. The advice is not wrong, but the mechanism usually given for it is.- The kernel absorbs most of these clashes rather than dropping the packet.
nf_ct_resolve_clash()(nf_conntrack_core.c:1163) exists precisely because of this failure mode, and its docstring names the DNS symptom without naming DNS: “The major problem with such packet drop is the extra delay added by the packet loss — it will take some time for a retransmit to occur (or the sender to time out when waiting for a reply). This function attempts to handle the situation without packet drop.” It does so two ways: if the two entries are mergeable, the loser is discarded and the packet is re-associated with the winner (__nf_ct_resolve_clash(), incrementingclash_resolve); failing that,nf_ct_resolve_clash_harder()inserts the new entry in the reply direction only, with a fixed timeout, so original-direction packets keep matching the old entry. The first path predates v4.19; the_harderpath was added in v5.6 (absent at v5.5).
The critical qualification is that clash resolution is opt-in per L4 protocol, via l4proto->allow_clash, and at v6.12 exactly two protocols set it: UDP and UDP-Lite (nf_conntrack_proto_udp.c:285 and :307). TCP, ICMP and ICMPv6 do not — checked file by file. That is the right design (silently merging two TCP flows would be a correctness bug, whereas two UDP datagrams of the same tuple are indistinguishable anyway), and it is also exactly why the Kubernetes DNS case benefits: DNS is UDP.
This is observable. On the host this note was revised on — a developer workstation running 22 Docker bridge networks, each with its own MASQUERADE rule, so genuinely NAT-heavy — summing /proc/net/stat/nf_conntrack across all 32 CPU lines gives:
| Counter | Value on the revision host | What it means |
|---|---|---|
entries (global) | 253 | Live conntrack entries, matching /proc/sys/net/netfilter/nf_conntrack_count |
clashres (clash_resolve) | 3,294 | Insert races that occurred and were absorbed without dropping a packet |
insert_failed | 0 | Races that could not be resolved and cost a packet |
drop | 0 | Packets dropped by conntrack |
invalid | 164 | Packets conntrack could not associate with a flow — these get no NAT applied |
nf_conntrack_max / nf_conntrack_buckets | 262,144 / 262,144 | Both at the documented ceiling: “the default size is calculated by dividing total memory by 16384… never more than 262144 buckets”, and this host’s 125 GiB is far past the clamp |
Live conntrack statistics from a NAT-heavy workstation. What it shows: more than three thousand insert races, and not one of them dropped a packet. The insight to take: the clashres-to-insert_failed ratio is the diagnostic to watch, not insert_failed alone — a high clashres with zero insert_failed means the kernel’s clash resolution is doing its job, whereas any non-zero insert_failed on UDP-heavy traffic is the signature of the five-second-DNS class of bug. On a kernel older than 5.6, or for a TCP workload where allow_clash is false, the same races land in insert_failed instead.
For completeness on the separate hardening path: the TCP-only nf_nat_used_tuple_harder() TIME_WAIT-eviction logic added in v6.5, and nf_nat_used_tuple_new()’s reverse-direction check added in v6.12, address port exhaustion under TCP churn, not this UDP insert race. They are different mechanisms for different failure modes that happen to live in the same file.
When debugging NAT, conntrack -L -j shows the live bindings (original and reply tuples side by side), which is the single most useful diagnostic — it shows you precisely what mapping the engine chose. Note that both conntrack -L and reading /proc/net/nf_conntrack directly require privilege: on the revision host, an unprivileged read of /proc/net/nf_conntrack returns Permission denied, while the aggregate counters in /proc/net/stat/nf_conntrack and the tunables under /proc/sys/net/netfilter/ are world-readable. When you only have the aggregates, the table above is what you can still learn.
See Also
- Built on: Connection Tracking conntrack — the flow table every NAT binding is stored on; NAT cannot work without it
- Built on: The Netfilter Framework and Hooks — the PRE/POST_ROUTING hook points where NAT runs
- Requested by: iptables and x_tables — the legacy
-t natfrontend (SNAT/DNAT/MASQUERADE targets) - Requested by: nftables and the nf_tables Subsystem — the modern
type natchains andsnat/dnat/masqueradestatements - Routing interplay: The Routing Subsystem and FIB — DNAT runs before the routing decision so the rewritten destination is routed; SNAT runs after
- Carries the packet: struct sk_buff — the buffer whose headers
nf_nat_manip_pkt()rewrites - The other “NAT”: NAT Traversal — the application-side STUN/TURN/ICE problem of getting through someone else’s NAT (distinct from this kernel mechanism)
- Consumers: Network Namespaces, Kubernetes MOC — container/pod egress masquerade
- The layer below: XDP Express Data Path — the boundary note. XDP runs before an
sk_buffexists and therefore before conntrack has attached anything to the packet, so an XDP program has no NAT binding available to it; since v5.18 it may query the same conntrack table through thebpf_xdp_ct_lookup()kfunc, which is the only place these two notes’ mechanisms meet. Stateless, line-rate DNAT in XDP is the alternative to this note’s stateful engine, not an accelerator for it - The layer above: The Network Receive Path — where the
sk_buffNAT rewrites comes from, and where aPREROUTING-translated packet continues after the rewrite - Generic Receive Offload — runs before the netfilter hooks, so NAT sees coalesced segments, not wire packets
- Parent MOC: Linux Networking Stack MOC · ACROSS: Networking and Protocols MOC