tcp-fingerprint: Passive TCP SYN fingerprint (p0f-style) and TCP/TLS RTT variables
Debian/Ubuntu installation
These docs apply to the APT package nginx-module-tcp-fingerprint provided by the GetPageSpeed Extras repository.
- Configure the APT repository as described in APT repository setup.
- Install the module:
sudo apt-get update
sudo apt-get install nginx-module-tcp-fingerprint
Show suites and architectures
| Distro | Suite | Component | Architectures |
|----------|-------------------------|-------------|-----------------|
| debian | bookworm | main | amd64, arm64 |
| debian | bookworm-early | main | amd64, arm64 |
| debian | bookworm-mainline | main | amd64, arm64 |
| debian | bookworm-mainline-early | main | amd64, arm64 |
| debian | trixie | main | amd64, arm64 |
| debian | trixie-early | main | amd64, arm64 |
| debian | trixie-mainline | main | amd64, arm64 |
| debian | trixie-mainline-early | main | amd64, arm64 |
| ubuntu | focal | main | amd64, arm64 |
| ubuntu | focal-early | main | amd64, arm64 |
| ubuntu | focal-mainline | main | amd64, arm64 |
| ubuntu | focal-mainline-early | main | amd64, arm64 |
| ubuntu | jammy | main | amd64, arm64 |
| ubuntu | jammy-early | main | amd64, arm64 |
| ubuntu | jammy-mainline | main | amd64, arm64 |
| ubuntu | jammy-mainline-early | main | amd64, arm64 |
| ubuntu | noble | main | amd64, arm64 |
| ubuntu | noble-early | main | amd64, arm64 |
| ubuntu | noble-mainline | main | amd64, arm64 |
| ubuntu | noble-mainline-early | main | amd64, arm64 |
| ubuntu | resolute | main | amd64, arm64 |
| ubuntu | resolute-early | main | amd64, arm64 |
| ubuntu | resolute-mainline | main | amd64, arm64 |
| ubuntu | resolute-mainline-early | main | amd64, arm64 |
An nginx module that exposes a passive fingerprint of the client's TCP/IP stack, read from the SYN that opened the connection, in the tradition of p0f: TTL, window, MSS, window scale, option layout and header quirks. It also times the TLS 1.3 handshake's round trip next to the kernel's TCP RTT.
A browser's User-Agent says which operating system it runs on, but the SYN
comes from the network stack that actually sent it. Residential proxies and
scraping farms that announce Windows while running on Linux boxes show up as a
Windows User-Agent with a Linux SYN: initial TTL 64 instead of 128,
mss,sok,ts,nop,ws options instead of mss,nop,ws,nop,nop,sok. The MSS tells
the link the client sits behind (Ethernet, PPPoE, a tunnel), the quirks tell a
SYN built by hand.
The TCP round trip ends at whoever acknowledges the segments, the TLS one at whoever holds the session keys: the same machine for a direct client, but through a tunnel (CONNECT or SOCKS proxy, residential proxy networks) TCP stops at the exit node and TLS goes on to the real client, often far away. A direct client shows two close values, a tunnelled one a TLS RTT well above the TCP one.
The module only reports. What to do with it (logging, scoring, challenging) is
up to the configuration; examples/ shows a few readings in njs.
HTTP/3 is not covered. QUIC runs over UDP: there is no TCP SYN to read, and the variables are empty on HTTP/3 requests. A client can pick HTTP/3 to avoid the fingerprint, so treat an empty
$tcpfp_signatureas "unknown", never as "clean".
Variables
| variable | value |
|---|---|
$tcpfp_signature |
everything below in one string, laid out like p0f v3 raw signatures: ver:ttl+hops:olen:mss:wsize,scale:options:quirks:pclass, e.g. 4:116+12:0:1460:mss*44,8:mss,nop,ws,nop,nop,sok:df:0 |
$tcpfp_ttl |
TTL (IPv4) or hop limit (IPv6) the SYN arrived with, e.g. 116 |
$tcpfp_ittl |
initial TTL guessed from it: 32, 64, 128 or 255 |
$tcpfp_hops |
$tcpfp_ittl minus $tcpfp_ttl |
$tcpfp_win |
TCP window |
$tcpfp_mss |
MSS option, empty without one |
$tcpfp_wscale |
window scale option, empty without one |
$tcpfp_options |
option layout, in order: mss, ws, sok (SACK permitted), sack, ts, nop, eol+N (end of options and N padding bytes), ?K (unknown kind K) |
$tcpfp_quirks |
oddities of the headers, empty when none (below) |
$tcpfp_tls_rtt |
TLS 1.3 handshake round trip in microseconds: from the server's Finished sent to the client's Finished read |
$tcpfp_tcp_rtt |
the kernel's smoothed TCP RTT (tcpi_rtt) in microseconds, read when the client's Finished arrived |
In the signature, olen is the length of the IPv4 options, wsize the window
written as a multiple of the MSS (mss*44) or of the MTU (mtu*4) when it is
one, mss and scale are 0 when the option is absent (the layout says
which), and pclass is + when the SYN carries data (TCP Fast Open), 0
otherwise.
Quirks, in this order:
| quirk | meaning |
|---|---|
df |
IPv4 "don't fragment" |
id+ |
DF with a non-zero IP ID |
id- |
no DF and a zero IP ID |
ecn |
ECN bits in the IP header, or ECE/CWR in the SYN |
0+ |
IPv4 reserved ("must be zero") flag set |
flow |
non-zero IPv6 flow label |
seq- |
zero sequence number |
ack+ |
non-zero ACK number without the ACK flag |
ack- |
ACK flag with a zero ACK number |
uptr+ |
non-zero urgent pointer without the URG flag |
urgf+ |
URG flag |
pushf+ |
PSH flag |
ts1- |
timestamp option with a zero own timestamp |
ts2+ |
timestamp option echoing a non-zero peer timestamp |
opt+ |
non-zero bytes after the end of options |
exws |
window scale above 14 |
bad |
malformed options |
Typical values:
| client | $tcpfp_options |
window, scale | initial TTL |
|---|---|---|---|
| Windows 10/11 | mss,nop,ws,nop,nop,sok |
64240 (mss*44), 8 |
128 |
| Linux, Android | mss,sok,ts,nop,ws |
64240, 65535... , 7 | 64 |
| macOS, iOS | mss,nop,ws,nop,nop,ts,sok,eol+1 |
65535, 6 | 64 |
The TTL is the initial TTL minus the hops: compare it with 64, 128 and 255
rather than for equality, or read $tcpfp_ittl.
The SYN variables are empty (not found) when there is no SYN to read: HTTP/3,
tcp_fingerprint off, connections accepted before tcp_fingerprint on was
loaded, non-TCP listeners.
$tcpfp_tls_rtt and $tcpfp_tcp_rtt are empty without TLS 1.3 (plain HTTP,
TLS 1.2: the server waits there for the client's key exchange, whose
computation would count), on HTTP/3 and with tcp_fingerprint off. The TLS
RTT also counts the client's work on the server's flight (certificate check, a
few milliseconds) and the time the worker takes to read the Finished; a server
flight larger than the initial congestion window (big certificate chain), or a
loss, adds round trips. Compare the two values with a wide margin (ratio and
absolute gap), never for equality. Tunnels that terminate TLS (corporate
gateways that inspect it) show no gap; relays that cut TCP close to a slow last
mile do (mobile networks' transparent TCP proxies, iCloud Private Relay).
Directive
Syntax: tcp_fingerprint on | off;
Default: tcp_fingerprint off;
Context: http
Turns on TCP_SAVE_SYN on every TCP socket the http block listens on.
Loading the module without tcp_fingerprint on changes nothing: off leaves
the sockets alone, so it never undoes another module that reads the SYN. A
listening socket kept across a reload from on to off goes on saving SYNs
(about 100 bytes per connection, freed when it closes) until nginx restarts.
Example
load_module modules/ngx_http_tcp_fingerprint_module.so;
http {
tcp_fingerprint on;
log_format fp '$remote_addr "$http_user_agent" sig=$tcpfp_signature '
'tls_rtt=$tcpfp_tls_rtt tcp_rtt=$tcpfp_tcp_rtt';
server {
listen 443 ssl;
http2 on;
access_log /var/log/nginx/fp.log fp;
location / {
# hand it to an auth_request / njs / backend that scores it
proxy_set_header X-TCP-Signature $tcpfp_signature;
proxy_pass http://backend;
}
}
}
njs examples
examples/tcpfp.js reads $tcpfp_signature and gives
hints, with examples/nginx.conf to wire them:
stackOs/osMismatch: the system of the stack against the one of the User-Agent (Windows or Mac UA on a Linux stack).link: the link the MSS points to: Ethernet, PPPoE, IP tunnel (6rd, 6in4), GRE, WireGuard, jumbo frames (datacenter hosts), or reduced (VPNs, mobile carriers' clamping).crafted: a SYN no ordinary stack sends (raw socket scanners).tunnelled: TLS round trip well above the TCP one.stacksBehind: several systems behind one address (NAT, proxy pool), in a shared dictionary.
They are hints, not verdicts. Corporate gateways (Zscaler, Netskope, Forcepoint) and university networks rewrite TCP on Linux boxes, mobile carriers clamp the MSS, VPNs and iCloud Private Relay change everything: real visitors show every one of them. Measure them on your own traffic, add them to a score with other signals, and never block on one alone.
How it works
- At each configuration load, the master process sets
TCP_SAVE_SYNon the listening sockets. Accepted connections inherit it: the kernel keeps a copy of each connection's SYN (IP and TCP headers, about 100 bytes) until it is read or the connection closes. - The first request that reads a variable on a connection fetches the SYN
with
getsockopt(TCP_SAVED_SYN)(one system call per connection, the kernel frees its copy), parses it, and keeps the result in the connection's memory pool. Keepalive requests and HTTP/2 streams reuse it. - After that, a request costs no system call and no allocation. Parsing is bounds-checked against the length the kernel returns.
- With
tcp_fingerprint on, the module sets an OpenSSL message callback on the SSL context of everyserver(SSL connections keep it when SNI switches context). It notes the time the server's Finished is written, and when the client's is read, computes the TLS RTT, readsgetsockopt(TCP_INFO), keeps both in the connection's pool and removes itself from the connection: no cost once the handshake is over.