winnat.sys WinNatTranslateTcpHeader TCP sequence randomization
Windows NAT (winnat.sys) is the kernel component that translates
between a private internal address space and a public external one.
Each flow through the NAT is represented by a session entry — a
structure that records the (internal src, internal port) ↔ (external
src, external port) mapping, the transport protocol, and the in-progress
state needed to rewrite packets in both directions. Outbound packets
get their source address and port rewritten to the public side; inbound
packets get the reverse treatment so the demux lands on the right
internal host.
For TCP, the translation has historically been address-and-port only. The TCP sequence number, the acknowledgement number, the timestamp options, the IP ID — all of these were preserved verbatim across the translation. That matches the minimum a NAT needs to do for the network to function: the four-tuple is what endpoints use to demultiplex a flow, and rewriting just the addresses and ports keeps the endpoints’ sequence-number machinery consistent across the boundary.
The cost of that minimal rewrite is a side channel. A NAT that rewrites the routing fields but leaves the transport-layer identifiers alone effectively mirrors the internal host’s identity into the public observable state. The most important of those identifiers for TCP is the sequence number — specifically the initial sequence number (ISN) the internal host chose when it opened the connection. The public side of the flow sees the same sequence progression the internal host generated, just from a different source address. Anyone who can observe one NATed flow learns something about the ISN distribution of the internal host, and that something is exactly what an off-path attacker needs to attack sibling flows.
Why the unmodified sequence number is a problem
Off-path TCP injection — the classic attack where an attacker who can spoof source IPs but cannot observe the traffic still injects a segment into a victim connection — depends on guessing two numbers: the sequence number of the connection under attack, and (for the data to land) the peer’s receive window. The sequence number is the hard part. The attack collapses to a blind guess if the ISN is unpredictable, and becomes practical if the ISN is predictable or correlated with something observable.
A non-randomising NAT is precisely the correlation oracle an off-path attacker wants. Suppose an internal host H opens two connections, C1 and C2, both translated through the same NAT. The public side of C1 and the public side of C2 both reveal sequence numbers drawn from H’s ISN generator. An attacker who can observe (or brute-force) C1’s sequence progression on the public side learns the distribution of H’s ISNs, and can then predict C2’s sequence space well enough to inject TCP segments into C2 — purely by sending crafted segments to C2’s public endpoint, without ever observing C2 directly. The NAT rewrote the addresses but handed the attacker the correlation they needed.
This is not a memory-safety bug — no kernel pointer is read incorrectly, no buffer is overrun. It is a missing defence, and the defence is the same one every modern TCP stack applies to its own ISNs: an unpredictable per-connection perturbation that decouples the publicly observed value from any internal generator the attacker could model.
The pre-patch translate path
The TCP-layer translate (WinNatTranslateTcpHeader) is invoked after
the IP-layer translate has already swapped the addresses. It receives
the in-packet TCP header, the new port to install (source or
destination depending on direction), a precomputed checksum fixup for
that port swap, and a pair of direction flags. Pre-patch it rewrote
exactly one 16-bit port field and folded the port change into the
checksum; the sequence and acknowledgement numbers were passed through
untouched:
/* WinNatTranslateTcpHeader — pre-patch
* hdr = in-packet TCP header
* new_port = port to install (source or dest, per direction)
* csum_arg2 = precomputed checksum fixup for the port swap
* forward = nonzero for outbound (selects Seq vs Ack in the patch)
* do_csum = update the checksum field
* dst_sel = nonzero to rewrite DstPort, else SrcPort */
/* 1. Rewrite one port field; the other stays as the endpoints sent it. */
if (dst_sel == 0) {
hdr->SrcPort = new_port; /* +0x00 */
} else {
hdr->DstPort = new_port; /* +0x02 */
}
/* 2. Fold the precomputed port-change fixup into the checksum. */
if (do_csum != 0) {
uint32_t sum = (uint16_t)csum_arg2
+ (uint16_t)hdr->Checksum; /* +0x10 */
sum = (sum >> 16) + (sum & 0xffff);
sum = (sum >> 16) + (sum & 0xffff);
hdr->Checksum = (uint16_t)sum; /* +0x10 */
}
/* hdr->SeqNumber (+0x04) and hdr->AckNumber (+0x08): untouched. */
Session creation allocated the session entry from a lookaside list, zeroed it, and populated the binding, protocol, direction, transport addresses and timer value. Nothing in that initialisation produced a per-session secret:
/* WinNatCreateSessionEntry — pre-patch (field init only) */
session->RefCount = 1; /* +0x00 */
session->Binding = binding; /* +0x50 */
session->Protocol = protocol; /* +0x78 (IPPROTO_TCP=6) */
session->TimerValue = compute_timer(...); /* +0x88 */
KeInitializeSpinLock(&session->Lock); /* +0xf0 */
session->Direction = (uint8_t)direction; /* +0x108 */
session->Instance = instance; /* +0x128 */
/* session->SeqNumberDelta (+0x100): left at zero from the memset */
The entire public-side sequence progression of every NATed TCP flow is the internal host’s own sequence progression. The NAT does not introduce any uncertainty the attacker would have to overcome.
The patch — perturb the sequence per session
The patch installs an unpredictable 32-bit delta on each TCP session at creation time and adds that delta to the sequence number on every outbound translate, folding the change into the existing checksum update:
/* WinNatCreateSessionEntry — patched section
* Gated by the patch flag AND an instance-level enable bit. */
if (patch_enabled && instance->PerturbTcpSeq != 0) {
uint32_t delta = 0;
if (BCryptGenRandom(instance->RandomAlgHandle, /* instance +0x278 */
(uint8_t *)&delta, sizeof(delta), 0) >= 0) {
session->SeqNumberDelta = delta; /* +0x100 */
}
}
The delta is read back at translate time by the transport-layer
dispatcher (WinNatTranslateTransportHeader) and passed to
WinNatTranslateTcpHeader as the trailing argument. The patched
function gates the whole perturbation block on the same flag, then
applies the delta forward to SeqNumber on outbound packets and in
reverse to AckNumber on inbound packets that carry the ACK flag:
/* WinNatTranslateTcpHeader — patched
* delta = session->SeqNumberDelta, fetched by the caller */
/* 0. Per-session sequence perturbation (the patch). The patch gate is
* re-checked here; if the patch is disabled, or the session has no
* delta (created while the patch was off), translate falls through
* to the plain port-rewrite path below. */
if (patch_enabled && delta != 0 && dst_sel == 0) {
uint32_t old_val;
uint32_t new_val;
if (forward != 0) {
/* outbound: shift the public-side Seq by +delta */
old_val = ntohl(hdr->SeqNumber); /* +0x04 */
new_val = old_val + delta;
hdr->SeqNumber = htonl(new_val); /* +0x04 */
} else if ((hdr->Flags & 0x10) != 0) { /* +0x0d, TH_ACK */
/* inbound: a peer ACK references the perturbed Seq, subtract
* the delta so the internal host sees its own progression. */
old_val = ntohl(hdr->AckNumber); /* +0x08 */
new_val = old_val - delta;
hdr->AckNumber = htonl(new_val); /* +0x08 */
} else {
goto skip_perturb; /* SYN/RST/FIN: no fold */
}
if (do_csum != 0) {
/* incremental one's-complement update for a 32-bit field
* change from old_val to new_val (in network byte order). */
uint32_t sum = (uint16_t)(old_val >> 16)
+ (uint16_t)(old_val & 0xffff)
+ (uint16_t)(~new_val >> 16)
+ (uint16_t)(~new_val & 0xffff)
+ (uint16_t)hdr->Checksum; /* +0x10 */
sum = (sum >> 16) + (sum & 0xffff);
sum = (sum >> 16) + (sum & 0xffff);
hdr->Checksum = (uint16_t)sum; /* +0x10 */
}
skip_perturb: ;
}
/* 1. and 2. port rewrite + port-change checksum fold: as pre-patch. */
The reverse direction (inbound) translates the acknowledgement field by
the inverse of the same delta, so the endpoints remain consistent with
each other: the internal host still sees its own sequence progression,
the external peer sees the perturbed progression, and the NAT converts
between the two at each translate. When the flow ends the session entry
is returned to its lookaside list (WinNatLibCleanupSession →
PplFreeToLookasideList); the delta is just a field inside the session
allocation, so it dies with the session — exactly the lifetime the
perturbation should have.
The principle is randomise any field the protocol uses to authenticate packet membership. The four-tuple is rewritten because that is what the NAT exists to do. The sequence number is also used to authenticate packet membership — that is its job in TCP — and a NAT that touches one but not the other is leaking the internal host’s sequence distribution into the public observable state. The fact that this lands as part of a monthly security patch — and that the inbound/outbound translate paths and the session-entry allocation all change in lockstep — is the signal that the vendor treats it that way too.
The full path this closes, end to end:
flowchart TD
A["internal host H opens C1 and C2 through the same NAT"] --> B["NAT rewrites address and port only, the sequence number passes through"]
B --> C["off-path attacker observes the public side of C1, learns the H ISN distribution"]
C --> D["attacker crafts TCP segments to the public side of C2, guessing the C2 sequence from the H distribution"]
D --> E["without per-session perturbation the guess lands inside the C2 receive window, off-path segment injection, hijack or data injection"]
Reachability requires that the attacker can send traffic to the public endpoint of a NATed flow and can observe (or brute-force) the public side of at least one sibling flow from the same internal host. The patched per-session delta makes each flow’s public-side sequence progression independent of every other’s, so observing C1 no longer informs C2.
A NAT that rewrites addresses but not sequence numbers leaks the internal host’s ISN. Any translation that preserves a field an attacker can use to correlate flows — sequence numbers, timestamps, IP-ID — is a side channel, and the fix is to perturb the rewritten field per session so each public-side flow is statistically independent of every other. Per-session, not per-packet, is the right lifetime: a fresh delta per packet would break the protocol (the peer would see jittering sequence numbers), and a single instance-wide delta would still correlate sibling flows. The delta has to live and die with the session so each flow is independently unpredictable but internally consistent. And the broader lesson — defence-in-depth changes are worth recording even when there is no crash; a patch that adds a missing randomisation, a missing access check, or a missing bound is fixing a real weakness, and the absence of a memory-corruption primitive does not make it non-security. The question to ask is “what does an attacker learn from the unmodified behaviour?”, not “does this dereference a bad pointer?”.