Skip to content

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 encryption

Architecture

mermaid
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"| AppTLS

Traffic State Machine

Vision tracks the state of the inner TLS connection through TrafficState:

go
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

mermaid
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

go
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:

go
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:

go
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:

go
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:

go
// 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:

go
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

ValueMeaning
1Ready for splice
2Will be ready after protocol processing
3Cannot splice (TUN, non-raw transport, etc.)

UnwrapRawConn

To splice, we need the raw TCP connection underneath all the wrappers:

go
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

  1. Vision is Go-specific: The unsafe.Pointer access 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
  2. Padding is critical: Without padding, the TLS handshake record sizes reveal the SNI and other fingerprinting data. The variable-length padding makes records indistinguishable.

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

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

  5. Splice requires both ends ready: Both inbound and outbound must have CanSpliceCopy == 1 before splice is used. The transition from 2→1 happens when Vision confirms the handshake is complete.

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

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

Technical analysis for re-implementation purposes.