You can’t traceroute to a client behind NAT. Unsolicited inbound gets dropped at the first box that has no mapping for it, and that’s the end of the story. I believed it, it’s true, and it’s also why I never once thought about the path back to a visitor — until a hop I didn’t order made me.
I was rebuilding a container image and ran a lazy traceroute to my own LAN gateway from inside the container, just to check DNS wasn’t broken. There was an extra line at the top I’d never seen from the host: 172.17.0.1, the docker0 bridge. One hop the bare host doesn’t have. The container sits one NAT deeper than the machine it runs on, so every packet it emits lands with its TTL one lower than the host’s would. Tiny thing. I couldn’t un-notice it.
This is a pet project — no product behind it, a weekend of scapy and an SVG I redrew four times. Everything below runs against my own devices, my own network, my own accounts, and nothing on the client gets touched beyond the connection it already opened to me. This blog publishes signals, not weapons, and a reverse-trace is about as passive as loud gets.
A NAT is a router, and routers subtract one
That extra hop isn’t a docker quirk. A NAT box forwards like a router, and a router decrements TTL by one. Your home router does it. docker0 does it. libvirt’s virbr0 does it. The carrier-grade NAT your mobile operator runs does it. We think of NAT as the thing that hides what’s behind it — and for the payload, sure. But the hop count doesn’t hide. Every extra NAT layer is exactly one more hop and one more tick off the TTL, a measurable amount of daylight between “on the metal” and “three boxes deep.”
I’d been circling this since a botnet teardown left me with a loose end I couldn’t tie off — residential exits all reporting a Linux TTL of 64, and me guessing out loud whether NAT was quietly normalizing the stack. This post doesn’t close that thread. It pulls on a neighbouring one, the corner where the arithmetic actually behaves.
Once I’d framed it that way the itch was obvious. If a container announces its depth from the inside, could I read that same depth from the outside — from a server the visitor connects to, looking back at whoever just called? And the honest question underneath, the one this post is actually about: if I could, would it tell me anything about whether the visitor is a bot? I’ll spoil my own verdict. Mostly no. Sometimes, cheaply, yes.
You can’t traceroute someone who hasn’t called first
The obvious wall is the one I opened with. You can’t send an unsolicited probe inward; it dies at the first NAT with no mapping for it.
Unless the visitor digs the hole for you. When a browser opens a connection, it punches a mapping — a conntrack entry — on every NAT between it and me, and that hole is bidirectional and keyed to one exact 5-tuple. So I don’t send fresh probes. I fire TTL-limited probes back along the same 5-tuple the client already established, and every NAT that’s holding that mapping happily forwards them inward. The intermediate routers answer ICMP time-exceeded as the TTL runs out, one per hop, just like a forward trace. The final packet — the one with enough TTL to actually reach the client — gets swallowed by the client’s own socket with no ICMP, because an application that isn’t listening for it never announces it arrived. The trace halts, all on its own, at the hop before the client. I map the whole path up to their edge and never say a word to their application.
Two ways to reuse a hole the browser already punched
I built two transports, because the punched hole looks different depending on how it was dug.
UDP is the awkward one — a browser can’t send a raw datagram. So I stand up our server as a STUN server and let WebRTC/ICE do the work. The browser’s STUN Binding Request punches the hole and, as a bonus, hands me its public 5-tuple (the srflx candidate). I reverse-trace on that.
TCP is cleaner and it’s the one I like. The browser’s plain HTTP request is the punched hole — an established conntrack entry on every NAT in the path. I sniff the live connection’s seq/ack off the wire and send in-window pure-ACK probes on that exact tuple, so conntrack forwards them instead of dropping them INVALID. The nice part: an ICMP time-exceeded is a soft error for an established TCP socket. The kernel doesn’t tear the connection down. So I collect the whole trace and hand it back in the same HTTP response the browser was already waiting on.
The public IP shows up three times, and that is the trick
Here’s the readout that made the whole idea worth it. Every internal NAT that answers ICMP time-exceeded has its reply SNAT’d back out under the same public IP on the way to me. So the client’s public IP doesn’t appear once at the tail of the trace. It appears once per NAT layer, stacked.
With a browser running inside a docker container, the reverse trace back toward it had this tail (real addresses swapped for documentation ranges):
# reverse-trace fired back down the connection the browser already opened
# probes ride the same 5-tuple; each NAT on the path forwards them inward
hop responder rtt
...
6 198.51.100.4 29 ms # ISP core
7 203.0.113.7 31 ms # public IP — home router's WAN NAT
8 203.0.113.7 32 ms # public IP again — the host's NAT
9 203.0.113.7 32 ms # and again — docker0, SNAT'd back out
10 * # the container's own socket. no ICMP. done.
Three consecutive lines, same public IP. Router WAN, host, container. Run it directly on the host and the public IP shows up exactly once, then silence. The repeat count is, roughly, the NAT depth. You’re reading the number of NATs behind a connection off the connection itself, with no cleverness required.
Then I asked the wrong clock what time it was
I don’t want to skip the beat where I embarrassed myself, because a lab notebook should include the dumb ones.
I bolted on a few extra clocks to cross-check the topology. The passive incoming TTL earns its keep first: the client’s own SYN or STUN packet arrives already stamped, and 64 − ttl is its hop distance — I never touched it, it’s the client’s own mark, and it places them even when the last routers go quiet. Then an active client probe — a full-TTL TCP keepalive with seq = snd_una − 1, which makes the client’s own stack answer with an ACK. It proves the client is really there and measures RTT to the actual endpoint even when the last-mile routers stay silent. And a tiny WebSocket ping-pong for application-layer RTT, to hold against the network RTT to the exit.
First run of the active probe against a box on my own bench, it reported 140 ms. Impossible. A WebSocket to the exact same machine was 27 ms. Two clocks on one box, off by an order of magnitude, and I spent a while suspicious of the network before the answer turned out to be me. I’d started the stopwatch before scapy’s send() machinery — socket setup, route lookup, ~100 ms of Python getting ready to put a packet on the wire — instead of from the packet’s own capture timestamp. Measured purely from pcap timestamps, send and receive both, the RTT dropped to where physics said it should be and agreed with everything else. Lesson, cheap to state and expensive to learn: the tool will lie to you the moment you measure the wrong clock.
Five exits, five different shapes
Once the clocks were honest, I pointed it at myself through a handful of paths. The shapes were distinct enough to be almost rude about it.
| Path | What the trace back showed | The tell |
|---|---|---|
| Direct home | public IP once, then silence; passive TTL, active-probe RTT and WS latency all agree (~27–34 ms) | boring in the good way — everything agrees, nothing relayed |
| CGNAT | the operator’s RFC1918 hops leak in — 10.221.x (a Romanian ISP), 10.102.x (an African one), 172.31.x (a Ukrainian one) — plus the public IP repeating | an extra NAT layer living at the carrier. Keep that one in mind; it comes back to bite |
| Datacenter proxy | the whole path stays inside the hosting ASN (143.244.x, DigitalOcean), low, flat RTT | the endpoint answered faster than the router one hop in front of it — routers rate-limit ICMP on the slow path, a box in a rack answers TCP on the fast one. That’s a rack, not a living room |
| VPN / L3 tunnel | exit gateway answered time-exceeded at ~38 ms; the keepalive to the real device came back 130–266 ms and jittered run to run | two RTTs to “the same IP” that flatly disagree — the gap is the tunnel plus a real phone’s power-save radio waking up |
| Residential proxy | app-layer WS RTT ran ~2× the network RTT (~133 ms WS vs ~66 ms network, a Spanish exit) | the TCP endpoint is near; the application frames get relayed onward to the operator |
One irritation worth flagging: varying IP.id per probe (which I need to correlate replies) makes ECMP load balancers scatter probes across parallel paths, so you get two different IPs at one hop. A Paris-traceroute mode — constant IP.id, single path — cleans that up.
Honest limits
Now the part where I talk myself down, because this is a pet project and I’d rather deflate it than oversell it.
Quality residential proxies win outright. When the traffic genuinely originates from a residential device, the trace looks like a real user because it is one — there’s nothing in the path to catch. And CGNAT muddies the whole “extra NAT = suspicious” story, because piles of perfectly legitimate mobile and home users sit behind carrier-grade NAT that’s deeper than my container was. NAT depth on its own is not a bot signal. It’s a fact about someone’s ISP, and it has to be read next to the RTT disagreements, never alone.
It’s also not a per-request filter, and never will be. It needs root and raw sockets, it actively probes back toward the client, and it costs a few seconds per session. That makes it a verification step for a session you already have reason to doubt — never a gate on every hit.
So is this any good for catching bots?
A qualified yes, in a narrow lane. Which is the honest answer.
Where it earns a place: the cheap, structural origins. A “residential” IP whose trace is really a box inside a hosting ASN. A connection whose two RTTs to the same endpoint disagree by 100 ms — that’s a tunnel, full stop. A client sitting one NAT deeper than a bare host — that’s a container or a VM, the docker0/virbr0 +1 I started with, still visible from the far end. Each one is a flag, not a verdict, and the most browser-deployable version doesn’t even need the raw-socket trace: it’s the disagreement between the WebRTC srflx IP, the HTTP source IP, and the trace topology. Three views of “where are you,” three layers the operator has to keep consistent and usually can’t, and a straight answer looks the same in all three.
Everything here is observation. I never send anything the visitor’s own connection didn’t already invite, and the client’s socket ends the trace by staying quiet, which is the whole design. Signals, not weapons.
You can dress up the exit IP. You can dress up the user-agent. You cannot dress up the number of doors standing behind you — that count gets written on the one layer you never control, the path back to me.