TCP/IP Fingerprinting: OS Clues in the SYN Packet
Capture TCP/IP fingerprints from SYN fields — TTL, window, MSS, option order, DF — with a scapy sniffer on your host. See why the OS stack leaks below TLS.
TCP/IP Fingerprinting: OS Clues in the SYN Packet
Before TLS sends a ClientHello, and long before HTTP/2 SETTINGS or a User-Agent string appear, the server (and any middlebox that can see the TCP handshake) already holds a small dossier on the operating system that opened the socket. That dossier comes from the TCP SYN: initial TTL habits, receive window, maximum segment size (MSS), the ordered list of TCP options, and whether the Don’t Fragment (DF) bit is set.
This article closes the fingerprint stack from the bottom up. Upper layers are covered in JA3/JA4 TLS Fingerprints Across HTTP Clients, TLS Fingerprinting Explained, and HTTP/2 Fingerprinting. How those wire signals sit next to headers and automation checks is sketched in anti-bot detection layers. Here the experiment is first-party again: sniff SYN packets on an interface you administer, trigger connections from machines you own, and compare the printed fields.
The central idea is simple and easy to forget when people talk only about browsers: the TCP stack belongs to the kernel. User space can pick ciphers, ALPN tokens, and HTTP libraries; it does not casually reinvent TCP option layout. When TCP looks like Linux and the User-Agent claims Windows, that disagreement is itself a signal — not because one field is “wrong,” but because the layers disagree about which OS is speaking.
What a SYN Advertises
A pure SYN (SYN set, ACK clear) carries more than “I want port 443.” Fields that classical tools such as p0f have used for years include:
- IP TTL (observed) — how many hops remain when the packet arrives. Hosts typically start from a conventional initial TTL (64, 128, or 255). After a few hops the observed value is lower; classifiers often round up to the nearest conventional bucket to guess the sender’s initial TTL (see below).
- TCP window size — the initial receive window in the SYN. Defaults differ by OS family and by whether window scaling will be negotiated.
- MSS — usually present as a TCP option; it reflects path/MTU assumptions on the sender.
- TCP options: set and order — common options include MSS, SACK permitted, Timestamps, NOP padding, and Window Scale. Two stacks can enable the same features and still differ in wire order and padding. Order is part of the fingerprint.
- DF (Don’t Fragment) — whether the IP header asks intermediate routers not to fragment. Typical desktop/server stacks set DF on these SYNs; treat your capture as ground truth rather than a universal law.
None of this needs a third-party HTML page. The signal is in the first TCP segment your client sends toward a listener on 127.0.0.1 or another host you control.
Inferring Initial TTL From Observed TTL
If you observe TTL 55 on the wire, the sender almost certainly started from 64 and crossed nine hops (64 − 55 = 9), not from 128. A practical reconstruction:
observed = IP.ttl from the SYN
for candidate in (64, 128, 255):
if observed <= candidate:
initial_guess = candidate
break
Examples (illustrative arithmetic, not a claim about any remote site):
| Observed TTL | Usual initial guess | Implied hop count |
|---|---|---|
| 64 | 64 | 0 (same L2/L3 segment or loopback quirks aside) |
| 57 | 64 | 7 |
| 120 | 128 | 8 |
| 240 | 255 | 15 |
On loopback, many stacks deliver a SYN with TTL still at the initial value (often 64 on Linux). That makes localhost captures excellent for learning option order and window defaults, and weaker for practicing hop arithmetic — use a second machine on your LAN when you want a non-zero hop count.
Be careful: some middleboxes rewrite TTL; some tunnels decrement oddly; IPv6 uses Hop Limit with similar conventions but different tooling. The rounding rule is a heuristic, not a cryptographic proof of OS identity.
Why This Points at the OS, Not the Browser
JA3/JA4 describe a TLS library’s ClientHello. HTTP/2 fingerprints describe a user-space HTTP stack. TCP/IP fingerprints describe kernel defaults (and sysctl overrides) on the sending host.
Consequences for a lab notebook:
- The same Chrome build on Windows vs Linux can share much of the TLS/HTTP story and still diverge on SYN window, option order, or initial TTL bucket.
- A Python process does not get a “Python TCP fingerprint.” It inherits the host OS TCP personality unless something in the path (VPN, transparent proxy, weird container networking) rewrites headers.
- Cross-layer consistency is what operators care about. Browser-shaped JA4 plus library-shaped HTTP/2 is one class of seam; browser User-Agent plus Linux-shaped SYN while claiming a Windows UA is another. Neither seam alone “proves” automation — together they raise the score. Headless and JS-side signals are a separate layer; see headless browser detection signals.
Typical OS Defaults (Measure, Don’t Memorize)
Public p0f-style databases list many signatures. Exact integers drift across kernel versions, VM defaults, and sysctl images — so this table is a hypothesis checklist, not a denylist. Prefer the numbers your sniffer prints.
| OS family (typical) | Initial TTL (common) | Window / scaling notes | Option habits you often see |
|---|---|---|---|
| Linux | 64 | Window and wmem defaults vary by distro/kernel; Window Scale usually present | MSS, SACK, Timestamps, NOP, WS — order is Linux-flavored |
| Windows | 128 | Distinct initial windows across versions | Option set/order differs from Linux; DF commonly set |
| macOS / other BSD-derived | Often 64 | BSD-ish window and timestamp behavior | Option layout closer to BSD than to Windows |
| Embedded / appliances | 64 or 255 | Wide variance | Unusual MSS or sparse options |
On Linux you can inspect related knobs without framing them as advice aimed at any defensive product — they are simply OS facts:
# Read-only examples on a machine you administer (values are yours)
sysctl net.ipv4.ip_default_ttl
sysctl net.ipv4.tcp_window_scaling
sysctl net.ipv4.tcp_timestamps
sysctl net.ipv4.tcp_sack
Changing TTL or window via sysctl changes what your SYN advertises. That is a property of owning the kernel configuration, not a recipe aimed at any named edge vendor.
Honesty About Raw Sockets
Unlike the JA3 listener (stdlib TCP accept + parse first TLS record) or the HTTP/2 listener (stdlib TLS + frame parse), you cannot recover TCP SYN header fields with Python’s standard library alone on a normal socket.accept() path. By the time accept returns, the handshake is already done; user space sees a connected stream, not the SYN’s option blob.
To print TTL / window / MSS / option order you need one of:
- a packet capture API (libpcap/Npcap) via scapy,
tcpdump, Wireshark, or similar; - a classical passive classifier such as p0f;
- or a privileged raw socket and your own parser.
Sniffing almost always requires root or CAP_NET_RAW (Linux), and you should only do it on hosts and traffic you are allowed to observe. The script below is for your loopback or your Ethernet — not for scanning networks you do not operate.
Runnable Path: Scapy SYN Sniffer
Install scapy in a venv you control:
python3 -m venv .venv
source .venv/bin/activate
pip install scapy
Save as tcp_syn_fingerprint.py:
#!/usr/bin/env python3
"""
Local TCP SYN fingerprint sniffer (scapy).
Requires root or CAP_NET_RAW. Sniffs on an interface you administer and
prints TTL, window, DF, MSS, and TCP option order for pure SYN packets.
Usage:
sudo python3 tcp_syn_fingerprint.py --iface lo
# macOS loopback is often lo0:
# sudo python3 tcp_syn_fingerprint.py --iface lo0
Then, in another terminal on the same host, open any local TCP connection
(for example: curl -sS http://127.0.0.1:9/ || true).
"""
from __future__ import annotations
import argparse
import sys
from typing import Any, List, Optional, Sequence, Tuple, Union
from scapy.all import IP, TCP, sniff # type: ignore[import-untyped]
Opt = Union[Tuple[Any, ...], str]
def initial_ttl(observed: int) -> int:
"""Round observed TTL up to a conventional initial-TTL bucket."""
for candidate in (64, 128, 255):
if observed <= candidate:
return candidate
return observed
def option_label(opt: Opt) -> str:
if not isinstance(opt, tuple) or not opt:
return str(opt)
name = opt[0]
value = opt[1] if len(opt) > 1 else None
if name == "MSS":
return f"MSS:{value}"
if name == "WScale":
return f"WS:{value}"
if name == "Timestamp":
return "TS"
if name == "SAckOK":
return "SACK"
if name == "NOP":
return "NOP"
if name == "EOL":
return "EOL"
return str(name) if value in (None, b"", "") else f"{name}:{value}"
def options_order(opts: Sequence[Opt]) -> List[str]:
out: List[str] = []
for opt in opts:
if isinstance(opt, tuple) and opt:
out.append(str(opt[0]))
else:
out.append(str(opt))
return out
def extract_mss(opts: Sequence[Opt]) -> Optional[int]:
for opt in opts:
if isinstance(opt, tuple) and opt and opt[0] == "MSS":
return int(opt[1])
return None
def df_set(ip: IP) -> bool:
# scapy FlagValue supports .DF; fall back to bit 1 of flags.
flags = ip.flags
if hasattr(flags, "DF"):
return bool(flags.DF)
return bool(int(flags) & 0x2)
def handle(pkt: Any) -> None:
if not pkt.haslayer(IP) or not pkt.haslayer(TCP):
return
ip = pkt[IP]
tcp = pkt[TCP]
# Pure SYN: SYN on, ACK off (skip SYN-ACK).
if not (tcp.flags & 0x02) or (tcp.flags & 0x10):
return
obs = int(ip.ttl)
opts = list(tcp.options)
print("TCP_FINGERPRINT")
print(f" src={ip.src}:{tcp.sport} -> dst={ip.dst}:{tcp.dport}")
print(f" ttl_observed={obs} ttl_initial_guess={initial_ttl(obs)}")
print(f" window={int(tcp.window)} mss={extract_mss(opts)} df={df_set(ip)}")
print(f" options={','.join(option_label(o) for o in opts)}")
print(f" options_order={options_order(opts)}")
sys.stdout.flush()
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(description="Sniff local TCP SYN fingerprints")
p.add_argument(
"--iface",
default="lo",
help="interface to sniff (Linux loopback: lo; macOS: lo0; or eth0/en0)",
)
p.add_argument(
"--count",
type=int,
default=0,
help="stop after N matching SYNs (0 = run until Ctrl-C)",
)
args = p.parse_args(argv)
print(
f"sniffing pure SYN on {args.iface} (needs root or CAP_NET_RAW)",
flush=True,
)
sniff(
iface=args.iface,
filter="tcp[tcpflags] & tcp-syn != 0",
prn=handle,
store=False,
count=args.count,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Drive Traffic Against Yourself
Terminal A (privileged):
# Linux
sudo python3 tcp_syn_fingerprint.py --iface lo --count 5
# macOS (loopback name differs)
sudo python3 tcp_syn_fingerprint.py --iface lo0 --count 5
Terminal B — any local TCP open is enough; the peer does not need to speak HTTP:
# Connection refused is fine — you only need the outbound SYN
curl -sS --connect-timeout 1 http://127.0.0.1:9/ || true
python3 -c "import socket; s=socket.socket(); s.settimeout(1); s.connect_ex(('127.0.0.1', 9)); s.close()"
# Optional: aim at a tiny local listener you started yourself
# python3 -m http.server 8080
# curl -sS http://127.0.0.1:8080/ -o /dev/null
To compare OS personalities, repeat from a Linux VM, a Windows host (Npcap required for scapy), and a Mac — each talking to a sink on that same machine or to a capture host you operate on the path.
Example Output Shape
Your numbers will differ by kernel and interface. A localhost Linux-shaped trial might look like (illustrative):
sniffing pure SYN on lo (needs root or CAP_NET_RAW)
TCP_FINGERPRINT
src=127.0.0.1:54321 -> dst=127.0.0.1:9
ttl_observed=64 ttl_initial_guess=64
window=65440 mss=65495 df=True
options=MSS:65495,SACK,TS,NOP,WS:7
options_order=['MSS', 'SAckOK', 'Timestamp', 'NOP', 'WScale']
Re-run after kernel upgrades. Loopback MSS values often look “too large” compared with Ethernet MTU-derived MSS — that is expected on lo and is another reason to capture once on a real NIC when you want WAN-shaped numbers.
Classical Tooling: p0f
If you want a battle-tested classifier instead of a teaching script, p0f (passive OS fingerprinting) remains the classic reference: it watches SYN (and other) traffic and matches signatures built from the same families of fields. Running p0f against a pcap you captured on your lab host is a good cross-check for the scapy printout. The educational goal is identical — understand which SYN fields move when the OS changes — not to claim that any particular production edge uses p0f verbatim.
tcpdump is enough when you only need a pcap for later study:
# Linux example — write a short pcap on loopback, then inspect in Wireshark/scapy
sudo tcpdump -i lo -c 20 -w /tmp/syn-lab.pcap 'tcp[tcpflags] & tcp-syn != 0'
Where the Fingerprint Gets Noisy
TCP/IP fingerprints are powerful on a quiet path and messy on a rewritten one. Expect distortion when:
- NAT and carrier-grade NAT — usually preserve TTL decrement and most TCP options, but hairpinning and ALGs can surprise you.
- Load balancers and reverse proxies — the SYN the backend sees may be from the balancer, not from the original client. Fingerprinting at the wrong hop fingerprints the hop.
- VPNs, tunnels, and some overlay networks — encapsulate or terminate TCP; the outer SYN reflects the tunnel endpoint’s stack.
- TCP Fast Open, middlebox option stripping, SYN cookies — can alter which options appear or how retransmissions look.
- IPv6, Happy Eyeballs, dual-stack happy races — you may classify a different address family than the application later uses for HTTP.
So the fingerprint is evidence, not an absolute identity card. Lab captures on 127.0.0.1 minimize path noise; production analysis must ask which device emitted the SYN you scored.
Stacking With TLS, HTTP/2, and Application Layers
A useful notebook columns for clients you ship:
| Trial | TCP (TTL guess / window / options order) | JA3 / JA4 | H2 fingerprint | User-Agent |
|---|---|---|---|---|
| Linux + curl | (scapy) | (TLS listener) | (H2 listener) | curl/… |
| Linux + browser | … | … | … | Mozilla/5.0 … |
| Windows + browser | … | … | … | Mozilla/5.0 … |
| Container / CI runner | … | … | … | … |
Defenders rarely score TCP alone. They look for agreement: does the SYN’s OS story match the TLS library story, the HTTP/2 SETTINGS story, and the headers/JS story? Aligning one layer while leaving another on library or OS defaults still leaves a seam. Capture each layer with the listeners in this series; keep every experiment on infrastructure you control.
If you later want browser-grade transport handled behind an API rather than maintained per language and kernel, the FineData API documentation describes scrape options such as use_antibot and stealth_antibot. That path is optional — the scapy lab above stands alone for learning.
Operational Checklist for Your Lab
- Sniff only on hosts and interfaces you administer; expect to need root or
CAP_NET_RAW. - Prefer pure SYN (filter SYN-ACK in code) so you do not mix client and server personalities.
- Record TTL observed and the rounded initial guess; on loopback, hop count is often zero.
- Keep TCP option order intact in your notes — sorting destroys the signal.
- Re-measure after kernel, container base image, or VPN client changes.
- Correlate with JA3/JA4 and HTTP/2 fingerprints; any single layer is incomplete.
- When path devices terminate TCP, treat the fingerprint as describing that device.
Summary
TCP/IP fingerprinting reads the SYN before TLS exists: observed TTL (and the 64/128/255 initial-TTL guess), window, MSS, DF, and the ordered TCP option list. Those fields mostly reflect kernel defaults, which is why a Linux-shaped SYN under a Windows User-Agent is interesting, and why this layer sits under JA3/JA4 and HTTP/2 in the stack. There is no honest stdlib-only SYN parser on a connected socket — use scapy (or p0f/tcpdump) with privileges on your own interface, label path noise from NAT and balancers, and compare OS stories across the machines you actually run.
Related Articles
HTTP/2 Fingerprinting: Capture Client Frame Signatures
Capture HTTP/2 fingerprints from SETTINGS, WINDOW_UPDATE, PRIORITY, and pseudo-header order using a local Python listener you control.
TechnicalJA3/JA4 TLS Fingerprints Across HTTP Clients
Compare JA3 and JA4 TLS fingerprints across requests, curl, Go, Node, and browsers. Capture your own hashes with a runnable Python script.
TechnicalTLS Fingerprinting Explained: How Anti-Bot Systems Detect Scrapers
TLS fingerprinting explained: how JA3/JA4 hashes let Cloudflare, Akamai, and other anti-bot systems detect scrapers from the TLS client hello alone.