golang

Lionheart: как я спрятал SOCKS5-туннель внутри видеоконференции Wildberries

  • среда, 1 апреля 2026 г. в 00:00:07
https://habr.com/ru/articles/1017410/

Привет, Хабр. Я написал SOCKS5-прокси, который прячет весь трафик внутри WebRTC TURN-сессий видеоплатформы WB Stream (stream.wb.ru — да, у Wildberries есть свой аналог Телемоста, я тоже прифигел). Для любого наблюдателя трафик выглядит как обычная видеоконференция.

В этом посте — полный разбор: как я отреверсил HTTP API платформы, зачем написал свой protobuf-парсер на 40 строк, и как KCP-соединение с VPS пролезает через чужой TURN-сервер.

Репозиторий: github.com/jaykaiperson/lionheart

Дисклеймер. Проект написан для изучения WebRTC, TURN и KCP. Автор не рекомендует использовать его для обхода сетевых ограничений. То, что туннель пробивает DPI, файрволы и белые списки на гигабитных скоростях — непредвиденный побочный эффект архитектуры. Если знаете, как технически ограничить эту возможность — пишите в issues, буду искренне рад.

Идея

Концепция не новая. Есть отличный vk-turn-proxy, который делает похожую штуку с TURN-серверами VK Calls и Яндекс Телемоста. Там можно завернуть WireGuard или Hysteria через TURN, есть параллельные стримы, гибкая настройка. Но для запуска нужно вручную генерировать invite-ссылку на звонок, передавать её через флаги, разбираться с параметрами.

Мне хотелось сделать вещь, которая работает в одну команду. Запустил бинарник на сервере — получил ключ. Вставил ключ на клиенте — прокси работает. Без ссылок, без регистрации, без API-ключей. Всё автоматом. Моя цель — сделать массовое решение, которое сможет настроить каждый.

Ключевое решение — не запускать браузер. Первый прототип использовал headless Chrome через chromedp: заходил на сайт, кликал кнопки, перехватывал ICE-серверы из RTCPeerConnection. Это работало, но старт занимал 30 секунд, бинарник весил 50 МБ, и на серверах без GUI требовался установленный Chrome. Я сел реверсить API и выяснил, что браузер вообще не нужен.

Реверс API stream.wb.ru

Для начала я написал сниффер — puppeteer-скрипт, который открывал настоящий Chrome с вкладками на нескольких видеоплатформах. Я вручную создавал встречи, а скрипт молча логировал все HTTP-ответы и перехватывал WebRTC через monkey-patching RTCPeerConnection и WebSocket. Никакой автоматики — просто пассивный сбор данных.

Результат по WB Stream:

[API] 200 /auth/api/v1/auth/user/guest-register
[API] 200 /api-room/api/v2/room
[API] 200 /api-room/api/v1/room/{id}/join
[API] 200 /api-room-manager/api/v1/room/{id}/token
[WS]  wss://wbstream01-el.wb.ru:7880/rtc?access_token=...
[ICE] turn:wb-stream-turn-1.wb.ru:3478 user=eeaMmFicg5GYwVhscg2R pass=xtj4wgmXKcfu1Y6ulhg8

Четыре HTTP-запроса и один WebSocket. Главная страница stream.wb.ru возвращает 498 на запрос без браузера, но API-эндпоинты отвечают нормально — никаких cookies, CSRF, капч, проверок TLS fingerprint.

Дальше я воспроизвёл весь флоу на чистом Go http.Client.

Шаг 1 — регистрация гостя. POST с произвольным displayName. В ответ — JWT.

rr, _ := wb(cl, "POST", "/auth/api/v1/auth/user/guest-register",
    []byte(`{"displayName":"lh_42"}`), "")
// → {"accessToken":"eyJhbGciOiJIUzI1NiIs..."}

Шаг 2 — создание комнаты. Тут я споткнулся. Пустой {} в теле отдаёт 400: invalid CreateRoomV2Request.RoomType: value must not be in list [ROOM_TYPE_UNSPECIFIED]. Починил — и тут же второй 400: invalid CreateRoomV2Request.RoomPrivacy. Значения нашёл в дампе существующей комнаты.

wb(cl, "POST", "/api-room/api/v2/room",
    []byte(`{"roomType":"ROOM_TYPE_ALL_ON_SCREEN","roomPrivacy":"ROOM_PRIVACY_FREE"}`),
    token)
// → {"roomId":"019d2564-5f22-7af9-bce4-8799f61ca569"}

Шаг 3 — вход в комнату. POST, не GET. На GET возвращается 501 Method Not Allowed — ещё один грабль.

wb(cl, "POST", fmt.Sprintf("/api-room/api/v1/room/%s/join", roomID),
    []byte("{}"), token)

Шаг 4 — LiveKit-токен. GET-запрос с query-параметрами. Первая попытка вернула бинарный мусор — оказалось, Go http.Client по умолчанию посылает Accept-Encoding: gzip, сервер честно жмёт ответ, а Go не распаковывает автоматически, потому что я задал кастомный Transport. Убрал заголовок — заработало.

wb(cl, "GET", fmt.Sprintf(
    "/api-room-manager/api/v1/room/%s/token?deviceType=PARTICIPANT_DEVICE_TYPE_WEB_DESKTOP&displayName=lh_42",
    roomID), nil, token)
// → {"roomToken":"eyJleHAiOjE3NzQ0NDQwODQs..."}

Декодирую payload JWT — и вижу "iss":"APIefx2BJbD3hvw". Это ключ LiveKit API. WB Stream работает на LiveKit — open-source WebRTC SFU. Протокол документирован, исходники открыты. Это сильно упростило следующий шаг.

LiveKit, WebSocket и самодельный protobuf

LiveKit общается по WebSocket на protobuf. Клиент подключается к wss://wbstream01-el.wb.ru:7880/rtc с токеном в query string, и сервер сразу присылает SignalResponse с JoinResponse внутри — там параметры комнаты, участники и, главное, ICE-серверы.

u := "wss://wbstream01-el.wb.ru:7880/rtc?access_token=" + token +
    "&auto_subscribe=1&sdk=js&version=2.15.3&protocol=16&adaptive_stream=1"
conn, _, _ := websocket.Dial(u, headers)

Параметры sdk=js&version=2.15.3&protocol=16 — копия того, что посылает браузерный SDK. С protocol=13 сервер молчит. С protocol=16 — сразу отвечает. Подбирал экспериментально.

Теперь нужно распарсить protobuf. Тащить google.golang.org/protobuf, генерить код из .proto-файлов, подтягивать весь livekit/protocol — перебор для трёх вложенных полей. Мне нужна вот эта структура:

SignalResponse {
    field 1 (bytes) = JoinResponse {
        field 5 (repeated bytes) = ICEServer {
            field 1 (repeated string) = urls
            field 2 (string) = username
            field 3 (string) = credential
        }
    }
}

Protobuf wire format устроен не так страшно, как кажется. Каждое поле начинается с varint-тега: (field_number << 3) | wire_type. Wire type 0 — varint, 2 — length-delimited (строки, байты, вложенные сообщения). Для type=2 после тега идёт varint длины и сами байты. Varint — little-endian по 7 бит, старший бит — флаг продолжения.

Парсер varint на Go:

func pbVar(d []byte, o int) (uint64, int) {
    var v uint64
    for s := 0; o < len(d) && s < 64; s += 7 {
        b := d[o]; o++
        v |= uint64(b&0x7f) << s
        if b < 0x80 { return v, o }
    }
    return 0, o
}

Функция для извлечения всех полей с нужным номером:

func pbAll(d []byte, f uint64) (r [][]byte) {
    for o := 0; o < len(d); {
        t, n := pbVar(d, o)
        if n == o { break }
        o = n
        switch t & 7 {
        case 0: _, o = pbVar(d, o)           // varint — пропускаем
        case 2:                                // length-delimited
            l, n := pbVar(d, o); o = n
            e := o + int(l)
            if e > len(d) || e < o { return }
            if t>>3 == f { r = append(r, d[o:e]) }
            o = e
        case 1: o += 8                        // fixed64
        case 5: o += 4                        // fixed32
        default: return
        }
    }
    return
}

И хелпер для строки: func pbStr(d []byte, f uint64) string.

Этих трёх функций хватает, чтобы добраться до ICE-серверов. Но тут был ещё один сюрприз. Парсер молча возвращал пустой результат — ICE-серверы не находились. Я добавил отладочный дамп всех полей JoinResponse:

field=1 bytes len=210    — Room
field=2 bytes len=724    — Participant
field=4 bytes len=5      — "1.9.0"
field=5 bytes len=92     — ← ICEServer!
field=5 bytes len=92     — ← ICEServer!
field=5 bytes len=93     — ← ICEServer!
field=5 bytes len=34     — ← ICEServer (STUN)
field=6 varint=1

Field 5. В документации LiveKit ICE-серверы — field 9. У WB Stream — field 5. Видимо, старая версия LiveKit или кастомный форк. Парсер теперь проверяет оба варианта:

for _, f := range []uint64{5, 9} {
    for _, blk := range pbAll(inner, f) {
        urls := pbAll(blk, 1)
        // проверяем наличие turn:/stun: в urls
        un, pw := pbStr(blk, 2), pbStr(blk, 3)
        // собираем результат
    }
}

Путь пакета

Что происходит, когда вы открываете google.com через lionheart:

Браузер шлёт SOCKS5 CONNECT на 127.0.0.1:1080. Клиент lionheart принимает TCP-соединение и открывает yamux-стрим через KCP-сессию. KCP пакует данные в UDP, шифрует AES-256 и отправляет на relay-адрес TURN-сервера wb-stream-turn-1.wb.ru:3478. TURN-сервер WB Stream ретранслирует UDP-пакет на VPS. На VPS KCP расшифровывает, yamux демультиплексирует, SOCKS5-сервер выпускает TCP к google.com. Ответ идёт обратно той же цепочкой.

Для DPI — это UDP-пакеты к TURN-серверу Wildberries. Штатный WebRTC-трафик.

Установка туннеля через TURN-relay:

// свободный UDP-порт (не pre-connected, иначе pion/turn ругается)
uc, _ := net.ListenUDP("udp", nil)

// авторизация на TURN с перехваченными кредами
tc, _ := turn.NewClient(&turn.ClientConfig{
    STUNServerAddr: addr, TURNServerAddr: addr,
    Conn: uc, Username: cred.User, Password: cred.Pass,
})
tc.Listen()
relay, _ := tc.Allocate()

// KCP через relay
blk, _ := kcp.NewAESBlockCrypt(key(password))
kc, _ := kcp.NewConn(peerVPS, blk, 10, 3, relay)
kc.SetNoDelay(1, 10, 2, 1)
kc.SetWindowSize(1024, 1024)
kc.SetStreamMode(true)

// мультиплексор
ym, _ := yamux.Client(kc, cfg)

Красивый момент: kcp.NewConn принимает net.PacketConn, а relay от pion/turn реализует этот интерфейс. KCP вообще не знает, что под ним TURN — просто пишет и читает UDP-пакеты.

Серверная часть

Сервер — 40 строк. KCP-листенер с AES, yamux-мультиплексор, SOCKS5 на выходе:

blk, _ := kcp.NewAESBlockCrypt(key(password))
l, _ := kcp.ListenWithOptions(addr, blk, 10, 3)

for {
    s, _ := l.AcceptKCP()
    s.SetNoDelay(1, 10, 2, 1)
    s.SetWindowSize(1024, 1024)
    s.SetStreamMode(true)

    go func(session *kcp.UDPSession) {
        ym, _ := yamux.Server(session, cfg)
        for {
            stream, _ := ym.AcceptStream()
            go socks5srv.ServeConn(stream)
        }
    }(s)
}

Ключ шифрования — SHA-256 от пароля. Пароль генерируется при первом запуске и вшивается в смарт-ключ (base64 от IP|пароль). Один ключ — одна команда для настройки клиента.

Что под капотом кроме основного флоу

Переподключение. Yamux-сессия пингуется каждые 15 секунд. При обрыве — exponential backoff от 2 до 60 секунд. TURN-креды кешируются на 5 минут, после трёх неудачных попыток подключения кеш сбрасывается и весь HTTP-флоу повторяется.

Самоуправление. При запуске программа ищет свои предыдущие экземпляры через pgrep, убивает их и останавливает systemd-сервис, если он запущен. Это решает вечную проблему «порт занят». Если на VPS уже стоит systemd-служба со старым бинарником — при ручном запуске нового unit-файл перезаписывается автоматически.

Ctrl+C. Первое нажатие — graceful shutdown (cancel context, 2 секунды на cleanup). Второе — моментальный os.Exit(0).

Запуск

Go 1.22+. Chrome не нужен.

go mod tidy && go build -o lionheart .

Сервер (VPS):

./lionheart   # режим 1, копируем смарт-ключ, опционально ставим systemd

Клиент:

./lionheart   # режим 2, вставляем ключ → SOCKS5 на 127.0.0.1:1080

Адрес для телефона в той же Wi-Fi выводится в консоль автоматически.

Минусы и планы

Проект экспериментальный. TURN-креды привязаны к одной платформе — если WB Stream поменяет API или закрутит гайки, сломается. LiveKit-хост wbstream01-el.wb.ru захардкожен — если у них балансировка по разным хостам, нужно будет парсить конфиг.

Главный приоритет — мобильный клиент. Весь код — чистый Go без CGO, через gomobile компилируется под Android и iOS. Осталось написать обвязку: на Android — VpnService + TUN-интерфейс, на iOS — NEPacketTunnelProvider.

Второй приоритет — поддержка других платформ. SaluteJazz (jazz.sber.ru) отдаёт контент по HTTP без защиты и не требует регистрации, но TURN-креды приходят через WebSocket, а не HTTP. Конечно, Телемост тоже потенциальный кандидат, но там нужна регистрация.

Ссылки

Буду рад багрепортам и pr <3