XTLS Vision
XTLS Vision eliminates double encryption when the inner protocol is TLS. Instead of encrypting already-encrypted TLS Application Data through the outer TLS layer, Vision detects when the inner TLS handshake completes and switches to direct passthrough — the outer TLS merely wraps inner TLS records without re-encrypting them.
Source: proxy/proxy.go, proxy/vless/encoding/encoding.go
The Double Encryption Problem
Without Vision:
[App Data] → [Inner TLS Encrypt] → [VLESS Frame] → [Outer TLS Encrypt] → Network
↑ encrypted once ↑ encrypted twice!With Vision:
Handshake phase: [TLS Handshake] → [Vision Padding] → [Outer TLS] → Network
Data phase: [TLS App Data] → [Direct Copy] → [Outer TLS] → Network
↑ no VLESS framing, no double encryptionArchitecture
flowchart TB
subgraph Client["Client Side"]
AppTLS["App ←→ Inner TLS"]
VW["VisionWriter<br/>(adds padding)"]
VR["VisionReader<br/>(removes padding)"]
OuterTLS["Outer TLS Connection"]
end
subgraph Server["Server Side"]
SVR["VisionReader<br/>(removes padding)"]
SVW["VisionWriter<br/>(adds padding)"]
SOuterTLS["Outer TLS Connection"]
Freedom["Freedom Outbound"]
end
AppTLS -->|"TLS records"| VW
VW -->|"padded frames"| OuterTLS
OuterTLS -->|"encrypted"| SOuterTLS
SOuterTLS -->|"padded frames"| SVR
SVR -->|"TLS records"| Freedom
Freedom -->|"response"| SVW
SVW -->|"padded frames"| SOuterTLS
SOuterTLS --> OuterTLS
OuterTLS --> VR
VR -->|"response"| AppTLSTraffic State Machine
Vision tracks the state of the inner TLS connection through TrafficState:
type TrafficState struct {
UserUUID []byte
NumberOfPacketToFilter int // packets remaining to analyze
EnableXtls bool // inner TLS 1.3 detected
IsTLS12orAbove bool
IsTLS bool
Cipher uint16 // detected cipher suite
RemainingServerHello int32
Inbound InboundState
Outbound OutboundState
}State Transitions
stateDiagram-v2
[*] --> Padding: Connection starts
Padding --> Padding: TLS Handshake packets<br/>(add/remove padding)
Padding --> FilterTLS: Analyzing packets
FilterTLS --> FilterTLS: Looking for ServerHello
FilterTLS --> DetectedTLS13: Found TLS 1.3
FilterTLS --> DetectedTLS12: Found TLS 1.2
FilterTLS --> NotTLS: Not TLS traffic
DetectedTLS13 --> WaitAppData: EnableXtls = true
WaitAppData --> DirectCopy: First Application Data record
DetectedTLS12 --> PaddingEnd: End padding
NotTLS --> PaddingEnd: End padding
PaddingEnd --> NormalCopy: No more padding
DirectCopy --> [*]: Direct splice/copy<br/>(bypass all framing)Vision Padding Format
During the handshake phase, Vision wraps each data chunk with padding:
+-----------+-----+-------+-------+---------+---------+
| UserUUID | Cmd | Content Len | Padding Len |
| (16B,opt) | (1B)| (2B BE) | (2B BE) |
+-----------+-----+-------+-------+---------+---------+
| Content (contentLen bytes) | Padding (random) |
+---------------------------------+-------------------+- UserUUID: Sent only once (first frame), then set to nil
- Command:
0x00=Continue,0x01=End,0x02=Direct (switch to passthrough) - Content: The actual TLS record data
- Padding: Random bytes to obscure packet sizes
Padding Size Calculation
func XtlsPadding(b *buf.Buffer, command byte, userUUID *[]byte,
longPadding bool, ctx context.Context, testseed []uint32) *buf.Buffer {
contentLen := b.Len()
if contentLen < testseed[0] && longPadding {
// Long padding: rand(testseed[1]) + testseed[2] - contentLen
paddingLen = rand(testseed[1]) + testseed[2] - contentLen
} else {
// Short padding: rand(testseed[3])
paddingLen = rand(testseed[3])
}
// Cap to buffer size
if paddingLen > buf.Size - 21 - contentLen {
paddingLen = buf.Size - 21 - contentLen
}
}Default testseed: [900, 500, 900, 256] — means:
- For content < 900 bytes (handshake): pad to ~900-1400 bytes
- For content >= 900 bytes: pad 0-256 bytes
TLS Analysis (XtlsFilterTls)
Vision inspects the first ~8 packets to detect the inner TLS version:
func XtlsFilterTls(buffer, trafficState, ctx) {
for each packet {
// Look for TLS ServerHello (0x16 0x03 0x03 ... 0x02)
if bytes.Equal(TlsServerHandShakeStart, packet[:3]) &&
packet[5] == TlsHandshakeTypeServerHello {
// Found ServerHello
trafficState.RemainingServerHello = recordLength + 5
trafficState.IsTLS12orAbove = true
// Extract cipher suite
sessionIdLen := packet[43]
cipherSuite = packet[43+sessionIdLen+1 : 43+sessionIdLen+3]
trafficState.Cipher = uint16(cipherSuite)
}
// Search ServerHello for TLS 1.3 indicator
if trafficState.RemainingServerHello > 0 {
if bytes.Contains(packet, Tls13SupportedVersions) {
// TLS 1.3 confirmed!
trafficState.EnableXtls = true
}
}
}
}The Tls13SupportedVersions pattern is [0x00, 0x2b, 0x00, 0x02, 0x03, 0x04] — the supported_versions extension indicating TLS 1.3.
VisionWriter
The writer adds padding during handshake and switches to direct copy after:
func (w *VisionWriter) WriteMultiBuffer(mb) error {
if *switchToDirectCopy {
// Unwrap all encryption layers
rawConn, _, writerCounter := UnwrapRawConn(w.conn)
w.Writer = buf.NewWriter(rawConn)
return w.Writer.WriteMultiBuffer(mb)
}
if *isPadding {
// Check for TLS Application Data (0x17 0x03 0x03)
for each buffer {
if isTLS && isApplicationData && isCompleteRecord {
if EnableXtls {
*switchToDirectCopy = true
command = CommandPaddingDirect // tell peer to switch
} else {
command = CommandPaddingEnd
}
buffer = XtlsPadding(buffer, command, ...)
*isPadding = false
} else {
buffer = XtlsPadding(buffer, CommandPaddingContinue, ...)
}
}
}
return w.Writer.WriteMultiBuffer(mb)
}VisionReader
The reader removes padding and switches to direct reading:
func (w *VisionReader) ReadMultiBuffer() (buf.MultiBuffer, error) {
buffer, err := w.Reader.ReadMultiBuffer()
if *switchToDirectCopy {
return buffer, err // already in direct mode
}
if *withinPaddingBuffers {
// Remove padding from each buffer
for each buffer {
newbuffer = XtlsUnpadding(buffer, trafficState, ...)
}
// Check command
if command == 0 { continue padding }
if command == 1 { stop padding, normal copy }
if command == 2 { stop padding, switch to DIRECT COPY }
}
if *switchToDirectCopy {
// Drain TLS internal buffers (input + rawInput)
inputBuffer = buf.ReadFrom(w.input)
rawInputBuffer = buf.ReadFrom(w.rawInput)
buffer = merge(buffer, inputBuffer, rawInputBuffer)
// Switch to raw connection reader
readerConn, readCounter, _ := UnwrapRawConn(w.conn)
w.Reader = buf.NewReader(readerConn)
}
}Accessing TLS Internal State (Client)
The critical (and controversial) piece — accessing Go's TLS input and rawInput buffers:
// In VLESS outbound:
if tlsConn, ok := iConn.(*tls.Conn); ok {
t = reflect.TypeOf(tlsConn.Conn).Elem()
p = uintptr(unsafe.Pointer(tlsConn.Conn))
} else if utlsConn, ok := iConn.(*tls.UConn); ok {
t = reflect.TypeOf(utlsConn.Conn).Elem()
p = uintptr(unsafe.Pointer(utlsConn.Conn))
} else if realityConn, ok := iConn.(*reality.UConn); ok {
t = reflect.TypeOf(realityConn.Conn).Elem()
p = uintptr(unsafe.Pointer(realityConn.Conn))
}
i, _ := t.FieldByName("input") // *bytes.Reader
r, _ := t.FieldByName("rawInput") // *bytes.Buffer
input = (*bytes.Reader)(unsafe.Pointer(p + i.Offset))
rawInput = (*bytes.Buffer)(unsafe.Pointer(p + r.Offset))These internal buffers contain data that the outer TLS layer has decrypted but the application hasn't read yet. When switching to direct copy, this data must be drained first.
Splice Copy (CopyRawConnIfExist)
On Linux, Vision can use splice(2) for zero-copy transfer:
func CopyRawConnIfExist(ctx, readerConn, writerConn, writer, timer, inTimer) error {
readerConn, readCounter, _ = UnwrapRawConn(readerConn)
writerConn, _, writeCounter = UnwrapRawConn(writerConn)
// Check splice eligibility
if runtime.GOOS != "linux" { return readV(...) }
tc, ok := writerConn.(*net.TCPConn)
if !ok { return readV(...) }
if inbound.CanSpliceCopy == 3 { return readV(...) }
// Wait for both sides to be ready
for {
if inbound.CanSpliceCopy == 1 && ob.CanSpliceCopy == 1 {
// SPLICE! Zero-copy kernel transfer
w, err := tc.ReadFrom(readerConn)
return err
}
// Not ready yet, do normal copy
buffer, err := reader.ReadMultiBuffer()
writer.WriteMultiBuffer(buffer)
}
}CanSpliceCopy Values
| Value | Meaning |
|---|---|
| 1 | Ready for splice |
| 2 | Will be ready after protocol processing |
| 3 | Cannot splice (TUN, non-raw transport, etc.) |
UnwrapRawConn
To splice, we need the raw TCP connection underneath all the wrappers:
func UnwrapRawConn(conn) (net.Conn, readCounter, writeCounter) {
// Peel layers: encryption → stats → TLS/uTLS/REALITY → proxyproto → unix
if commonConn, ok := conn.(*encryption.CommonConn); ok { conn = commonConn.Conn }
if xorConn, ok := conn.(*encryption.XorConn); ok { return xorConn } // full-random: don't penetrate
if statConn, ok := conn.(*stat.CounterConnection); ok { conn = statConn.Connection }
if tlsConn, ok := conn.(*tls.Conn); ok { conn = tlsConn.NetConn() }
// ... utls, reality, proxyproto, unix
return conn
}Implementation Notes
Vision is Go-specific: The
unsafe.Pointeraccess to TLS internal buffers only works with Go's TLS implementation. Other languages need alternative approaches:- Custom TLS implementation with exposed handshake state
- BIO-based TLS (OpenSSL) where you control the read buffer
- Hook the TLS record layer
Padding is critical: Without padding, the TLS handshake record sizes reveal the SNI and other fingerprinting data. The variable-length padding makes records indistinguishable.
TLS 1.3 only for direct copy: Vision only switches to direct copy for TLS 1.3 (where Application Data is encrypted). For TLS 1.2, padding ends but framing continues.
Complete record detection:
IsCompleteRecord()checks that the buffer contains complete TLS Application Data records (0x17 0x03 0x03 + length). Partial records should not trigger the direct copy switch.Splice requires both ends ready: Both inbound and outbound must have
CanSpliceCopy == 1before splice is used. The transition from 2→1 happens when Vision confirms the handshake is complete.1ms sleep before splice: The
time.Sleep(time.Millisecond)before splice is a workaround for a rare race condition where the TLS stack hasn't fully processed the last record.UserUUID in first frame: The first padding frame includes the 16-byte UserUUID for authentication. Subsequent frames omit it (set to nil). The server verifies this matches the authenticated user.