Skip to content

XTLS Vision

XTLS Vision устраняет двойное шифрование, когда внутренний протокол — TLS. Вместо шифрования уже зашифрованных данных TLS Application Data через внешний TLS-уровень, Vision обнаруживает завершение внутреннего TLS-рукопожатия и переключается на прямую передачу — внешний TLS лишь оборачивает внутренние TLS-записи без повторного шифрования.

Исходный код: proxy/proxy.go, proxy/vless/encoding/encoding.go

Проблема двойного шифрования

Без Vision:

[Данные приложения] → [Внутреннее TLS-шифрование] → [Кадр VLESS] → [Внешнее TLS-шифрование] → Сеть
                           ↑ зашифровано один раз                          ↑ зашифровано дважды!

С Vision:

Фаза рукопожатия: [TLS-рукопожатие] → [Паддинг Vision] → [Внешний TLS] → Сеть
Фаза данных:      [TLS App Data] → [Прямое копирование] → [Внешний TLS] → Сеть
                                      ↑ без кадрирования VLESS, без двойного шифрования

Архитектура

mermaid
flowchart TB
    subgraph Client["Клиентская сторона"]
        AppTLS["Приложение ←→ Внутренний TLS"]
        VW["VisionWriter<br/>(добавляет паддинг)"]
        VR["VisionReader<br/>(удаляет паддинг)"]
        OuterTLS["Внешнее TLS-соединение"]
    end

    subgraph Server["Серверная сторона"]
        SVR["VisionReader<br/>(удаляет паддинг)"]
        SVW["VisionWriter<br/>(добавляет паддинг)"]
        SOuterTLS["Внешнее TLS-соединение"]
        Freedom["Freedom Outbound"]
    end

    AppTLS -->|"TLS-записи"| VW
    VW -->|"кадры с паддингом"| OuterTLS
    OuterTLS -->|"зашифровано"| SOuterTLS
    SOuterTLS -->|"кадры с паддингом"| SVR
    SVR -->|"TLS-записи"| Freedom

    Freedom -->|"ответ"| SVW
    SVW -->|"кадры с паддингом"| SOuterTLS
    SOuterTLS --> OuterTLS
    OuterTLS --> VR
    VR -->|"ответ"| AppTLS

Конечный автомат состояния трафика

Vision отслеживает состояние внутреннего TLS-соединения через 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
}

Переходы состояний

mermaid
stateDiagram-v2
    [*] --> Padding: Соединение установлено
    Padding --> Padding: Пакеты TLS-рукопожатия<br/>(добавление/удаление паддинга)
    Padding --> FilterTLS: Анализ пакетов
    FilterTLS --> FilterTLS: Поиск ServerHello
    FilterTLS --> DetectedTLS13: Обнаружен TLS 1.3
    FilterTLS --> DetectedTLS12: Обнаружен TLS 1.2
    FilterTLS --> NotTLS: Не TLS-трафик

    DetectedTLS13 --> WaitAppData: EnableXtls = true
    WaitAppData --> DirectCopy: Первая запись Application Data

    DetectedTLS12 --> PaddingEnd: Завершение паддинга
    NotTLS --> PaddingEnd: Завершение паддинга

    PaddingEnd --> NormalCopy: Паддинг больше не нужен

    DirectCopy --> [*]: Прямое splice/copy<br/>(обход всего кадрирования)

Формат паддинга Vision

Во время фазы рукопожатия Vision оборачивает каждый блок данных паддингом:

+-----------+-----+-------+-------+---------+---------+
| UserUUID  | Cmd | Content Len   | Padding Len       |
| (16B,opt) | (1B)| (2B BE)       | (2B BE)           |
+-----------+-----+-------+-------+---------+---------+
| Content (contentLen bytes)      | Padding (random)  |
+---------------------------------+-------------------+
  • UserUUID: Отправляется только один раз (первый кадр), затем устанавливается в nil
  • Command: 0x00=Продолжение, 0x01=Завершение, 0x02=Прямая передача (переключение на passthrough)
  • Content: Фактические данные TLS-записи
  • Padding: Случайные байты для маскировки размеров пакетов

Расчёт размера паддинга

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

Значение testseed по умолчанию: [900, 500, 900, 256] — это означает:

  • Для содержимого < 900 байт (рукопожатие): паддинг до ~900-1400 байт
  • Для содержимого >= 900 байт: паддинг 0-256 байт

Анализ TLS (XtlsFilterTls)

Vision инспектирует первые ~8 пакетов для определения версии внутреннего TLS:

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

Паттерн Tls13SupportedVersions — это [0x00, 0x2b, 0x00, 0x02, 0x03, 0x04] — расширение supported_versions, указывающее на TLS 1.3.

VisionWriter

Writer добавляет паддинг во время рукопожатия и переключается на прямое копирование после:

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

Reader удаляет паддинг и переключается на прямое чтение:

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)
    }
}

Доступ к внутреннему состоянию TLS (клиент)

Критически важная (и спорная) часть — доступ к внутренним буферам Go TLS input и rawInput:

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

Эти внутренние буферы содержат данные, которые внешний TLS-уровень расшифровал, но приложение ещё не прочитало. При переключении на прямое копирование эти данные должны быть сначала извлечены.

Splice-копирование (CopyRawConnIfExist)

На Linux Vision может использовать splice(2) для передачи без копирования:

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

ЗначениеОписание
1Готов к splice
2Будет готов после обработки протокола
3Splice невозможен (TUN, не-raw транспорт и т.д.)

UnwrapRawConn

Для splice необходимо получить необработанное TCP-соединение под всеми обёртками:

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
}

Замечания по реализации

  1. Vision специфичен для Go: Доступ через unsafe.Pointer к внутренним буферам TLS работает только с реализацией TLS на Go. Для других языков необходимы альтернативные подходы:

    • Пользовательская реализация TLS с открытым состоянием рукопожатия
    • TLS на основе BIO (OpenSSL), где вы контролируете буфер чтения
    • Перехват уровня TLS-записей
  2. Паддинг критически важен: Без паддинга размеры записей TLS-рукопожатия раскрывают SNI и другие данные для отпечатков. Паддинг переменной длины делает записи неразличимыми.

  3. Только TLS 1.3 для прямого копирования: Vision переключается на прямое копирование только для TLS 1.3 (где Application Data зашифрованы). Для TLS 1.2 паддинг завершается, но кадрирование продолжается.

  4. Обнаружение полной записи: IsCompleteRecord() проверяет, что буфер содержит полные записи TLS Application Data (0x17 0x03 0x03 + длина). Неполные записи не должны вызывать переключение на прямое копирование.

  5. Splice требует готовности обеих сторон: И inbound, и outbound должны иметь CanSpliceCopy == 1 перед использованием splice. Переход от 2→1 происходит, когда Vision подтверждает завершение рукопожатия.

  6. Задержка 1 мс перед splice: time.Sleep(time.Millisecond) перед splice — обходное решение для редкого состояния гонки, когда TLS-стек ещё не полностью обработал последнюю запись.

  7. UserUUID в первом кадре: Первый кадр с паддингом включает 16-байтовый UserUUID для аутентификации. Последующие кадры опускают его (установлен в nil). Сервер проверяет соответствие аутентифицированному пользователю.

Технический анализ для целей повторной реализации.