Contents

XTLS Vision

The flow that stops TLS-in-TLS double encryption and hides proxy traffic timing.

When you proxy an HTTPS website through a TLS-based tunnel, the site’s own TLS records get encrypted a second time by the tunnel — TLS-in-TLS. The payload is wasted CPU (encrypting ciphertext), and the nesting is detectable: a censor watching record sizes and packet timing can tell a TLS handshake wrapped inside another TLS session apart from a normal browser visit. xtls-rprx-vision removes the inner layer once it is safe to do so, so the outer connection carries the site’s real TLS records directly.

proxy/vless/vless.go proxy/vless/encoding/addons.go

Vision is a VLESS flow. In EncodeBodyAddons, a flow value of xtls-rprx-vision (the constant vless.XRV) swaps the plain body writer for a VisionWriter; any other flow writes TCP bytes untouched (UDP bodies are always length-framed instead, before the flow is even checked). Everything below only happens on that flow.

{
	"protocol": "vless",
	"settings": {
		"vnext": [
			{
				"users": [{ "id": "", "flow": "xtls-rprx-vision" }]
			}
		]
	},
	"streamSettings": { "security": "reality" }
}

Vision watches the first packets to confirm the inner stream is TLS 1.3

Vision does not blindly unwrap — it inspects up to the first eight packets and only trusts a genuine TLS 1.3 handshake. NewTrafficState starts NumberOfPacketToFilter at 8, and XtlsFilterTls decrements it on every buffer while pattern-matching the bytes.

proxy/proxy.go

It keys on the record prefixes: 0x16 0x03 plus handshake type 0x01 for a ClientHello, 0x16 0x03 0x03 plus handshake type 0x02 for a ServerHello. From the ServerHello it reads the negotiated cipher suite and scans the record for the TLS 1.3 supported_versions marker 00 2b 00 02 03 04. Only then does it set EnableXtls = true — and even then it refuses TLS_AES_128_CCM_8_SHA256:

if bytes.Contains(b.BytesTo(end), Tls13SupportedVersions) {
	v, ok := Tls13CipherSuiteDic[trafficState.Cipher]
	if !ok {
		v = "Old cipher: " + strconv.FormatUint(uint64(trafficState.Cipher), 16)
	} else if v != "TLS_AES_128_CCM_8_SHA256" {
		trafficState.EnableXtls = true
	}
	// …
	trafficState.NumberOfPacketToFilter = 0
	return
}

If the inner stream is TLS 1.2 or plaintext, EnableXtls stays false and Vision keeps wrapping — splicing an old, non-uniform record stream would itself be a signature.

The first packets are padded to erase the handshake’s length signature

Before any splicing, Vision reshapes the handshake packets to random lengths so the tell-tale “small ClientHello, big certificate” size pattern disappears. XtlsPadding prepends a short header and appends random filler to each buffer while IsPadding is true.

proxy/proxy.go

The wire framing is: an optional 16-byte user UUID (written once, on the first padded frame — it is the marker XtlsUnpadding matches against the session’s UUID to recognize where the padded stream begins), a 1-byte command, a 2-byte content length, a 2-byte padding length, then the real content, then the padding bytes:

[16B UUID?][cmd][contentLen hi lo][padLen hi lo][ content … ][ random padding … ]

Padding length is drawn from the account’s testseed (default {900, 500, 900, 256}): small handshake packets get a long pad — rand(500) + 900 − contentLen bytes — while later ones get up to rand(256), always capped so the whole record fits in one buffer. XtlsUnpadding on the far side reads the same header, emits the content, and drops the padding.

Once real application data appears, Vision splices the raw socket

When the first inner TLS application-data record (0x17 0x03 0x03) goes by on a TLS 1.3 stream, Vision stops touching the bytes and copies the raw socket end-to-end. In VisionWriter.WriteMultiBuffer, seeing that prefix on a complete record with EnableXtls sets switchToDirectCopy = true and sends the CommandPaddingDirect byte.

proxy/proxy.go proxy/vless/encoding/encoding.go

On its next write the VisionWriter calls UnwrapRawConn, which peels the stats / TLS / REALITY / proxy-protocol layers off the connection down to the raw TCP socket, and writes straight to it. The read side mirrors the switch: XtlsRead’s copy loop sees the direct-copy flag and hands the rest of the connection to CopyRawConnIfExist, which on Linux and Android promotes the copy to a kernel splice via tc.ReadFrom — bytes move socket to socket without a userspace copy or a second round of encryption. The inner TLS records, already valid TLS 1.3 on the wire, become the outer connection’s payload.

After the handshake, Vision unwraps the outer TLS and splices the site's own TLS records straight through — no nesting for a censor to fingerprint.
Don't

Use Vision over WebSocket or gRPC

Vision needs to reach the underlying TCP socket to splice. It rejects anything that isn’t raw TLS or REALITY — "XTLS only supports TLS and REALITY directly for now." A CDN-friendly transport re-frames the bytes and defeats the whole mechanism.

Do

Pair Vision with REALITY on raw TCP

flow: xtls-rprx-vision over security: reality (or tls) on a raw TCP stream is the intended combination: the handshake is padded, then the connection degrades to a plain spliced TLS flow that looks like an ordinary visit to the borrowed SNI.