Skip to content

TCP timestamp and window-scaling fingerprints across operating systems

· 22 min read
Copyright: MIT
The token mss,sok,ts,nop,ws as a monospace wordmark with a single orange underline under ts and a small grey TCP options subtitle

A TCP SYN packet carries, at most, forty bytes of options after its fixed twenty-byte header. There is not much room in there. A maximum segment size, a window scale shift, a flag that says selective acknowledgment is welcome, a timestamp, and a few no-op bytes to pad the whole thing out to a multiple of four. None of it is secret. All of it is functional, every field exists to make the connection faster or more reliable. And yet the exact contents of those forty bytes, and more importantly the order the options appear in, are stable enough per operating system that a passive observer can read them off the first packet of a connection and name the OS that sent it, often down to a kernel-version range, without sending a single byte back.

That is the trick this post is about. RFC 793 never said which order TCP options have to appear in, so every stack picked its own, and those choices ossified into the kernel source decades ago. The question is which fields carry the signal, how they are encoded on the wire, why the option layout turns out to be a stronger discriminator than any single value, and what happened to the one field that used to leak something genuinely sensitive: the timestamp, which for years let anyone on the path estimate how long your machine had been running and, worse, track it across IP addresses by its clock skew.

The sections below run roughly in order of how a fingerprint reads the packet. First the SYN option fields and how each is laid out in bytes. Then the option layout itself, the ordering and padding that p0f and nmap both encode as a short string, with concrete per-OS examples. Then the window scale shift count and what window size tells you. Then the timestamp option in depth: the uptime leak, the clock-skew attack, and the per-connection randomization that defanged both. A closing section on what survives in 2026, when so many connections are NATed, proxied, or tunneled.

What is in a SYN, field by field

The TCP header ends with a variable options area. On a SYN, that area is where the two endpoints advertise the extensions they support, because most of these options are only meaningful if both sides agree at connection setup. Four options dominate a modern SYN, and each has a fixed byte layout defined by its RFC.

The maximum segment size option is kind 2, length 4: a one-byte kind, a one-byte length, then a two-byte MSS value. It tells the peer the largest segment this host wants to receive, usually derived from the interface MTU (1460 on a 1500-byte Ethernet path). The window scale option is kind 3, length 3, per RFC 7323: kind, length, and a single shift-count byte. The selective-acknowledgment-permitted option, SACK-permitted, is kind 4, length 2, just kind and length, a pure flag with no payload. The timestamp option is kind 8, length 10: kind, length, a four-byte TSval, and a four-byte TSecr.

That is most of the budget. MSS is four bytes, window scale is three, SACK-permitted is two, timestamp is ten. Nineteen bytes of real options. But a window-scale or SACK option that lands on an odd boundary leaves the following 32-bit words misaligned, so stacks insert NOP bytes (kind 1, a single byte) as padding. The NOPs do nothing. Their only job is to push the next option onto a four-byte boundary. And because each stack pads in its own way and in its own places, the NOPs are where a surprising amount of the fingerprint lives.

A Linux SYN options field, byte by byte (olayout: mss,sok,ts,nop,ws) 02 04MSS k=2 l=4 05 b4mss=1460 04 02sok 08 0ats k=8 l=10 TSval(4) TSecr(4)timestamp payload 01NOP 03 03ws The single orange NOP exists only to align the 3-byte window-scale option onto a 4-byte word. Where each stack inserts its padding, and in what order it lists the options, is the fingerprint. olayout = mss,sok,ts,nop,ws *The option fields of a typical Linux SYN. The values (MSS 1460, ws shift) are ordinary; the ordering and the single NOP are what identify the stack.*

If you want the broader picture of how these layer-3 and layer-4 signals combine with TTL and window size into a full stack identity, the companion post on TCP/IP stack fingerprinting covers the non-options fields. This post stays inside the options area.

The option layout is the fingerprint

RFC 793 specified the option encoding but imposed no ordering. A stack can send MSS first or last, can put SACK-permitted before or after the timestamp, can pad with NOPs in one place or three. Functionally every ordering is equivalent. The peer parses options by walking the kind/length fields, not by position. So the order is free, and because it is free, it diverged. Each kernel’s TCP output path writes the options in whatever sequence its authors chose, and that sequence has barely changed in twenty years because there was never a reason to touch it.

Both major passive fingerprinting tools encode this layout as a short string. p0f, the passive OS fingerprinter Michal Zalewski first released in 2000 and rewrote as v3, writes a SYN signature as eight colon-separated fields:

sig = ver:ittl:olen:mss:wsize,scale:olayout:quirks:pclass

The sixth field, olayout, is the comma-delimited list of options in the exact order they appear. p0f’s tokens are mss, ws, sok, ts, nop, and eol+n for an explicit end-of-options followed by n padding bytes. The wsize,scale field carries the window size and the window-scale shift, where the window size is often written relative to MSS or MTU, so mss*20 means twenty times the MSS rather than a fixed number. That relativity is itself a fingerprint, because some stacks size their initial window as a multiple of MSS and others use a flat constant.

Lay a few p0f database signatures side by side and the divergence is obvious:

Linux 3.11+ *:64:0:*:mss*20,10:mss,sok,ts,nop,ws:df,id+:0
Linux 2.4 *:64:0:*:mss*4,0:mss,sok,ts,nop,ws:df,id+:0
Windows XP *:128:0:*:65535,0:mss,nop,nop,sok:df,id+:0
Windows 7/8 *:128:0:*:8192,2:mss,nop,ws,nop,nop,sok:df,id+:0
macOS 10.x *:64:0:*:65535,1:mss,nop,ws,nop,nop,ts,sok,eol+1:df,id+:0
FreeBSD 9.x *:64:0:*:65535,6:mss,nop,ws,sok,ts:df,id+:0

Read the sixth field across those rows. Linux puts SACK-permitted second, right after MSS, then timestamp, then a NOP, then window scale: mss,sok,ts,nop,ws. Windows 7 leads with MSS, then a NOP, then window scale, then two more NOPs, then SACK-permitted, and carries no timestamp at all in that signature: mss,nop,ws,nop,nop,sok. macOS uses yet another arrangement, with window scale early, timestamp late, SACK-permitted last, and an explicit end-of-options with one byte of padding: mss,nop,ws,nop,nop,ts,sok,eol+1. FreeBSD is the tidiest, mss,nop,ws,sok,ts, with a single NOP and no trailing padding.

Same options, different order: the olayout string per OS Linux 3.x msssoktsnopws Windows 7 mssnopwsnopnopsok macOS 10.x mssnopwsnopnoptssokeol+1 FreeBSD 9 mssnopwssokts The position of sok (orange) alone separates all four. Windows omits the timestamp entirely. *The SACK-permitted token, highlighted, lands in a different slot in every stack. Order, not value, does the work.*

nmap, which fingerprints actively rather than passively, encodes the same idea more compactly in its O (TCP options) test. Each option becomes a single letter: M for MSS with its value appended, W for window scale with the shift appended, T for timestamp followed by two bits noting whether each timestamp field was zero, S for SACK-permitted, N for NOP, and L for end-of-options. nmap’s own documentation gives the worked example M5B4NW3NNT11, which decodes to an MSS option with value 0x5B4, a NOP, a window scale of 3, two more NOPs, and a timestamp whose two fields were both non-zero. The nmap manual notes the historical observation that drove all of this: Solaris emitted NNTNWME while an old Linux 2.1 kernel emitted MENNTNW, the same options in a wholly different sequence. Two stacks, identical functionality, different bytes on the wire.

The reason this is a strong fingerprint and not a fragile one is that the ordering is buried in kernel source, not configuration. A user can change the window size with a sysctl, can turn timestamps on or off, but cannot reorder the options without recompiling the stack. So the layout string is hard to spoof from an ordinary application. A scraper running on Linux that wants to look like Windows has to either use a userspace TCP stack or patch the kernel, because the socket API gives it no handle on option order. That is exactly the mismatch a detector hunts for, and it is the subject of detecting a proxy by OS mismatch: a connection whose TCP layout says Linux while its User-Agent says Windows is, almost certainly, a Linux box pretending.

Window scale: a one-byte exponent that says a lot

The window scale option exists because the TCP header’s window field is only sixteen bits, capping the advertised receive window at 65,535 bytes. On a fast, high-latency path that is far too small to keep the pipe full. RFC 7323 (which replaced RFC 1323 in 2014) fixes this with a shift count: the real window is the advertised 16-bit window left-shifted by the scale value. The option byte is a single number, the shift count, capped at 14, which lets the window grow to 1,073,725,440 bytes, roughly 1 GiB. The option may only appear on the SYN and SYN-ACK, because both sides have to learn the scale before any data window is interpreted.

The shift count an OS chooses is part of its signature. Look back at the p0f rows. Modern Linux advertises a scale of 10. Windows 7 in that signature uses 2. macOS uses 1, FreeBSD uses 6, and Windows XP uses 0, meaning no scaling at all. These are defaults baked into each stack’s receive-buffer auto-tuning. On Windows, receive-window auto-tuning has been on by default since Vista, and at the default “normal” tuning level the scale factor is 8, though the value that actually appears on the wire depends on the buffer size the stack has settled on for that socket. The point is that the scale is not random. It clusters tightly per OS and per kernel generation, so it narrows the candidate set even before you read the option order.

Window scale: advertised window << shift count window = 65535 << shift shift = 0..14 = up to ~1 GiB Linux 3.xshift 10 FreeBSDshift 6 macOSshift 1 Windows 7shift 2 Windows XPshift 0 (none) *The shift count is one byte, capped at 14. Each stack's default clusters tightly, so it narrows the OS guess on its own.*

The window size itself, the 16-bit field before scaling, carries a related signal. Some stacks set the initial advertised window to a multiple of the MSS, which p0f writes as mss*20 or mss*4. Others use a flat constant: Windows XP’s 65,535, Windows 7’s 8192. Whether the window tracks MSS or sits at a fixed value is a structural property of the stack, not a tunable, so the wsize expression and the scale together rarely agree across OS families by accident.

The timestamp option, and the two things it used to leak

The timestamp option is the richest field in the SYN, and historically the most dangerous from a privacy standpoint. Its mechanics are simple. Each endpoint stamps its outgoing segments with the current value of its timestamp clock in the TSval field, and echoes the most recent value it received from the peer in the TSecr field. RFC 7323 puts the recommended clock tick rate between 1 millisecond and 1 second per tick. The two fields together let a sender measure round-trip time precisely (subtract the echoed TSecr from the current clock) and let the receiver run PAWS, Protection Against Wrapped Sequences, which uses the monotonic timestamp to reject old segments when the 32-bit sequence number wraps on a fast connection. Both are legitimate, useful functions.

The trouble is that the timestamp clock is a clock. It ticks at a steady rate, and if it starts at a known value, an observer can read absolute time off it.

A short note on the tick rate matters here, because it is also a fingerprint. RFC 7323 bounds the clock between one tick per millisecond and one tick per second, and within that range each stack picked a frequency. Older Linux drove the timestamp from jiffies, which on many builds ran at 250 or 1000 ticks per second. Some BSDs and embedded stacks tick more slowly. The rate is visible: collect two timestamped segments a known interval apart and divide the TSval delta by the elapsed wall-clock time, and the quotient is the host’s tick rate to good precision. So before the random offset was added, an observer could read three things off the timestamp without any active probing at all, the tick rate, the absolute uptime, and the clock skew. Two of those are now masked on a default modern stack; the tick rate is the one that survives, because randomizing the offset does not change how fast the clock advances.

The uptime leak

The original, naive implementation derived the timestamp clock straight from the kernel’s monotonic uptime counter, the jiffies on Linux, which starts at or near zero when the machine boots. If TSval is uptime in disguise, then anyone who sees one SYN can divide TSval by the tick rate and recover how long the host has been up. nmap added exactly this to its OS detection: a TCP uptime guess, computed from the timestamp. For a server that reboots only on patching, the uptime is a slowly changing identifier, and a sudden reset is a visible event. Worse, two connections from behind the same NAT could be separated, because two machines that booted at different times carry different timestamp clocks, so a single public IP that emits two distinct, independently advancing timestamp sequences is two hosts, not one.

RFC 7323 calls this out directly. A naive implementation that derives the timestamp clock from system uptime “may unintentionally leak this information to an attacker,” and the spec recommends generating a random, per-connection offset to add to the clock when writing the option. That recommendation is the fix that closed the leak, and the major stacks adopted it.

The clock-skew attack, and why it was worse

The deeper attack does not need the absolute value of the clock at all. It needs the rate. In a 2005 IEEE paper that won a best-paper award at the Symposium on Security and Privacy, Tadayoshi Kohno, Andre Broido, and KC Claffy showed that the timestamp clock of a physical device drifts from true time at a rate that is stable, measurable remotely, and distinct per device. No two quartz oscillators tick at exactly the same frequency. The deviation is small, parts per million, microseconds per second, but a fingerprinter who collects enough timestamped packets can fit a line to the clock’s drift and recover that skew to high precision.

The consequence is severe. Clock skew is a property of the hardware, so it follows the device across IP addresses, across reboots in the naive case, and through NAT. The 2005 paper showed the technique works even when the host keeps its system time disciplined by NTP, because NTP corrects the system clock, not the raw oscillator the TCP timestamp counts. A device that hides behind a rotating set of proxy IPs still emits the same skew on every connection that carries a timestamp. For a defender, that is a near-perfect device identifier that no IP rotation can launder. For a privacy-conscious user, it was a quiet disaster.

Clock skew: two devices, two drift rates, recovered from TSval offset time device A: +42 ppm device B: +11 ppm The slope is the fingerprint. It follows the hardware through NAT and across IP changes. *Skew is the slope of the line fit to a device's timestamp drift. NTP corrects the system clock but not the oscillator the slope measures.*

How per-connection randomization changed both

Both leaks share a root cause: the timestamp clock was a single, global, monotonic counter that every connection from the host drew from. Fix that and you fix both. In 2016, Florian Westphal landed a Linux kernel commit, “tcp: randomize tcp timestamp offsets for each connection,” that adds a random 32-bit offset per connection. The kernel extends its ISN generator to return an extra random value alongside the initial sequence number, stores it as a per-socket ts_off, and applies it when writing and reading the on-wire timestamp. Internally the stack still uses its normal monotonic clock, so PAWS and RTT measurement work unchanged, but what appears on the wire is the real clock plus a connection-specific random constant. The offset stays fixed for the life of a connection quadruple, which keeps timestamps monotonic where PAWS needs them, but differs between connections, which breaks the cross-connection correlation.

This is now the default. The Linux sysctl net.ipv4.tcp_timestamps takes three values: 0 disables timestamps, 1 enables them with the per-connection random offset, and 2 enables them without the offset. The default is 1. With the offset in place, dividing TSval by the tick rate gives a meaningless number, so the uptime guess fails, and the per-connection randomization scatters the starting points so a naive skew fit across connections no longer lines up cleanly. Windows took a blunter route: it ships with TCP timestamps off by default on the modern desktop releases, which is why so many Windows p0f signatures carry no ts token at all. No timestamp, no timestamp leak.

The skew attack is not fully dead. A determined adversary who can collect many packets within a single long-lived connection can still fit the oscillator drift, because the random offset is constant for that connection and cancels out of the slope. But the cheap, passive, cross-connection version, the one that tracked a device across IPs from a handful of SYNs, is gone on a stack that randomizes. That is a real change in what the field gives away, and it is worth being precise about: the option still appears, the clock still ticks, but the absolute reference point that made it dangerous has been removed from the wire.

Quirks, padding, and the long tail

Beyond the four headline options, p0f records a list of quirks, small anomalies in the IP and TCP headers that accompany the SYN: the don’t-fragment bit (df), a non-zero IP ID with DF set (id+), explicit congestion notification (ecn), a non-zero urgent pointer without the URG flag, a push flag on a SYN, and timestamp-specific oddities like a zero TSval on the first SYN (ts1-) or a non-zero TSecr where none should exist (ts2+). Each quirk is a deviation that some stacks exhibit and others do not, and together they extend the fingerprint past the option layout into the surrounding header.

The end-of-options handling is its own tell. macOS, in the signature above, terminates its options with an explicit EOL and one byte of padding, written eol+1. Many stacks simply pad with NOPs and never emit an EOL. Whether a stack bothers with an explicit end-of-options marker, and how many padding bytes trail it, is another low-entropy bit that costs nothing to read and rarely matches across OS families by coincidence. None of these single bits is decisive. Stacked together with the option order and the scale value, they push the identification from “some Unix” down to a specific kernel generation.

It is worth being clear about what these signals can and cannot resolve. A single SYN gives you an OS family and often a kernel-version band, because the option order and quirks change between major releases but not between two machines running the same build. They do not give you a host. Every Linux 6.x box with default sysctls emits the same mss,sok,ts,nop,ws layout, the same scale of 7 to 10 depending on buffer tuning, the same quirk set. That sameness is the whole point of a fingerprint that survives proxying, but it is also its ceiling: the SYN labels the stack, not the device. The host-level identifier the SYN used to carry was the timestamp clock, and that is exactly the part that got randomized away. So in 2026 the options area is an excellent family classifier and a poor unique key, which is the opposite of the situation a decade ago, when the timestamp made the same packet a near-unique device tag.

The one place the layout can still single out a specific connection is when it disagrees with itself or with a higher layer. A SYN whose option order says one OS while its TTL or MSS says another is a rewritten or proxied packet. A connection whose TCP layer says Linux while its TLS ClientHello and User-Agent say Windows is a userspace HTTP client on a Linux host wearing a costume. Those mismatches are not low-entropy at all; they are binary, and a single one is usually enough to flag a session. The passive-fingerprinting literature, including the long line of work that grew out of p0f and passive OS fingerprinting, leans on exactly this cross-layer agreement rather than on any one field’s raw entropy.

This is also where fingerprint evasion gets genuinely hard. An HTTP client or scraper that wants to disappear has to make its TCP layer agree with everything above it. Spoofing the User-Agent is trivial. Spoofing the TLS ClientHello is well-trodden, the subject of TLS fingerprinting from JA3 to JA4. But spoofing the SYN option layout means controlling bytes the socket API never exposes, which forces a userspace TCP stack or a kernel patch, and even then the timestamp clock, the window auto-tuning behavior, and the quirks all have to move together or the disguise tears at one of its seams. The same logic that makes p0f useful to Cloudflare for separating SYN-flood packets from real traffic, which they automate by compiling p0f signatures into BPF at the edge, makes the TCP layer a stubborn thing to fake convincingly.

What survives in 2026

The option layout is the durable part. It has not moved in twenty years, it is decided by kernel source rather than configuration, and it separates the major OS families cleanly on the very first packet of a connection. Read the order of MSS, SACK-permitted, timestamp, the NOP padding, and window scale, add the scale shift and the window-size expression, and you have a label that is hard to forge from userspace and cheap to compute passively. That is why p0f, twenty-five years after its first release, still ships a signature database that detection systems and CDNs run in production. The mechanism aged well because the thing it reads almost never changes.

The timestamp is the part that genuinely changed, and the direction is worth holding onto. For a decade the timestamp clock was the most invasive field in the SYN, because it leaked absolute time and, through clock skew, a hardware identifier that no proxy could strip. Per-connection offset randomization on Linux and timestamps-off-by-default on Windows took the cheap, passive, cross-connection version of both attacks off the table. The skew is still in the oscillator, and an adversary with a single long connection and enough packets can still fit it, but the casual read from a few stray SYNs no longer works on a modern, default-configured stack. The field that used to give the most now gives among the least, and it got there not by removing the option but by adding noise to one specific number.

The honest summary for anyone relying on these signals in 2026 is that the option layout is a strong OS-family hint and a weak host identifier, the window scale narrows the guess, and the timestamp is mostly back to doing its job of measuring RTT rather than betraying the machine that sent it. The interesting fingerprints have migrated up the stack, to TLS and HTTP/2, where the client has more bytes to get wrong. But the SYN still arrives first, before any of that, and it still announces which family of kernel wrote it. A connection that contradicts itself between that SYN and the User-Agent two layers later is the cleanest tell on the wire, and the forty bytes of TCP options are where the contradiction shows up first.


Sources & further reading

Further reading