ч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 добавляет правило package_name_regex (в стабильной версии — с 1.14.0, 31 августа)

12 апреля

TeapodStream: случайный порт и пароль (habr)

13 апреля

AmneziaVPN: случайный порт и пароль (#2456)

14 апреля

Anubis замораживает приложения на время работы VPN (habr)

17 апреля

v2rayNG 2.1.0: пароль и случайный порт, по желанию. В тот же день в v2rayNG приходит отчёт про tun0 — его закроют как «not planned»

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

ничего: #2117 и #2120 закрыты без исправления

—

—

Пароль, который надо найти в настройках и включить самому, защищает в основном тех, кто читал Хабр в апреле, а все остальные так и сидят с настройкой по умолчанию. И даже включённый пароль закрывает не всё. 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.

Если свести всё вместе, на конец сентября картина такая:

Клиент

Как проверяет

Исключённое приложение через tun0

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. Если заводили, поделитесь ссылкой.

Ссылки

Статьи

Трекеры клиентов

AmneziaVPN

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Каким VPN-клиентом на Android вы пользуетесь?
36.36%AmneziaVPN4
9.09%v2rayNG1
0%Hiddify0
36.36%Happ4
0%Karing0
18.18%TeapodStream2
0%Другой клиент на sing-box или Xray0
0%WireGuard0
27.27%Коммерческий VPN-сервис3
9.09%Другой1
Проголосовали 11 пользователей. Воздержавшихся нет.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Что показала проверка командой из статьи?
25%Пришёл адрес VPN-сервера: утечка есть2
37.5%Таймаут на tun0, tun1 и tun2: утечки нет3
37.5%Не проверял3
Проголосовали 8 пользователей. Воздержавшихся нет.