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, без двойного шифрованияАрхитектура
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:
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
}Переходы состояний
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: Случайные байты для маскировки размеров пакетов
Расчёт размера паддинга
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:
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 добавляет паддинг во время рукопожатия и переключается на прямое копирование после:
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 удаляет паддинг и переключается на прямое чтение:
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:
// 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) для передачи без копирования:
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 | Будет готов после обработки протокола |
| 3 | Splice невозможен (TUN, не-raw транспорт и т.д.) |
UnwrapRawConn
Для splice необходимо получить необработанное TCP-соединение под всеми обёртками:
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
}Замечания по реализации
Vision специфичен для Go: Доступ через
unsafe.Pointerк внутренним буферам TLS работает только с реализацией TLS на Go. Для других языков необходимы альтернативные подходы:- Пользовательская реализация TLS с открытым состоянием рукопожатия
- TLS на основе BIO (OpenSSL), где вы контролируете буфер чтения
- Перехват уровня TLS-записей
Паддинг критически важен: Без паддинга размеры записей TLS-рукопожатия раскрывают SNI и другие данные для отпечатков. Паддинг переменной длины делает записи неразличимыми.
Только TLS 1.3 для прямого копирования: Vision переключается на прямое копирование только для TLS 1.3 (где Application Data зашифрованы). Для TLS 1.2 паддинг завершается, но кадрирование продолжается.
Обнаружение полной записи:
IsCompleteRecord()проверяет, что буфер содержит полные записи TLS Application Data (0x17 0x03 0x03 + длина). Неполные записи не должны вызывать переключение на прямое копирование.Splice требует готовности обеих сторон: И inbound, и outbound должны иметь
CanSpliceCopy == 1перед использованием splice. Переход от 2→1 происходит, когда Vision подтверждает завершение рукопожатия.Задержка 1 мс перед splice:
time.Sleep(time.Millisecond)перед splice — обходное решение для редкого состояния гонки, когда TLS-стек ещё не полностью обработал последнюю запись.UserUUID в первом кадре: Первый кадр с паддингом включает 16-байтовый UserUUID для аутентификации. Последующие кадры опускают его (установлен в nil). Сервер проверяет соответствие аутентифицированному пользователю.