Contents

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.

Same server, two callers. The key in the client's handshake is the only thing that forks the path — the prober is served real content.

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.

Don't

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.

Do

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.