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.
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.
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.
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.
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.
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.
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.