Surviving an active probe
What happens when the censor connects to your server and pokes it — and how each layer answers so nothing looks like a proxy.
An active probe is the censor doing what your client does: opening a TCP connection to your server and watching how it reacts. It sends a normal TLS ClientHello, or a line of HTTP, or pure garbage — then measures the reply, the certificate, the timing, and how the connection closes. The only way to fail a probe is to react in a way that no ordinary web service would. A reset on a malformed byte, a self-signed certificate, a fixed-length error before close — any of these is a fingerprint. Every anti-probe mechanism below exists to make the wrong-key path indistinguishable from a boring, real server.
Answer a probe the way the real service it impersonates would
The rule that ties all four protocols together is this: on a failed handshake, hand the connection to something real and let that real thing answer. A verified client gets the tunnel; everyone else gets routed to a genuine backend and never learns a proxy was here.
The fork happens before your server has emitted a single distinguishing byte. It reads the opening bytes, tries to authenticate them, and only on success does anything proxy-shaped happen.
REALITY relays an unauthenticated handshake to a site you don’t own the risk of
REALITY’s answer is the strongest because there is no fake certificate to catch. The server
carries the TLS session of a real, third-party site (dest), so a probe completes a genuine
handshake against that site’s real certificate. Authentication is smuggled into the ClientHello SessionId field, keyed to your privateKey and a shortId; a prober can’t forge it and doesn’t
know it’s expected.
{
"network": "tcp",
"security": "reality",
"realitySettings": {
"show": false,
"dest": "www.some-real-site.com:443",
"serverNames": ["www.some-real-site.com"],
"privateKey": "…",
"shortIds": ["", "6ba85179e30d4fc2"]
}
} transport/internet/reality/reality.go infra/conf/transport_internet.go VLESS replays unrecognized bytes to a real backend
VLESS over TLS has no borrowed site, so its answer is a fallback: bytes that don’t decode as a
valid VLESS request are streamed verbatim to a backend you run, which produces the real reply. The handler even short-circuits — firstLen < 18 falls back immediately — and it can route by
SNI (name), ALPN (alpn), and HTTP path to different destinations.
{
"protocol": "vless",
"settings": {
"clients": [{ "id": "…", "flow": "xtls-rprx-vision" }],
"decryption": "none",
"fallbacks": [
{ "dest": "8001", "xver": 0 },
{ "path": "/ws", "dest": "8002" }
]
}
} proxy/vless/inbound/inbound.go The dest on port 8001 should be a real web server. A prober that speaks plain HTTPS gets your
actual website; a prober that hits /ws gets whatever lives on 8002. Nothing about the reply
says “proxy”. Trojan uses the identical fallbacks block — a wrong password
(the first 56 bytes aren’t a known hash, or byte 56 isn’t \r) triggers the same replay.
Shadowsocks has no site to hide behind — so it drains
Shadowsocks can’t fall back to HTTP because there is no plaintext framing to inspect. Its defense
is timing: on a bad key it reads a pseudo-random number of extra bytes before closing, so the
prober can’t measure a fixed reject length. The bulk of the drain length is derived from your
users’ keys (validator.GetBehaviorSeed()), with a smaller per-connection random roll on top —
so your server’s rejections are consistent with themselves but never byte-identical, and no two
servers reject alike.
// wrong key, unknown user, or replayed IV all take the same path:
drainer, errDrain := drain.NewBehaviorSeedLimitedDrainer(int64(behaviorSeed), 16+38, 3266, 64)
// …
return nil, nil, drain.WithError(drainer, reader, errors.New("failed to match an user")) proxy/shadowsocks/server.go proxy/shadowsocks/protocol.go That hides the length of the rejection, but a raw Shadowsocks port still answers every probe with an eventual silent close — never with real content. Against a persistent prober it is weaker than a TLS-fronted protocol that serves a live website. Put it behind a real transport if that’s a concern.
The misconfig that gives you away is a fallback to nothing
Every mechanism above depends on the “real thing” actually being real. The single most common way operators defeat their own anti-probe layer is pointing the escape hatch at something that behaves like a proxy failure.
Fall back to a closed port or a borrowed site you don't control
A fallbacks dest pointing at a dead port answers every probe with an instant connection
reset — the exact tell you were hiding. A REALITY dest / serverNames pair aimed at a site
that redirects, serves a different TLS version, or is itself blocked makes your server behave
unlike the site it claims to be.
Point it at a live server that gives a plausible reply
Run a real web server on the fallback dest. For REALITY, borrow a dest that is popular,
reachable from your region, supports TLS 1.3 + HTTP/2, and doesn’t redirect elsewhere — the
handshake the prober completes is that site’s, so it must hold up on its own.
The runner-up footgun is a certificate that only a proxy would present: a self-signed cert, or a Let’s Encrypt cert for a domain the SNI doesn’t match, on a TLS-based VLESS or Trojan inbound. REALITY sidesteps that entirely by never owning a certificate — which is exactly why it’s the default answer to the active-probe problem.