Всё начиналось по приколу. Недавно в сети поднялась шумиха вокруг уязвимости VLESS-клиентов: оказалось, что даже при использовании сплит-туннелирования (когда VPN включен только для избранных приложений), любое "шпионское" приложение на телефоне может узнать IP-адрес вашего VPN-сервера.
Уязвимость была тривиальной - ядро клиента открывает локальный SOCKS-прокси, который никак не защищен. Любая софтина на устройстве может постучаться в этот локальный порт и отправить пакет наружу. Ради академического интереса я написал Android-приложение TeapodStream, под капотом которого связал xray-core и tun2socks. Локальный прокси я посадил на случайный порт и закрыл динамическим паролем (подробнее об этом писал в прошлой статье, пиво кстати выдохлось)) ).

Пост получил огромный отклик у комьюнити, породил живое обсуждение и разлетелся по закладкам. Я думал, что на этом мой эксперимент закончен.
Но... я сам не заметил, как меня затянуло.
Дыра, о которой не принято говорить
После публикации поста в комментариях всплыл еще один баг безопасности. Куда более противный, и присутствует он чуть ли не во всех популярных VPN-клиентах под Android.
Симптом выглядит так: шпионское приложение, которое не добавлено в список туннелируемых, просто выполняет системный вызов, эквивалентный команде curl --interface tun0https://checkip.amazonaws.com - и пакет благополучно улетает на ваш сервер, раскрывая его IP.
Как это вообще возможно, если работает сплит-туннелинг?
Давайте заглянем под капот Android. При поднятии VPN ваше приложение через VpnService API создает сетевой интерфейс tun0. Дальше операционная система использует Policy-Based Routing (маршрутизацию на основе политик). Когда обычное приложение открывает сокет и пытается выйти в сеть, Android смотрит на его UID. Если UID есть в списке разрешенных для VPN - пакет летит в tun0, если нет - летит через дефолтный маршрут (сотовую сеть или Wi-Fi).
Но есть один нюанс. tun0 - это сетевое устройство уровня ядра системы. А ядру Linux плевать на высокоуровневые политики и Android UID. Если приложение принудительно укажет сокету параметр SO_BINDTODEVICE с именем интерфейса tun0, ядро послушно отправит пакет именно туда, в обход всех правил Android.
Раньше такой фокус требовал root-прав. Но, начиная с ядра Linux версии 5.7, этот вызов разрешили делать обычным пользовательским приложениям. Бинго!
Пишем свой tun2socks (закрываем дыры Android?)
С этой дырой невозможно бороться, просто подставив хитрые параметры запуска для обычного tun2socks или xray-core. Пакет уже провалился в туннель, его нужно отлавливать и фильтровать изнутри.
Поняв это, я принял радикальное решение: написать собственное ядро tun2socks с нуля на Go, используя gVisor (userspace сетевой стек).
Логика работы моего кастомного движка: после получения пакета из tun0, мы собираем из него 5-tuple (протокол, IP источника, порт источника, IP назначения, порт назначения). Затем мы стучимся в нативный API Android (ConnectivityManager.getConnectionOwnerUid) и спрашиваем: "А кому, собственно, принадлежит этот сокет?".
Получив UID, мы сверяем его со списками сплит-туннелинга. Любой пакет, чей UID нам не подошел или который мы вообще не смогли определить, безжалостно дропается.
На уровне протоколов это выглядит так:
С TCP всё просто. Если в туннель падает
SYN-пакет (попытка открыть соединение), проверяем UID. Если приложению нельзя в VPN - отправляем в ответRST(сброс соединения). Если можно - заворачиваем трафик в наш запароленный SOCKS.С UDP всё намного сложнее. Соединения как такового нет, это просто поток датаграмм (нет
SYN-пакетов). Нам приходится реагировать на каждый пакет. Мы сверяем UID и кэшируем связку портов, чтобы не дергать тяжелый Android API на каждую датаграмму. Плюс на этом этапе приходится делать ребинд сокетов (Strict Source Binding), чтобы UDP-пакеты не застревали в петле маршрутизации внутри самого туннеля и не утекали.С ICMP - сплошная засада. В ядре определить, от какого именно приложения идет ICMP-пакет (тот же пинг), настолько сложно и ресурсоемко, что это просто теряет смысл для мобилки. Поэтому было принято волевое решение: дать пользователю галочку "Заблокировать весь ICMP в туннеле" от греха подальше.
И оно заработало? Короткий ответ: да. Кастомный tun2socks на Go оказался очень гибким и непробиваемым для SO_BINDTODEVICE.
От пет-проекта к полноценному приложению (боли и радости)
Я был в шоке от фидбэка комьюнити. Столько слов благодарности, столько пожеланий и... столько баг-репортов.
Каждый день я старался выделять время и допиливать проект. Функционал рос: появилась маршрутизация трафика по GeoIP/GeoSite, тайл в шторке быстрых настроек, экспорт интентов (для автоматизаций через Tasker или Macrodroid), профили настроек (чтобы можно было пошарить свой конфиг близким), поддержка Always-On VPN и многое другое.
Драма с редизайном В какой-то момент я решил, что UI приложения выглядит слишком скучно, и выкатил стильное (на мой взгляд) обновление интерфейса. Реакция была мгновенной и беспощадной. В issues, в tg, на почту посыпались жалобы: "моим клиентам стало неудобно", "вынужден отказаться от вашего приложения, если не вернете старый дизайн" и всё в таком духе. В этот момент я осознал: то, что я пилил "по приколу для души", люди уже вовсю используют, в том числе в коммерческом бизнесе для своих клиентов. Местами пришлось искать компромиссы.
Битва за стабильность Но самая большая боль, работа над которой продолжается до сих пор - это стабильность vpn-соединения. Android - суровая среда. Нужно корректно обрабатывать переключения сети (Wi-Fi <-> LTE), уход телефона в глубокий сон (Doze mode) и пробуждение, запуск и остановку туннеля из шторки или автоматизаций (когда UI приложения вообще не загружается в память), поддержку heartbeats и кучу других нюансов.
Я напомню: TeapodStream не является коммерческим проектом. Занимаюсь я им в свободное от работы время. Его код открыт, я не планирую вводить монетизацию и не собираю донаты. Это просто мой способ размять мозги, повеселиться и (надеюсь) сделать интернет чуточку свободнее и безопаснее.
Исходники TeapodStream и моего кастомного tun2socks лежат на GitHub. В приложении хватает мелких багов и это скорее альфа\бета версия. Как думаете, в какую сторону развивать проект дальше?
Всем добра, и спасибо, что дочитали!
Комментарии (34)

Gambit132
04.05.2026 07:52Куда копать дальше? Думаю что все просто, куда душа хочет, туда и лопату втыкай =) Проект же для души, а не для отчетности. Можно порыться в DNS утечках возможных, пошукать kill switch, чтобы если что "тушило свет и топтало фазу".
В любом случае, спасибо за опенсорс, без попыток срубить денег на каждом чихе.

hdrover
04.05.2026 07:52В sing-box похожего можно добиться через правила маршрутизации с версии 1.14.0 https://sing-box.sagernet.org/configuration/route/rule/#package_name_regex
{ "package_name_regex": [ "^" ], "invert": true, "action": "route", "outbound": "block" }Правило сработает, если не удается распознать имя приложения (при SO_BINDTODEVICE).

barabumba
04.05.2026 07:52Интересно, спасибо за ваш труд. Скажите, в чём идея маршрутизации по GeoIP/GeoSite? На первый взгляд кажется, что это задача самого xray-core.

PATRI0T
04.05.2026 07:52Получается проблема касается только тех впн-серверов, где inbound и outbound адреса одинаковы? Если используются каскады впн, то входящий адрес сервера не опрелить через эту уязвимость?

seregina_alya
04.05.2026 07:52Определится любой сервер, через который выпустили ваш пакет в мир
Учитывая, что чаще всего это российские пакеты через сервер внутри страны и иностранные где-то ещё, у нас есть обоснованное предположение, что пакет либо будет выходить сразу, либо будет маршрутизироваться таким образом. Остальное - как крокодил в Москве, существует в паре зоопарков и у некоторых любителей.
Поэтому мы проверяем ip через амазон, проверяем через яндекс либо своё что-то - и имеем либо один, либо два адреса, которые с огромной вероятностью принадлежат впн серверам. Увернуться от такого поможет только намного более нестандартная конфигурация

vp7
04.05.2026 07:52Достаточно иметь 3 хоста - один в России, два в другой стране (или там один хост, но с двумя IP адресами).
На хосте в России стоит проброс трафика на внешний хост1 (хоть через iptables, хоть через прокси типа haproxy).
На внешнем хосте1 стоит аналогичный проброс трафика на внешний хост2.
На внешнем хосте2 стоит VPN сервер.
Самая простая и надёжная конфигурация.Если хочется посложнее и выпускать часть трафика в России - нужен второй хост в России, т.к. IP адрес хоста1 РФ будет уже засвечен и для трансграничной передачи его использовать нельзя.
Итоговая схема будет клиент => [хост 1 в России, он же выпускает часть трафика в России] => хост2 в России => хост1 вне РФ => хост2 вне РФ => выход в интернет
itshnick88
04.05.2026 07:52На хосте в России стоит проброс трафика на внешний хост1 (хоть через iptables, хоть через прокси типа haproxy).На внешнем хосте1 стоит аналогичный проброс трафика на внешний хост2.На внешнем хосте2 стоит VPN сервер.
Зачем в вашей схеме на последнем хосте (внешнем хосте2) VPN сервер?

masterthemac
04.05.2026 07:52В точке выхода нужно обратиться по целевому адресу и в обратную сторону прислать ответ. Организация такого взаимодействия с помощью ВПН и производится.
Вы же не просто до внешнего хоста2 хотите дойти, а выйти через него дальше в интернет.

itshnick88
04.05.2026 07:52Мы же до точки выхода как-то без использования ВПН дошли, зачем нам для обратной стороны нужен ВПН?
Или вы имеете ввиду, что напрямую мы идём через несколько хостов через iptables/haproxy, а обратку мы через ВПН получаем?
Или я не могу сообразить ничего)
Просто выглядит так, что у нас на последнем хосте стоит ВПН, а до него мы же как-то добрались и без ВПН (судя по "схеме", на других серваках его нет), соответственно, этому последнему серваку не нужен ВПН, чтобы запросить веб-контент и отдать его нам обратно.
Либо в этой схеме что-то не так, о чём я изначально и думаю

p0isk
04.05.2026 07:52Любой пакет, чей UID нам не подошел или который мы вообще не смогли определить, безжалостно дропается.
Интересно посмотреть лог и посмотреть на список тех приложений, которые стучаться через SO_BINDTODEVICE. И удивиться, если там будут не только российские приложения.

nidalee
04.05.2026 07:52У меня такой лог ведется в кастомном клиенте: пока только Telegram зачем-то лезет через VPN даже когда не просят, если включены MTPROTO прокси. Причем явно "проверочно", изредка один пакет.

Lev3250
04.05.2026 07:52Заметил, что teams игнорит сплит туннелинг и идёт в лоб, даже если стоит в исключениях на впн. Видать тоже что-то подобное задействуется, когда он сам выбирает, через какой интерфейс выходить в интернет

vp7
04.05.2026 07:52А можно попросить добавить поддержку ByeByeDPI для избранных приложений? :)
Появится удобная возможность в режиме split tunnel добавить youtube в один список (bye bye), другие приложения в другой (vless).

nidalee
04.05.2026 07:52Любой пакет, чей UID нам не подошел или который мы вообще не смогли определить, безжалостно дропается.
Зачем? Просто выводите его напрямую с IP девайса.

derundevu
04.05.2026 07:52А я все ждал - когда же подобное появится в клиентах или статья про это)
Я сделал подобное у себя в клиенте примерно недели 2 назад, внеся патч в hev-socks5-tunnel:
https://github.com/derundevu/yaxc
с UDP не стал даже разбираться, оставил чисто TCP
Wendor Автор
04.05.2026 07:52Оно давно появилось, просто статья появилась лишь сейчас. https://habr.com/ru/articles/1022422/#comment_29831814
А почему с UDP игнорируете? это ведь и quic, и аудио\видео связь, и гейминг и многое другое.
GoblinHero
А можно имя tun интерфейса генерировать случайное? Не tun0 а какой-нибудь xyq0? Шпиён конечно все равно список интерфейсов может получить, но так вроде меньше палимся...
Wendor Автор
Android отдаст список интерфейсов любому приложению, не важно какое там имя
hdrover
Без root нельзя. С root имя можно менять в том же sing-box прямо в интерфейсе программы.