Перейти к содержанию

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.

  1. Configure the APT repository as described in APT repository setup.
  2. 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_signature as "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_SYN on 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 every server (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, reads getsockopt(TCP_INFO), keeps both in the connection's pool and removes itself from the connection: no cost once the handshake is over.