ч1: Обзор. Полгода после утечки через tun0. Кто из VPN-клиентов закрыл дыру, а кто закрыл issue.
ч2: Разбор. Как мы закрывали утечку через tun0 в AmneziaVPN.
Надо отметить, что мы не имеем отношения к официальным разработчикам AmneziaVPN. Мы сторонние разработчики. Сделали PR-ы в их репозитории. И пока что их не приняли. И смотря на прошлые PR-ы в их репозитории, вероятность принятия наших PR-ов "как есть", низка. В лучшем случае, мы надеемся что наша работа поможет разработчикам AmneziaVPN и других VPN клиентов.
TL;DR
На Android у VPN-клиентов две разные утечки адреса сервера. Пароль на локальный прокси закрывает первую и никак не касается второй.
Вторую, через привязку сокета к
tun0, можно закрыть только в самом клиенте, проверяя, чьё соединение вышло из туннеля. Так сделали TeapodStream и OlConnect, и оба, судя по коду, пропускали как раз исключённые приложения, потому что про исключённое приложение Android отвечает «владелец неизвестен». TeapodStream исправил это в тот же день, когда мы ему написали, а OlConnect пока молчит.В AmneziaVPN фикс лежит в PR, которые пока не приняты. sing-box даёт правило, но его нужно вписать в конфиг руками. v2rayNG закрыл отчёт об утечке как «not planned». От WireGuard, коммерческих VPN и Google мы не нашли ни слова.
Проверить свой клиент можно одной командой из Termux.
Одна команда
Раздельное туннелирование в VPN-клиентах на Android обычно включают ради удобства: банк, госуслуги и маркетплейсы ходят в интернет напрямую, потому что через зарубежный сервер они часто не работают, а всё остальное идёт через туннель. Но у этой настройки есть вторая задача, о которой вспоминают реже. Приложение, которое вы вывели из туннеля, не должно иметь к нему доступа и тем более не должно узнать, через какой сервер вы ходите. Проверить, выполняется ли это у вас, можно за минуту.
Возьмите Android-телефон с включённым VPN и добавьте Termux в исключения раздельного туннелирования, то есть скажите VPN-клиенту, что Termux должен ходить в интернет напрямую, мимо туннеля. Теперь выполните в Termux:
curl -m 10 --interface tun0 https://ifconfig.me/ip
Если в ответ пришёл адрес вашего VPN-сервера, утечка есть: приложение, которое по вашим же настройкам в туннель не входит, только что сходило через него и узнало адрес сервера. Ни root, ни своего кода для этого не понадобилось. Если curl отвалился по таймауту, попробуйте tun1 и tun2, потому что на нашем тестовом телефоне туннель назывался tun1. Таймаут на всех трёх — хороший знак.
Termux здесь играет роль любого приложения, которое вы исключили из VPN именно затем, чтобы оно VPN не видело, и это не теоретическая угроза. Открытый детектор RKNHardering, запущенный как приложение вне VPN, проходит тем же путём. В прогоне одного из ревьюеров наших PR он дошёл до сервера через tun0, и адрес сервера девять раз попал в его отчёт.
Мы делаем фикс этой утечки для AmneziaVPN, и до фикса AmneziaVPN вёл себя так же, как все: из шести попыток обхода до сервера доходили все шесть. Пока мы писали фикс, стало интересно, что сделали остальные. Весной об утечке писали много, а с тех пор прошло полгода, так что мы прошли по трекерам и релизам 28 клиентов, заглянули в китайские и англоязычные обсуждения, поискали, что говорит Google, и прочитали код тех, кто утечку закрыл. В этой статье обзор того, что получилось. Как устроен наш фикс и сколько он стоит, мы рассказываем во второй статье.
Две утечки, которые путают
Весной на Хабре обсуждали сразу две утечки, и в комментариях их постоянно смешивали, хотя причины и лекарства у них разные. Поэтому начнём с того, чтобы их развести.
Первая утечка связана с локальным прокси. Клиенты на Xray и sing-box обычно устроены так: VPN-интерфейс забирает трафик, tun2socks разбирает его на соединения и отдаёт ядру Xray через SOCKS-порт на 127.0.0.1, часто 10808. Но loopback общий для всех приложений телефона, и если у порта нет пароля, любое приложение может подключиться к нему и ходить в интернет через ваш сервер, исключено оно из VPN или нет. Об этом 7 апреля написал runetfreedom («Из-за критической уязвимости VLESS клиентов…») и назвал восемь уязвимых клиентов: Happ, v2RayTun, V2BOX, v2rayNG, Hiddify, Exclave, Npv Tunnel и NekoBox.
Вторая утечка устроена совсем иначе, и прокси тут ни при чём. У WireGuard и AmneziaWG никакого локального порта нет, а утечка есть: на AmneziaWG мы воспроизвели её сами. Чтобы понять, откуда она берётся, нужно знать, как работает раздельное туннелирование в Android. Оно держится на маршрутизации: когда VPN-клиент говорит системе «это приложение в туннель не пускать», Android прописывает правила по UID, и у приложений внутри VPN появляется маршрут в tun0, а у остальных его нет. Обычному сокету этого достаточно, потому что без маршрута пакет просто идёт мимо туннеля.
Но начиная с Linux 5.7 любое приложение может без всяких прав привязать сокет к интерфейсу вызовом setsockopt(SO_BINDTODEVICE, "tun0"). Для такого сокета ядро тоже ищет маршрут и тоже его не находит, однако пакет после этого не бросает, а решает, что адресат где-то «на линии» за названным интерфейсом, и отправляет пакет в tun0. Дальше всё происходит честно: VPN-клиент шифрует пакет, сервер выпускает его в интернет, а сайт видит адрес сервера.
Пароль на прокси против этого не помогает, потому что пакет идёт не через прокси, а через сам туннель. Ни одна настройка Android без root это тоже не запрещает. Закрыть утечку может только VPN-клиент, у которого есть файловый дескриптор туннеля: он должен смотреть, чей пакет из него вышел, и чужие не пропускать. Как это сделать и во что мы при этом упёрлись, мы разбираем во второй статье.

Есть и третья утечка, тихая. Xray-клиент AmneziaVPN на Android 13+ исключает адрес своего сервера из маршрутов VPN, а система публикует это исключение в свойствах VPN-сети, где его может прочитать любое приложение с разрешением ACCESS_NETWORK_STATE, не отправив ни одного пакета. Это нашёл один из ревьюеров наших PR. У AmneziaWG такого маршрута нет, мы проверили это на своём телефоне, а другие клиенты на этот счёт не смотрели.
И ещё одно различие, которое в комментариях часто теряется. «Приложение видит, что у вас VPN» и «приложение узнало адрес вашего сервера» — разные вещи. Первое почти не лечится, потому что tun0 виден, выдают задержки и есть другие признаки. Второе лечится, и оно важнее: адрес сервера могут заблокировать, и тогда сервер перестанет работать у всех, кто им пользуется. Эта статья только о втором.
Как это было
Самый ранний похожий отчёт, который мы нашли, датирован сентябрём 2025 года. В нём пользователь sing-box жаловался, что банковское приложение видит прокси, хотя в include_package его не было. Механизм там не назван, телефон был с root, и issue закрыли как «not planned». Всерьёз история началась весной 2026-го, и её удобно показать таблицей:
Дата | Что случилось |
|---|---|
10 марта | runetfreedom сообщает разработчикам клиентов о локальном прокси (по его словам) |
7 апреля | статья runetfreedom. В тот же день issue в sing-box, Hiddify, Karing и Throne |
8 апреля | выходит детектор RKNHardering. Karing 1.2.16 добавляет пароль на локальный порт |
9 апреля | Throne 1.1.2: пароль на локальный порт |
10 апреля | sing-box добавляет правило |
12 апреля | TeapodStream: случайный порт и пароль (habr) |
13 апреля | AmneziaVPN: случайный порт и пароль (#2456) |
14 апреля | Anubis замораживает приложения на время работы VPN (habr) |
17 апреля | v2rayNG 2.1.0: пароль и случайный порт, по желанию. В тот же день в v2rayNG приходит отчёт про |
22 апреля | «Android. Три буквы»: Shelter не спасает (habr) |
4 мая | TeapodStream переходит на свой tun2socks с проверкой владельца соединения (habr) |
10 августа | первый фикс для AmneziaWG от makekryl (amneziawg-go#174) |
10 сентября | FlClash 0.8.97: пароль на локальный порт, по желанию |
23 сентября | наша серия PR для AmneziaVPN |
26 сентября | панель 3x-ui учится включать пароль в Happ через подписку (3x-ui#6628) |
28 сентября | TeapodStream 1.6.6 закрывает обход в режиме «Все, кроме выбранных» |
Из таблицы видно, как история распалась на две волны. Первую утечку закрывали в основном в апреле, за считанные дни после статьи, потому что исправление там простое и понятное. Со второй всё медленнее: за полгода что-то сделали четыре проекта, и у двух из них защита поначалу не закрывала главный случай.
Первая утечка: закрыли почти все, но часто «по желанию»
С локальным прокси клиенты справились быстро, но по-разному, и разница прежде всего в том, включена ли защита сама:
Клиент | Что сделали | Версия, дата | Включено по умолчанию |
|---|---|---|---|
AmneziaVPN | случайный порт и пароль при каждом подключении | #2456, 13.04 | да |
TeapodStream | случайный порт и пароль при каждом запуске | 12.04 | да |
v2rayNG | пароль и случайный порт | 2.1.0, 17.04 | нет |
Karing | пароль на mixed-порт | 1.2.16, 08.04 | не выяснили: код закрыт |
Throne | пароль на входящие | 1.1.2, 09.04 | не выяснили |
FlClash | случайные логин и пароль | 0.8.97, 10.09 | нет |
Happ | переключатель пароля в клиенте; панель 3x-ui может включить его подпиской | 3x-ui#6628, 26.09 | зависит от панели |
Hiddify | — | — |
Пароль, который надо найти в настройках и включить самому, защищает в основном тех, кто читал Хабр в апреле, а все остальные так и сидят с настройкой по умолчанию. И даже включённый пароль закрывает не всё. Exclave с версии 0.17.35 предупреждает, что если на SOCKS5 включены и пароль, и UDP, то UDP паролем не защищён и обойти его легко.
Вторая утечка: проверка владельца и её ловушка
Со второй утечкой простого исправления нет. Клиенту приходится для каждого нового соединения из туннеля спрашивать у Android, чьё оно. Метод для этого есть с Android 10, ConnectivityManager.getConnectionOwnerUid, и отвечает он только активному VPN-сервису. Схема выглядит простой: получил UID владельца, сверил со списком исключений, чужое отклонил.
Так сделали двое. TeapodStream в мае переписал tun2socks и проверяет каждое соединение (статья Wendor, код в XrayVpnService.kt), а OlConnect проверяет пакеты в своём VPN-сервисе (OlcrtcVpnService.kt).
Ловушка в том, что именно отвечает Android. Естественно ожидать, что про пакет исключённого приложения метод вернёт его UID, и дальше останется найти этот UID в списке исключений. Но ConnectivityService в AOSP, найдя сокет, делает вот что:
if (uid == INVALID_UID) return uid; // Not found. if (hasNetworkStackPermission()) return uid; final NetworkAgentInfo vpn = getVpnForUid(uid); if (vpn == null || !isVpnServiceVpn(vpn) || vpn.networkCapabilities.getOwnerUid() != mDeps.getCallingUid()) { return INVALID_UID; }
VPN-приложение узнаёт владельца, только если его VPN к этому владельцу применяется, а исключённое приложение — как раз тот случай, когда не применяется. Поэтому на его пакет метод отвечает INVALID_UID, тем же значением, что и на «сокет не найден». Наши логи это подтверждали: каждая попытка обхода от исключённого приложения приходила как «владелец неизвестен», и ни разу с настоящим UID. Для TCP есть и вторая причина, потому что сокет, привязанный к tun0, платформа не находит вовсе. Значит, правильная логика здесь одна: неизвестный владелец означает отказ.
Именно на этом, судя по коду, оба клиента и споткнулись. TeapodStream в режиме «Только выбранные», который стоит у него по умолчанию, всё делал верно, потому что неизвестного владельца нет в списке разрешённых и соединение отклоняется. А в режиме «Все, кроме выбранных» фильтр пропускал всё, чего нет в списке исключений, и -1 тоже. В комментарии в коде -1 был объяснён как пакеты от раздачи интернета, но ровно так же выглядит и обход от исключённого приложения. OlConnect пропускает INVALID_UID в любом режиме, первой же проверкой. Вдобавок он всегда пропускает запросы на порт 53, хотя DNS тоже выдаёт адрес сервера, пропускает всё, что не TCP и не UDP, и IPv6, а сама проверка, судя по коду, подключена только к их собственному протоколу OpenFlux.
Это вывод по коду: на телефоне мы эти клиенты не проверяли, потому что TeapodStream работает через Xray, а наш Xray-сервер из России не подключается, а сервера OpenFlux у нас нет. Обоим авторам мы написали не публично: в OlConnect 27 сентября, автору TeapodStream 28-го.
Автор TeapodStream ответил в тот же день и выпустил v1.6.6. Теперь в режиме «Все, кроме выбранных» соединение с неизвестным владельцем отклоняется, если в списке исключений что-то есть. Пропустить его снова можно только явной настройкой, которая разрешает раздачу интернета через туннель, и в коде честно сказано, что вместе с раздачей возвращается и обход. Автор OlConnect за четыре дня не ответил ни в issue, где мы просили закрытый канал для отчёта, ни новым релизом, так что если вы им пользуетесь, проверка из начала статьи займёт минуту.
Совсем другим путём пошёл sing-box. После issue #4009 защиты по умолчанию там не появилось, зато появился инструмент, правило package_name_regex (коммит 6746fcb, в стабильной версии с 1.14.0). С его помощью соединение, у которого sing-box не смог определить пакет, можно отклонить правилом, которое ставится первым:
"route": { "find_process": true, "rules": [ { "package_name_regex": [".*"], "invert": true, "action": "reject" } ] }
Логика та же, неизвестный владелец означает отказ. Мы это правило на устройстве не проверяли, и стоит учесть, что оно отклонит всё, для чего sing-box не нашёл пакет. Наш похожий фильтр в режиме «только выбранные» ломал Private DNS, так что после правила проверьте, что имена резолвятся.
В AmneziaVPN первый фикс для AmneziaWG предложил makekryl ещё в августе (amneziawg-go#174). Это только часть на Go, и она отбрасывает всё, что не может опознать. Наша серия от 23 сентября покрывает весь путь, от фильтра до переключателя в настройках (amneziawg-go#199, amneziawg-android#104, amnezia-client#3199), и неизвестный владелец у нас тоже означает отказ. На телефоне с включённым фильтром до сервера доходит 0 попыток обхода из 6, с выключенным все 6. Ни один из этих PR пока не принят.
Были и те, кто от исправления отказался. В v2rayNG#5499 пользователь воспроизвёл утечку той самой командой curl --interface tun0 из исключённого Termux, и issue закрыли как «not planned». В NekoBox висит #1112 с меткой «enhancement», где банк видит прокси, хотя исключён. Диагноза там нет, но симптом знакомый.
Большинство же просто молчит. Ни в трекерах, ни в блогах мы не нашли ничего об этой утечке у WireGuard for Android, Mullvad, Proton VPN, IVPN, Cloudflare WARP, Outline, Tailscale, NordVPN, Windscribe, Lantern, Psiphon, Orbot и OpenVPN for Android. «Не нашли» не значит «не сделали», но и публичного признания проблемы нет.
Молчит и Google. В AOSP мы не нашли ни бага, ни CVE, ни строчки в бюллетенях безопасности. В Android 17 появился системный экран, где пользователь выбирает приложения мимо VPN (ACTION_VPN_APP_EXCLUSION_SETTINGS), но, судя по описаниям, это новый экран выбора, а не новый механизм: об изменениях маршрутизации там ни слова. Позицию Google хорошо показывает соседний случай. В апреле исследователь показал другой обход VPN на Android 16, через системный сервис (Tiny UDP Cannon), и получил ответ «Won’t Fix (Infeasible)», а исправила его только GrapheneOS в сборке 2026050400.
Если свести всё вместе, на конец сентября картина такая:
Клиент | Как проверяет | Исключённое приложение через |
|---|---|---|
AmneziaVPN, наши PR | фильтр по владельцу, неизвестный — отказ | не проходит (проверено на телефоне), но PR не приняты |
TeapodStream ≥ 1.6.6 | фильтр по владельцу | не проходит в обоих режимах, пока не включена раздача (по коду; до 1.6.6 в режиме «Все, кроме» проходило) |
OlConnect 1.6.5 | фильтр по владельцу, только для OpenFlux | проходит (по коду; автор не ответил) |
sing-box ≥ 1.14 | правило вручную | не проходит, если правило вписано (не проверяли) |
v2rayNG | нет, «not planned» | проходит (по отчёту в #5499) |
остальные из обзора | ничего не нашли | — |
Что не помогает
В весенних обсуждениях предлагали и обходные пути, и почти все они от второй утечки не спасают. Чаще всего советовали рабочий профиль, Shelter или Island: кажется, что приложение в изолированном профиле не увидит туннель основного. Но профили Android разделяют данные и аккаунты, а сеть у них одна, и RKNHardering находит tun0 изнутри Shelter («Android. Три буквы»).
Не помогает и переименование интерфейса, потому что атакующему не нужно знать имя, он его перебирает: tun0, tun1 и так далее. Наша собственная проверка делает то же самое. На нашем телефоне с Android 16 Termux не смог получить список интерфейсов ни через if_nameindex, ни через /sys/class/net, всюду получая EACCES, а перебор имён при этом работает.
Пароль на SOCKS, как мы уже разобрали, закрывает только первую утечку. Anubis (habr) замораживает выбранные приложения, пока включён VPN, и это действительно работает, но цена понятна: банк недоступен, пока вы в туннеле.
С root вторую утечку можно закрыть правилом вроде iptables -A OUTPUT -o tun0 -m owner --uid-owner <UID> -j DROP и таким же для ip6tables. Мы это не проверяли, потому что root — отдельный разговор и большинству он недоступен.
Что делать сейчас
Прежде всего стоит проверить свой клиент командой из начала статьи, по очереди для tun0, tun1 и tun2. Она проверяет только TCP, поэтому, если хочется проверить и UDP, возьмите наш leak_probe.py. Он пробует TCP, три варианта UDP и два curl, работает в Termux без зависимостей и в конце пишет, сколько попыток из шести дошли до сервера. Запускать его нужно из приложения, исключённого из VPN.
Если у вас sing-box 1.14 или новее, впишите правило из раздела выше и прогоните проверку ещё раз.
Если нужен клиент, который закрывает утечку сам, выбор пока маленький. По коду это TeapodStream 1.6.6 и новее, если не включать в нём раздачу интернета. Для AmneziaVPN есть наша тестовая сборка strict.7. Это не официальный релиз: сборка подписана нашим ключом и ставится рядом с приложением из магазина, отдельным приложением «AmneziaVPN Strict». А если ваш клиент не защищает, напишите его разработчикам и приложите команду из начала статьи.
Тем, кто сам пишет VPN-клиент, пригодится вторая статья: там подробно о том, как устроен наш фильтр, во что мы упёрлись и что мы бы сделали с самого начала.
Чего мы не знаем
Мы не смотрели iOS, хотя в комментариях к весенним статьям об этом спрашивали: там другая модель per-app VPN, и тестового устройства у нас нет. Мы наверняка кого-то пропустили, и если ваш клиент закрывает утечку, а в обзоре его нет, напишите, мы добавим. И мы не нашли, заводил ли кто-нибудь баг в AOSP. Если заводили, поделитесь ссылкой.
Ссылки
Статьи
runetfreedom, «Из-за критической уязвимости VLESS клиентов…»: локальный прокси, 7 апреля
Wendor, «Критическая уязвимость VLESS клиентов? Подержите мое пиво…»: TeapodStream, пароль и случайный порт
Dertefter, «Android. Три буквы. Российские приложения»: методы детекта, Shelter
Wendor, «Когда пет-проект выходит из-под контроля: пишем свой tun2socks и закрываем дыры в Android VPN»: проверка владельца
Tiny UDP Cannon: другой обход VPN и ответ Google
Трекеры клиентов
AmneziaVPN
issue #2457, с которого всё началось
amneziawg-go#174 (makekryl)
наша серия: amneziawg-go#199, amneziawg-android#104, amnezia-client#3199

