ч2: Разбор. Как мы закрывали утечку через tun0 в AmneziaVPN. AmneziaVPN.. AmneziaVPN. AmneziaWG.. AmneziaVPN. AmneziaWG. android.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go. JNI.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go. JNI. SO_BINDTODEVICE.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go. JNI. SO_BINDTODEVICE. split tunneling.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go. JNI. SO_BINDTODEVICE. split tunneling. tun0.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go. JNI. SO_BINDTODEVICE. split tunneling. tun0. VpnService.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go. JNI. SO_BINDTODEVICE. split tunneling. tun0. VpnService. Информационная безопасность.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go. JNI. SO_BINDTODEVICE. split tunneling. tun0. VpnService. Информационная безопасность. Сетевые технологии.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go. JNI. SO_BINDTODEVICE. split tunneling. tun0. VpnService. Информационная безопасность. Сетевые технологии. Системное программирование.. AmneziaVPN. AmneziaWG. android. getConnectionOwnerUid. Go. JNI. SO_BINDTODEVICE. split tunneling. tun0. VpnService. Информационная безопасность. Сетевые технологии. Системное программирование. утечка IP-адреса.

ч1: Обзор. Полгода после утечки через tun0. Кто из VPN-клиентов закрыл дыру, а кто закрыл issue.

ч2: Разбор. Как мы закрывали утечку через tun0 в AmneziaVPN.

TL;DR

  • Приложение, которое вы исключили из VPN на Android, может привязать сокет к tun0, пройти через ваш туннель и узнать адрес сервера. Мы закрыли это внутри клиента: когда пакет выходит из туннельного интерфейса, фильтр спрашивает у Android, чьё это соединение, и чужие не пропускает.

  • Главная ловушка в том, как Android отвечает на этот вопрос. Про исключённое приложение он говорит «владелец неизвестен», поэтому неизвестный владелец у нас означает отказ.

  • На телефоне с фильтром до сервера доходит 0 попыток обхода из 6, без него все 6. При 5000 новых потоков в секунду задержка подключения такая же, как без фильтра. Выключенный фильтр на Android стоит одно атомарное чтение на пакет, а вне Android его кода в сборке нет вовсе.

  • Код лежит в трёх PR в AmneziaVPN, которые пока не приняты. Самую неприятную ошибку мы нашли уже после ревью, в собственном мосту JNI, и исправили. Есть тестовая сборка, которая ставится рядом с приложением из магазина. В конце статьи перечислено всё, чего фильтр пока не умеет, и чек-лист для тех, кто захочет сделать то же в своём клиенте.

С чего всё началось

Раздельное туннелирование в мобильных VPN-клиентах обычно воспринимают как удобство: банк и госуслуги ходят напрямую, потому что из-за границы они не открываются, а всё остальное идёт через туннель. Но у этой настройки есть и вторая задача, о которой задумываются реже. Приложение, которое вы вывели из туннеля, не должно иметь к нему доступа и тем более не должно узнать, через какой сервер вы ходите. Адрес сервера в 2026 году — самое ценное, что есть у пользователя VPN, работающего против блокировок: если его узнали, его могут заблокировать, и тогда сервер перестанет работать у всех, кто им пользуется.

На Android VPN-клиент работает через VpnService. Система создаёт виртуальный сетевой интерфейс, обычно tun0, и отдаёт клиенту его файловый дескриптор. Всё, что приложения отправляют в туннель, клиент читает из этого дескриптора, шифрует и отправляет на сервер, а ответы пишет обратно. Какие приложения пойдут в туннель, клиент говорит системе заранее, вызовами addAllowedApplication и addDisallowedApplication, и дальше за это отвечает сама система.

Весной 2026 года выяснилось, что этого недостаточно. На ядре Linux 5.7 и новее, то есть почти на любом современном телефоне, любое приложение, даже исключённое из VPN, может вызвать setsockopt(SO_BINDTODEVICE, "tun0"), то есть привязать свой сокет к туннельному интерфейсу, и его пакеты уйдут в туннель вопреки всем настройкам. Root для этого не нужен, своего кода тоже: если Termux исключён из VPN, достаточно выполнить в нём одну команду.

curl -m 10 --interface tun0 https://ifconfig.me/ip

Если в ответ пришёл адрес VPN-сервера, утечка есть. Для AmneziaVPN об этом сообщили в issue #2457, и летом мы взялись это исправить. Что это за утечка в целом и что с ней сделали другие клиенты, мы разобрали в первой статье. Здесь речь о том, как устроен наш фикс, во что мы упёрлись по дороге и что фильтр до сих пор не умеет.

Сама проверка в AmneziaWG в итоге уместилась в одну строчку цикла, который читает пакеты из tun:

if uidfilter.Supported && !device.tun.uidGate.AllowOutboundPacket(elem.packet, device) {
	continue
}

Вокруг неё набралось полтысячи строк Go и столько же тестов, мост через JNI, переключатель в настройках клиента и три раунда ревью. Строчка оказалась самой простой частью работы, а вся статья — про остальное.

Результатом мы скорее довольны: на пути AmneziaWG обход на телефоне больше не проходит, а выключенный фильтр ничего не стоит. Но список того, что фильтр пока не умеет, получился длинным, и в конце статьи он приведён целиком. Пишем мы этот разбор по двум причинам.

Первая – утечку пока закрыли считанные клиенты. И тем, кто возьмётся за неё у себя, наш путь вместе с ошибками сэкономит время. Поэтому, в конце есть ещё и чек-лист, и заметки со всеми нашими открытиями.

Вторая – нам нужна критика. Каждое ревью находило то, что мы пропустили, а самую неприятную ошибку мы нашли у себя уже после них, так что почти наверняка что-то ещё осталось. Если вы видите у нас слабое место или знаете, как сделать лучше, напишите в комментариях.

Шесть из шести

Целью с самого начала был AmneziaWG: issue, с которого всё началось, про него. Но в AmneziaVPN два пути трафика, и фильтр нужен в обоих, так что по порядку работы первым оказался путь Xray, а мост JNI для AmneziaWG мы отложили до появления телефона. Первый код фильтра был написан ещё в июле, а проверить его на телефоне удалось только 20 сентября. На первом же запуске выяснилось, что Xray из России у нас не подключается вообще: тот же ключ не работал и в версии из магазина, так что дело было в сети, а не в нашей сборке. Проверять утечку без работающего туннеля бессмысленно, поэтому Xray мы оставили как есть и сразу перешли к AmneziaWG.

Для замеров мы написали leak_probe.py, скрипт для Termux на стандартной библиотеке Python. Он пробует шесть способов уйти через туннель, и все шесть с SO_BINDTODEVICE: TCP, три запроса по UDP (к двум STUN-серверам и DNS-запрос, в ответе на который приходит адрес спрашивающего) и два curl. Для контроля он пробует ещё привязку к адресу туннеля и обычное соединение без привязки. Первый прогон шёл на телефоне с Android 16, Termux был в исключениях AmneziaWG, а фильтра на этом пути ещё не было:

Способ

Результат

привязка к tun0: TCP, 3 × UDP, 2 × curl

6 из 6 дошли до сервера

привязка к адресу туннеля

таймаут

без привязки

домашний адрес, как и задумано

Из этой таблицы следовали две вещи, на которые фильтр и был рассчитан. Привязка к адресу туннеля никуда не ведёт, потому что Android маршрутизирует по UID приложения, а не по адресу источника, и дыра, значит, именно в привязке к устройству. А UDP течёт точно так же, как TCP, поэтому фильтру мало смотреть на TCP-соединения, ему нужно видеть все потоки.

Попутно обнаружилась мелочь, которая пригодилась позже. На этом телефоне Termux не может получить список сетевых интерфейсов: и if_nameindex, и /sys/class/net отвечают EACCES. При этом туннель назывался не tun0, а tun1, и скрипт находил его простым перебором имён. Атакующий поступит точно так же, поэтому прятать туннель под другим именем бесполезно, и это первое, что стоит ответить тем, кто предлагает так защищаться.

Почему ядро это пропускает

Прежде чем что-то чинить, хотелось понять, почему это вообще работает. Раздельное туннелирование в Android устроено как набор правил маршрутизации по UID. Когда VPN поднят, в ip rule телефона появляется правило примерно такого вида:

oif tun0 uidrange <UID приложений внутри VPN> lookup <таблица VPN>

Оно говорит: если сокет приложения из этого диапазона UID отправляет пакет в tun0, маршрут нужно искать в таблице VPN. Для приложений внутри VPN такая таблица находится, а для остальных правила нет, и fib_lookup для их сокета, привязанного к tun0, не находит ничего. Логично было бы ожидать, что пакет на этом и умрёт, но здесь срабатывает деталь ядра, которую нам объяснил один из ревьюеров. Если маршрут не нашёлся, а сокет сам назвал выходной интерфейс, ip_route_output_key_hash_rcu решает, что адресат — сосед «на линии» за этим интерфейсом, и отправляет пакет туда. Увидеть это можно одной командой из adb shell, если VPN работает в режиме «только выбранные приложения» и shell (UID 2000) в него не входит:

ip route get <адрес> oif tun0 uid 2000

В ответе будет dev tun0 без всякой таблицы, тогда как для UID из списка была бы таблица VPN. Отличить такие пакеты от законных невозможно: у них тот же адрес источника и никакой метки. А поменять правила маршрутизации или поведение ядра VPN-приложение без root не может.

Всё это касается пути IPv4. Ревьюер, читая код ядра 6.1, такого отката для IPv6 не нашёл, но сам IPv6 не проверял. Мы тоже не смогли: у нашего сервера нет IPv6, и туннель его не везёт.

Где это можно чинить

Раз ядро ведёт себя так, выбор мест для исправления оказался невелик. Штатное исключение приложений, addDisallowedApplication, отпадает сразу, потому что именно его и обходят. Запретить приложению привязываться к интерфейсу тоже нельзя: чужие сокеты VPN-приложению недоступны, а настроек, которые бы это запрещали, у Android нет. С root дыру закрыл бы iptables с -m owner, по правилу на каждое приложение, но root есть у немногих, и строить защиту на нём нельзя. Можно было бы ничего не делать и описать проблему в документации, но для VPN, который работает против блокировок, адрес сервера — ровно то, что отдавать нельзя.

Остаётся единственное место, через которое проходит каждый пакет, привязанный к tun0: код, который читает файловый дескриптор туннеля. Там их и нужно отсеивать.

В комментариях к статье про TeapodStream спрашивали, почему такие пакеты отбрасывать, а не пускать мимо туннеля, ведь молча брошенный пакет сам по себе может быть сигналом. Для AmneziaWG пустить мимо означало бы затащить в клиент полноценный TCP/IP-стек, чтобы завершать чужие соединения и открывать их заново через обычную сеть. На пути Xray такой стек есть, но тогда клиент становится прокси для приложения, которое вы как раз хотели держать подальше. Да и узнать, что VPN включён, приложение может многими другими способами. Скрываем мы адрес сервера, и отказ его не выдаёт.

Кто владелец пакета

Чтобы отсеивать чужие пакеты, фильтр должен для каждого нового соединения узнать, какое приложение его открыло. Первая мысль была сделать это прямо в Go, рядом с циклом чтения: разобрать /proc/net/tcp, найти нужный сокет и взять его UID. На Android 10 и новее так не выйдет, потому что /proc/net/tcp показывает процессу только его собственные сокеты. Таблица при этом выглядит вполне правдоподобно, и легко потратить время, прежде чем заметить, что нужного соединения в ней не бывает никогда. Netlink-запрос SOCK_DIAG из Go тоже не помогает, у него нет нужных привилегий.

Привилегированный путь на Android один: ConnectivityManager.getConnectionOwnerUid(protocol, local, remote), появившийся в API 29. Отвечает он системному сетевому стеку и приложению, которому принадлежит активный VpnService, то есть нам. Но это Java API, и из этого выросла вся структура фильтра. Go знает механизм и ничего не знает о политике: он разбирает пакет, собирает 5-кортеж из протокола, адресов и портов, спрашивает через мост и подчиняется ответу. Решение принимает Kotlin. Упрощённо, без логов, оно выглядит так:

fun allow(network: String, srcIp: String, srcPort: Int, dstIp: String, dstPort: Int): Boolean {
    val uid = resolveUid(network, srcIp, srcPort, dstIp, dstPort)
    if (uid == INVALID_UID) return false  // владелец неизвестен или вне нашего VPN
    if (uid == ownUid) return true
    return when (mode) {
        SplitTunnelMode.INCLUDE -> appIdOf(uid) in appIds
        SplitTunnelMode.EXCLUDE -> appIdOf(uid) !in appIds
    }
}

Самая важная строчка здесь вторая, и она совсем не очевидна. Естественно ожидать, что про соединение исключённого приложения метод вернёт его 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. Причин на самом деле две. Найденного владельца вне VPN платформа скрывает, как видно из фрагмента выше. А TCP-сокет, привязанный к tun0, она не находит вовсе, о чём подробнее в разделе про ограничения, и UDP-сокет находит только запасным путём и тоже скрывает. Получается, что в режиме «все, кроме выбранных» обход отсекает не сравнение со списком, а отказ при неизвестном владельце, и если неизвестных разрешить, пропустишь ровно тот обход, ради которого всё затевалось. В первой статье мы разбирали два клиента, которые, судя по коду, на этом споткнулись. Автор одного из них, TeapodStream, исправил это в тот же день, когда мы ему написали.

Отказ при неизвестном владельце не бьёт по законному трафику, и причина простая. Пакет без сокета-владельца может отправить только сырой сокет, а для него нужен CAP_NET_RAW, то есть root, а на телефоне с root никакая защита в пространстве пользователя уже не поможет. У пакета, который приложение отправило без root, сокет-владелец есть всегда, и его соединение разрешится. Почти всегда: несколько исключений мы разберём в конце. Отдельный случай составляет раздача интернета, если прошивка пускает её через VPN, потому что у пересланных пакетов владельца на телефоне нет и строгий режим их отбросит. Этого мы не проверяли.

Из той же логики следует второе правило: ни один пакет потока не должен уйти в туннель, пока нет ответа. Соблазнительно пропускать пакеты, пока Kotlin думает, и вынести решение потом. Но адрес сервера виден уже по первому пакету, дошедшему до чужого сервера, так что такая гонка была бы не вопросом производительности, а дырой.

Два пути трафика, два хука

В AmneziaVPN для Android трафик ходит двумя путями, и общего у них только файловый дескриптор туннеля, поэтому фильтр пришлось встраивать дважды, и в двух разных формах.

Путь Xray идёт через tun2socks. Стек gVisor собирает из пакетов соединения и отдаёт их в SOCKS-порт Xray, а форвардеры gVisor получают 5-кортеж ещё до того, как соединение создано. Туда мы и поставили хук. На этом пути один вопрос приходится на одно соединение, и при отказе TCP получает RST, так что connect у приложения сразу падает. Всё это мы знаем по коду, на телефоне путь Xray мы так и не запустили.

AmneziaWG устроен иначе. Его RoutineReadFromTUN в amneziawg-go берёт из tun сырые пакеты, шифрует и отправляет пиру. Соединений здесь нет, есть только пакеты, поэтому хук сам разбирает заголовки и держит кэш решений, чтобы не спрашивать Kotlin про каждый пакет. Стоит он сразу после строки elem.padding = padding. Эту строку upstream вписал в тот же промежуток кода, где живёт наш хук, и при ребейзе git может поставить хук строчкой выше, где он тоже компилируется и работает, но до того, как элемент заполнен целиком. Сейчас это ничего не ломает, но такой порядок хрупкий. Полагаться на внимательность человека при каждом ребейзе мы не стали и поставили pre-push хук, который такой пуш отклоняет.

ч2: Разбор. Как мы закрывали утечку через tun0 в AmneziaVPN - 1

Путь пакета и два хука

Мост между Go и Kotlin на пути AmneziaWG тоже пришлось писать руками. Xray связан с Kotlin через gomobile, который генерирует привязки сам, а libwg-go.so — обычная cgo-библиотека с JNI-функциями в jni.c. Вопрос «чей это пакет» приходит из горутины Go, то есть из потока, которого JVM никогда не видела, и отсюда растут все правила моста. Поток подключается к JVM один раз, как демон, а отключается деструктором pthread_key, когда завершается. Каждый вызов обёрнут в PushLocalFrame и PopLocalFrame, потому что поток, который никогда не возвращается в Java, иначе копил бы локальные ссылки. Метод allow ищется при регистрации через GetObjectClass, а не через FindClass: из нативного потока FindClass видит только системный загрузчик классов и классов приложения не находит. И если что-то пошло не так, поток не удалось присоединить, Java бросила исключение или фильтр не зарегистрирован, ответ всегда отказ.

Ошибки JNI компилируются молча и проявляются только во время работы. Мост заработал с первой сборки, но только потому, что эти ловушки мы расписали заранее. Через неделю, когда поиск владельца переехал на воркеры, одна из этих ловушек всё-таки сработала, и о ней будет отдельный раздел.

Отказ по умолчанию и дыра через ping

Первая версия фильтра пропускала всё, что не TCP и не UDP. Рассуждали мы так: getConnectionOwnerUid умеет искать владельцев только этих двух протоколов, а остальное проверить нечем. Ошибку в этом рассуждении показал чужой PR. В августе makekryl предложил собственный фикс для AmneziaWG (amneziawg-go#174), и тот отбрасывал всё, что не мог опознать. Сравнивая подходы перед отправкой своих PR, мы задались вопросом, что будет с ICMP, и проверили.

Оказалось, что Android разрешает любому приложению открыть ping-сокет без всяких прав, socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP), и SO_BINDTODEVICE работает и на нём. Приложение вне VPN пинговало через tun0 и получало ответы с задержкой туннеля, а фильтр молчал, потому что его никто не спрашивал. Значит, адресат видел эти запросы с адреса сервера.

С тех пор, пока фильтр включён, отбрасывается всё, что нельзя приписать владельцу: другие протоколы, фрагменты IPv4 и пакеты IPv6 с extension headers. Цена у этого есть: в строгом режиме у разрешённых приложений не работают ping и traceroute в режиме ICMP (-I). Обычный traceroute шлёт UDP и работает, со строгим режимом он дошёл до 1.1.1.1 за 10 хопов. Проверяется всё это icmp_probe.py: с фильтром echo через tun0 уходит в таймаут, без фильтра отвечает.

Судим только SYN

Следующая ошибка нашлась на второй день проверок, когда мы включили режим «только выбранные приложения». Chrome был в списке, страницы открывались, а фильтр при этом отклонял сотни его соединений в минуту к обычным сайтам на порту 443, и все с «владелец неизвестен». Это было похоже на обход, но в SYN_SENT ничего не висело. Тогда мы опросили /proc/net/tcp из adb shell, откуда видны сокеты всех UID, и увидели сокеты в состояниях FIN_WAIT1, LAST_ACK и CLOSING с UID 0, к тем же самым адресам.

Объяснение нашлось в том, как ядро обращается с закрытыми сокетами. Когда приложение вызывает close(), ядро отвязывает сокет от процесса, и для sock_diag его владельцем становится UID 0. Хук AmneziaWG тогда судил каждый пакет и помнил решение 10 секунд, поэтому если соединение простаивало дольше, его FIN судился заново. В режиме «только выбранные» UID 0 находится вне VPN, платформа отвечает INVALID_UID, и FIN отбрасывался. В режиме «все, кроме выбранных» UID 0 внутри VPN, и поэтому первые прогоны ничего такого не показали.

Мы стали судить TCP только по пакету с флагом SYN. Соединения, чей SYN отклонён, просто не существует, а значит, все последующие пакеты заведомо принадлежат разрешённому соединению, и спрашивать про них незачем. Путь Xray этой ошибки не знал с самого начала, потому что форвардер gVisor спрашивает один раз, при открытии соединения.

Первое ревью: часы дороже кэша

PR мы отправили 23 сентября, и первое ревью пришло от makekryl через час. Замечаний было два: проверка оставалась в сборках не для Android, а цена на пакет, по его словам, получалась серьёзной. Первое было справедливо, и о нём речь пойдёт в разделе про выключенный фильтр. Со вторым мы сначала решили померить, прежде чем спорить.

Замер дал неожиданный результат. Попадание в кэш стоило 114 нс при 8,5 нс на разбор пакета (go test -bench на ноутбуке с i5-4210U, linux/amd64; на телефоне абсолютные цифры другие). Из этих 114 нс 81 уходило на time.Now(), потому что кэш сверял срок жизни записи с часами на каждом поиске. Часы в Go не бесплатны: это вызов vDSO, а на некоторых машинах, в том числе в WSL, он заметно дороже, чем на железе.

Первая правка читала часы раз на 64 поиска, и только на втором ревью выяснилось, что это была ошибка. Пока туннель простаивает, счётчик поисков не растёт, и после долгой паузы просроченный вердикт мог действовать ещё до 63 пакетов. Теперь часы продвигает отдельный тикер раз в секунду, и чтение часов стало одним атомарным чтением. Вместе с отказом от мьютекса и более компактным ключом путь пакета UDP из кэша стал стоить 62 нс вместо 152, а пакет установленного TCP-соединения стоит 14 нс.

Второе ревью: как остановить весь туннель

Второе ревью оказалось серьёзнее. Ревьюер собрал всю серию на своём Galaxy S24 FE, подтвердил, что обход закрыт и для TCP, и для UDP, с connect и без, и нашёл то, чего не нашли мы.

Проверка владельца выполнялась прямо на потоке, который читает tun. Пока Kotlin отвечал про новый поток, туннель стоял для всех остальных, а цена ответа зависит от самого ответа. getConnectionOwnerUid открывает netlink-сокет на каждый вызов. Если сокет находится точным запросом, это два дешёвых запроса, но если нет, например потому что сокет закрыли сразу после отправки, для UDP к ним добавляются два дампа всей таблицы сокетов. Выбрать дорогой путь приложение может нарочно, достаточно закрывать сокет сразу после sendto. Получалось, что любое приложение, даже вне VPN, могло притормозить весь туннель.

Чтобы увидеть это в цифрах, мы написали churn_probe. Он открывает новые UDP-потоки с заданной частотой и одновременно меряет время TCP-подключения через туннель. На версии strict.2 при 1000 новых потоков в секунду медиана подключения выросла до 471 мс, если сокеты держались открытыми, и до 1085 мс, если закрывались сразу, причём во втором случае 2 подключения из 40 не состоялись вовсе. Без фильтра медиана была около 200 мс. Все эти замеры сделаны на Poco F7 с Android 16.

Исправление мы построили на принципе «держать, а не бросать». Поток чтения tun больше вообще не спрашивает Kotlin. Если у пакета нет вердикта, пакет копируется в очередь своего потока, ключ уходит четырём воркерам, а чтение продолжается. Когда вердикт готов, удержанные пакеты уходят по порядку или отбрасываются. Бросать первый пакет было бы проще, но брошенный SYN стоит каждому новому TCP-соединению секунду на повторную отправку, а каждому DNS-запросу повтор, и пользователь это заметил бы.

Удержание, конечно, ограничено, иначе флуд забил бы память. Сейчас лимит такой: 16 пакетов на поток, 256 потоков, 1 МиБ и 2 секунды на пакет. Сначала пакетов на поток было четыре, но позже выяснилось, что новый сокет, отправивший пачку из 20 датаграмм, терял 16 из них. Если ждут уже 256 потоков или занят весь мегабайт, пакеты новых потоков отбрасываются, лишние пакеты сверх лимита тоже, а пакет, прождавший больше двух секунд, не уходит даже после разрешения. Установленные TCP-соединения это не касается, потому что их пакеты без SYN проходят без вопроса. Разрешённые UDP-потоки защищает кэш, и с ним связана ошибка, которую мы нашли уже сами.

Дольше всего пришлось думать над тем, как оставить кэш без блокировок. В итоге воркеры к нему не прикасаются вовсе. Они только помечают ожидающий поток как решённый, а поток чтения переносит готовые вердикты в свой кэш при следующем промахе. Удержанные пакеты уходят раньше, чем поток чтения увидит вердикт, поэтому порядок пакетов внутри потока сохраняется.

Медиана подключения при нагрузке новыми потоками

Медиана подключения при нагрузке новыми потоками

После этого медиана подключения держится около 200 мс при любой частоте до 5000 новых потоков в секунду, с открытыми сокетами и с закрытыми, как будто фильтра нет. Доля отвеченных DNS-запросов из свежих сокетов с фильтром и без одинакова.

Вторая находка того же ревью касалась самого кэша. Мы запоминали вердикт по протоколу, адресу и порту источника на 10 секунд, но порт принадлежит сокету только пока тот жив. Стоит приложению закрыть сокет, и ядро отдаст порт следующему, возможно, сокету другого приложения, а приложение может занять порт и нарочно. Полный 5-кортеж сужает это окно, но не закрывает его, потому что новый сокет тоже может занять тот же 5-кортеж после закрытия старого. Поэтому TCP мы перестали кэшировать вовсе и судим каждый SYN заново, а UDP кэшируем по полному 5-кортежу, и оставшееся окно описали в PR, а не спрятали.

Одна деталь для честности. В тот же день ревьюер сам поправил часть своих цифр: его нагрузка закрывала сокеты сразу после отправки, то есть мерила самый дорогой путь, а не разрешённый трафик. Выводы от этого не изменились, и наши замеры на своём телефоне их подтвердили.

Третья находка: кэш, который сбрасывался целиком

Следующую ошибку мы нашли сами, когда перечитывали код для этой статьи. Вердикты UDP живут в кэше на 4096 записей, причём запрещённые тоже. Кэш, переполненный живыми записями, сбрасывался целиком, вместе с вердиктами разрешённых потоков. Около 400 новых потоков в секунду держат его полным, а такие потоки может открыть через tun0 любое приложение, в том числе исключённое из VPN. После сброса разрешённый поток спрашивался заново, и если в этот момент уже ждали 256 потоков, его пакет отбрасывался. Утечки это не давало, но QUIC и звонки разрешённых приложений таким способом можно было испортить нарочно.

Теперь переполненный кэш выбрасывает сначала просроченные и запрещённые вердикты, а разрешённые трогает, только если одни они занимают больше семи восьмых кэша. Продлевать срок жизни вердикта при каждом попадании было бы проще, но так делать нельзя: тогда сокет, занявший 5-кортеж закрытого разрешённого потока, держал бы чужое разрешение, пока шлёт пакеты. На телефоне с новой сборкой исключённое приложение заливало tun0 запрещёнными потоками со скоростью 5000 в секунду. Свежие DNS-запросы разрешённого приложения при этом получали ответ в 97,7 % случаев против 99,5 % без флуда, а медиана TCP-подключения осталась 216 мс, и до сервера по-прежнему не доходила ни одна попытка обхода из 6. Позже мы повторили этот замер со счётчиками внутри фильтра и увидели, что в четырёх прогонах фильтр не отбросил ни одного DNS-пакета, а доля ответов (99,0–99,8 %) сползала от прогона к прогону сама по себе. Свои отбросы он дал лишь однажды, в первые секунды флуда, пока переполнялась таблица ожидания.

У того же правила, по которому попадание в кэш ничего не продлевает, нашлось ещё одно следствие. Вердикт UDP живёт 10 секунд, и активный поток раз в 10 секунд упирается в его конец, после чего его пакеты держатся, как у нового потока, а всё сверх лимита отбрасывается. Один сокет, отправлявший 20 DNS-запросов каждые 100 мс, раз в десять секунд терял 16 из 200, в сумме 0,8–1,0 %. Теперь поток, который ещё шлёт, спрашивается заново в фоне, когда вердикту исполнилось 2 секунды и старый вердикт ещё действует. В результате ничего не ждёт, по просроченному вердикту не проходит ни один пакет, а потери почти как без фильтра: 0,2–0,4 % против 0,14 %. Заодно сократилось окно для захвата: сокет, занявший 5-кортеж закрытого разрешённого потока, теперь держит чужое разрешение около двух секунд, а не десять.

Как мы проверяли

После второго ревью мы решили пройти по фильтру целиком и проверить на телефоне всё, что вызывало сомнения. Оказалось, что для этого нужны инструменты, которых у нас не было.

Начали мы с отдельной экспериментальной сборки org.amnezia.vpn.exp, которая ставится рядом с основной и в которой прототипы исправлений включаются и выключаются на лету. Фильтр в ней раз в пять секунд пишет в logcat свои счётчики: сколько потоков ждало вердикта, сколько пакетов отброшено, сколько занял ответ платформы. Другое имя пакета тут же вскрыло собственную мелкую ошибку. Сервис искал свой процесс по зашитому имени org.amnezia.vpn:…, поэтому сборка с другим именем пакета, открытая заново, не находила уже работающий туннель и показывала его выключенным, а кнопка подключения ничего не делала. По образцу экспериментальной сборки потом переделали и тестовые релизы. Раньше сборка, подписанная нашим ключом, ставилась вместо приложения из магазина, так что каждый, кто хотел её попробовать, сначала терял свои серверы, и желающих было немного. Начиная со strict.7 она называется org.amnezia.vpn.strict и стоит рядом с магазинной, отдельным приложением «AmneziaVPN Strict».

Затем нужно было избавиться от касаний. Каждая проверка требовала подключиться, открыть список приложений, поменять режим, щёлкнуть переключатель, и каждое нажатие по скриншоту стоило времени, а экраны на Qt видны uiautomator лишь частично, так что промах в одном месте портил весь прогон. Поэтому наши сборки научились принимать команды из adb. Команда tools/awgctl.sh "cmd=reconnect mode=include add=com.termux strict=1" меняет режим, список приложений и строгий режим и переподключает туннель без единого касания. Сама команда кладётся в системное свойство debug.awg.ctl, которое может выставить только shell. Экспортированный приёмник интентов был бы проще, но тогда перенастроить VPN смогло бы любое приложение на телефоне. У нас сигнал «применить» приходит интентом к активности, и приложение, пославшее его само, сможет только повторить последнюю команду shell. В PR этот код не попадает: проверка перед пушем отказывает ветке pr/*, которая его несёт.

Важнее всего оказалась методика. В экспериментальной сборке фильтр можно снять прямо в работающем туннеле, и это дало честный контроль. Каждую находку мы мерили трижды: в том виде, в каком код лежит в PR, с прототипом исправления и совсем без фильтра, причём в одном и том же туннеле. Где разница была тонкой, прогоны шли в порядке ABBA, по два на вариант, и это было не лишним. За сессию результаты дрейфуют сами по себе: доля отвеченных DNS-запросов за четыре прогона сползала с 99,8 до 99,0 % при любых настройках, и без контроля такой дрейф легко принять за эффект. К прежним пробам добавились три новые: udp_flow_probe меряет потери на одном длинном UDP-потоке и проверяет большие датаграммы, inbound_probe устанавливает соединения к телефону через туннель с компьютера, подключённого к тому же серверу, а udp_traceroute проверяет обычный traceroute без root. Logcat мы писали в файл с самого начала каждого прогона, потому что кольцевой буфер HyperOS теряет строки приложения за считанные минуты.

Часть гипотез не подтвердилась, и это тоже результат. Код подсказывал обход через входящие соединения к исключённому приложению: ответный SYN-ACK уходит от сокета, который ядро показывает с uid 0, а в режиме «все, кроме выбранных» uid 0 разрешён. На деле Android уводит такой SYN-ACK мимо tun0, и фильтр его не видит вовсе: 0 соединений из 4 и с фильтром, и без. Не подтвердилось и предположение, что под флудом нужны дополнительные воркеры. Четыре успевают около 4700 отказов в секунду с медианой меньше миллисекунды, а с восемью поиск идёт даже медленнее, видимо, потому что они упираются в одну и ту же службу платформы. При малой нагрузке ответ платформы занимает 2–5 мс по медиане и до 8,5 мс, под флудом обычно меньше миллисекунды, хотя отдельные ответы доходят до 17 мс, и всё это на два порядка меньше двухсекундного предела удержания. И наконец, все прототипы, включённые разом, обход не открыли: до сервера дошли 0 попыток из 6 (по одному прогону на вариант).

Падение, которое мы внесли сами

Именно эти инструменты и нашли самую неприятную ошибку, причём нашу собственную. Через три дня после второго ревью мы гоняли на телефоне экспериментальную сборку и переключали режимы командами из adb. На очередном переподключении процесс VPN-сервиса умер:

18:53:17.410 D AmneziaWG/awg0: Device closed
18:53:17.438 I Zygote  : Process 3054 exited due to signal 11 (Segmentation fault)

Между закрытием устройства и смертью процесса прошло двадцать восемь миллисекунд. Tombstone, файл, который Android пишет при падении нативного кода, не появился: каталог /data/tombstones последний раз менялся почти за месяц до этого. В logcat не было ни строчки стека.

Без дампа оставалось рассуждать по времени. В эти 28 мс Kotlin снимает фильтр, и воркеры, которые спрашивают владельца, завершаются. Они были закреплены за потоками ОС через runtime.LockOSThread, чтобы к JVM привязывались ровно четыре потока, и тогда это казалось аккуратным решением. Но горутина, закреплённая за потоком, при выходе забирает поток с собой, а перед тем как вернуть его библиотеке pthread, Go блокирует все сигналы: в runtime.mexit стоит sigblock(true), и открытыми остаются только сигналы 32–34. Дальше срабатывает деструктор pthread-ключа из нашего jni.c и отсоединяет поток от JVM вызовом DetachCurrentThread.

ART при этом выполняет свой код, а для него SIGSEGV — рабочий инструмент. Неявные проверки на приостановку потока и на null устроены как чтение защищённой страницы, и обработчик ART такие сигналы ловит сам. Если в этот момент сборщик мусора попросил потоки остановиться, ART получает свой SIGSEGV, но сигнал заблокирован. Заблокированный синхронный сигнал ядро не откладывает, а выполняет действие по умолчанию, и процесс убит, а debuggerd ничего не пишет. Эту часть мы вывели из симптомов и в исходники ART не лезли.

Гипотезу проверили нагрузкой. Шестьдесят раз сняли и вернули фильтр в работающем туннеле без нагрузки, и не было ни одного падения, видимо, потому что сборщику мусора нечего собирать. Под нагрузкой в 500 новых потоков в секунду процесс упал на 76-м переключении из ста, через 80 мс после снятия фильтра. С 32 воркерами и 3000 новых потоков в секунду было одно падение на 60 переключений.

Исправление свелось к одной удалённой строке. Без закрепления поток после выхода воркера возвращается планировщику Go и остаётся привязанным к JVM, так что отсоединять нечего, и так же живут потоки gomobile на пути Xray. С исправлением по тому же протоколу не было ни одного падения на 300 переключений. Воркеры завершаются при каждом отключении и переподключении, в том числе при смене сети, так что ошибка сидела и в PR, и в тестовом релизе strict.3. Исправление вошло в PR и в strict.7. Тем, кто ставил strict.1–3, лучше перейти на strict.7: в strict.3 есть это падение, а в первых двух ошибки, исправленные после второго ревью. strict.7 встанет рядом со старой сборкой, поэтому настройки стоит перенести резервной копией, а старую удалить.

Там же, перечитывая код выпуска удержанных пакетов, мы заметили, что он не держит ссылку на очередь шифрования и в теории может столкнуться с закрытием устройства. Воспроизвести такую гонку не удалось ни разу за 3000 итераций в 16 горутинах, но шесть строк, которые её исключают, тоже ушли в PR.

«Выключено» значит «не существует»

Фильтр по умолчанию выключен, и это главный аргумент за то, чтобы upstream его принял: пользователь, который его не включал, не должен за него платить. Вне Android он и не платит ничего, а на Android платит одно атомарное чтение на пакет. Поэтому выключенный фильтр у нас не фильтр, который всё разрешает, а отсутствие фильтра.

Вне Android хука нет в бинарнике вообще, это и было первое замечание makekryl. uidfilter.Supported стала константой времени сборки:

//go:build android

package uidfilter

const Supported = true

В файле с //go:build !android она равна false, компилятор выбрасывает ветку вместе с вызовом, и go tool nm находит метод только в сборке под Android. На Android без установленного фильтра проверка сводится к вызову и одному атомарному чтению, 4 нс на пакет в том же бенчмарке на ноутбуке.

Однажды эта гарантия была неправдой, и заметили мы это почти случайно. Перед тем как написать в тексте PR «ничего не стоит, когда выключено», мы перечитали свой код и нашли в Wireguard.kt вызов awgSetUidFilter(null) на каждом подключении и отключении, даже с выключенной настройкой. Если бы клиентский PR приняли раньше, чем вышла библиотека AmneziaWG с мостом, этот вызов сломал бы каждое подключение AmneziaWG. Теперь с выключенной настройкой мост не вызывается вовсе, а awgSetUidFilter(null) вызывается только тогда, когда фильтр действительно был установлен.

Чего фильтр пока не умеет

Всё, о чём пойдёт речь ниже, написано в тексте PR, а не спрятано. Часть этих ограничений следует из устройства платформы и обойти их нельзя, часть мы исправили в отдельных ветках, а часть просто не смогли проверить.

Первое ограничение касается UDP из сокета, закрытого сразу после отправки: такая датаграмма может потеряться даже у разрешённого приложения. Владелец ищется после того, как пакет прочитан из tun, а отправитель, который «выстрелил и забыл», к этому времени уже закрыл сокет, и назвать владельца несуществующего сокета платформа не может. DNS, STUN, QUIC и все, кто ждёт ответа, держат сокет открытым и от этого не страдают. В TCP та же история выглядит как одиночный отклонённый SYN без повтора, потому что браузер открывает соединения заранее и часть сразу отменяет. За две минуты обычного сёрфинга в режиме «только выбранные» таких случаев было три.

Хуже обстоит дело с разрешённым приложением, которое само привязывает TCP к tun0: оно остаётся без TCP. Поиск платформы отправляет запрос с ifIndex = 0, а ядро не находит сокет, привязанный к устройству, если в запросе интерфейс не указан. UDP находится запасным дампом, а TCP нет, поэтому, например, curl --interface tun0 из разрешённого Termux выглядит как сломанный VPN. Нашёл это ревьюер, потом воспроизвели и мы: с привязкой таймаут, без неё ответ за 0,8 с. Публичного API, где можно указать интерфейс, нет, а разрешить TCP с неизвестным владельцем значит вернуть обход.

С Private DNS по имени хоста в режиме «только выбранные» тоже есть проблема. Системный резолвер в этом режиме вне VPN, и владельца его сокетов платформа от нас скрывает. Если Private DNS задан по имени, например one.one.one.one, резолвер сначала ищет это имя обычным DNS через туннель. Запрос отклоняется, Android помечает VPN как сеть без интернета, и перечисленные приложения не резолвят ничего: в нашем замере 0 запросов из 5. В автоматическом режиме Private DNS всё работает, потому что отклоняются только попытки DoT на порт 853, Android откатывается на обычный DNS, а эти запросы идут с UID приложения и проходят. Безопасного решения мы не нашли, ведь любой способ пропустить резолвер пропускает и обход. В комментариях к статье про TeapodStream жаловались на то же самое, так что это, похоже, общая беда фильтров по владельцу.

Входящие соединения в том же режиме «только выбранные» тоже ломаются. Если к приложению на телефоне подключаются через туннель, например с другого клиента того же сервера, фильтр судит ответный SYN-ACK как новое соединение. Сокет, от которого он уходит, ядро показывает с uid 0, а в этом режиме uid 0 вне VPN, и соединение не устанавливается: 0 из 5. Исправление, которое в таком случае спрашивает про слушающий сокет, лежит в отдельной ветке followup/inbound-synack и даёт 5 из 5.

Похожая история с UDP-датаграммами больше MTU туннеля. Ядро режет их на фрагменты, а у фрагментов нет портов, поэтому фильтр отбрасывает их даже у разрешённого приложения, и DNS-запрос в 1400 байт при MTU 1280 не получил ответа ни разу из 20. В ветке followup/fragments последующие фрагменты идут за вердиктом первого, и ответов становится 16 и 20 из 20.

Одну проблему такого рода мы уже исправили, и о ней стоит рассказать, потому что на неё легко наступить. На телефоне с XSpace клон браузера из списка не мог открыть ни одной страницы. У копии приложения свой UID, пользователь × 100000 + appId, а у её песочницы SDK на Android 13+ ещё один, а мы сравнивали UID целиком. Теперь сравниваем app id, и это исправлено ещё в strict.2.

Остальное короче. Фильтр работает только на Android 10 и новее: на старых версиях getConnectionOwnerUid нет, и он не включается. ping и ICMP-traceroute в строгом режиме не работают, потому что владельца ICMP-сокета платформа назвать не умеет, а пинг снаружи на адрес телефона в туннеле не проходит, так как эхо-ответ шлёт само ядро. Обычный traceroute по UDP, как мы уже говорили, работает.

Не проверены IPv6, потому что у нашего сервера его нет, и путь Xray целиком, потому что из России наш Xray не подключается. Читая код пути Xray, мы нашли там две свои же проблемы, которые на AmneziaWG уже исправлены. Поиск владельца для UDP идёт там синхронно в процессорах gVisor. Медленный ответ задерживает не весь туннель, а свой процессор, но ошибка та же, что была на AmneziaWG до второго ревью. А вердикт UDP живёт, пока по потоку идёт трафик, и ещё 60 секунд простоя, так что сокет, занявший 5-кортеж закрытого потока, наследует его без предела по времени, пока шлёт пакеты. Перед PR для Xray оба места нужно переделать так же, как на AmneziaWG.

И наконец, есть утечка, которую фильтр не закрывает вовсе, потому что она устроена иначе. Путь Xray в AmneziaVPN на Android 13+ публикует адрес сервера в маршрутах VPN, и любое приложение читает его, не отправив ни одного пакета. Это нужно исправлять отдельно. У AmneziaWG такого маршрута нет.

Чек-лист: как закрыть утечку в своём клиенте

Если вы делаете VPN-клиент под Android и хотите закрыть ту же дыру у себя, вот что мы бы сделали с самого начала, зная то, что знаем сейчас:

  1. Ставьте хук там, где пакет выходит из tun fd, до пересылки. Всё, что привязано к tun0, проходит через это место.

  2. Владельца спрашивайте через getConnectionOwnerUid из приложения, которому принадлежит активный VpnService (API 29+). INVALID_UID означает отказ в любом режиме, и это правило важнее всех остальных.

  3. Не выпускайте ни одного пакета потока до вердикта. Держите, а не бросайте: брошенный SYN стоит секунду.

  4. Не спрашивайте на потоке чтения tun. Нужны воркеры и очередь с лимитами, а при переполнении отказ новым потокам.

  5. Не закрепляйте за потоком ОС горутину, которая ходит в JVM и может завершиться: поток уйдёт в DetachCurrentThread с заблокированными сигналами.

  6. TCP судите по каждому SYN и не кэшируйте. UDP кэшируйте по полному 5-кортежу с коротким TTL и перепроверяйте активный поток до истечения вердикта, а не на нём.

  7. Отбрасывайте то, что нельзя приписать владельцу: ICMP, фрагменты без первого, IPv6 с extension headers.

  8. Сравнивайте app id, а не UID: клоны приложений, второе пространство, песочницы SDK.

  9. Выключенная защита не должна стоить ничего. Меряйте, а не предполагайте.

  10. Проверяйте в обоих режимах раздельного туннелирования: leak_probe.py, icmp_probe.py и churn_probe.

Где мы можем ошибаться

Самое полезное, что может случиться с этой статьёй, это комментарий, который найдёт у нас ошибку. Поэтому напоследок места, в которых мы уверены меньше всего.

Мы не смогли проверить IPv6, и если у вас есть сервер AmneziaWG с IPv6, прогоните leak_probe.py: он пробует и v6. Путь Xray собран и покрыт тестами, но на телефоне не проверен, а две проблемы, описанные выше, там ещё не исправлены, так что нужен кто-то, у кого Xray подключается.

Есть и вопросы, на которые у нас нет уверенного ответа. На пути Xray отказ выглядит как RST, а на пути AmneziaWG пакет молча отбрасывается, и connect у атакующего ждёт таймаута, и мы не уверены, стоит ли отвечать RST и там. Сокет, занявший 5-кортеж только что закрытого разрешённого потока, получает его вердикт примерно на 2 секунды, если у него тот же порт источника и тот же адресат, и нам это окно кажется приемлемым, но хочется услышать другие мнения. В режиме «только выбранные» Private DNS по имени хоста сейчас тихо ломает DNS, и мы пробуем два варианта, уведомление при подключении или отказ подключаться с объяснением, и пока не решили, какой лучше. И наконец, может быть, существует способ лучше getConnectionOwnerUid узнать владельца сокета, привязанного к устройству, которого мы не нашли.

Как попробовать

Тестовая сборка v5.0.3.1-strict.7 — это AmneziaVPN с нашими тремя PR и двумя исправлениями из отдельных веток. Она подписана нашим ключом и ставится рядом с приложением из магазина, под именем «AmneziaVPN Strict», так что ничего удалять не нужно, а ключ сервера импортируется в неё отдельно. Одновременно Android держит только один VPN. SHA-256 файла e000a327487236c4f67c20282ba5f196634c14888e028f455de86ebe2155dd1b, это же значение показывает GitHub. Переключатель «Строгое раздельное туннелирование» находится в настройках раздельного туннелирования, работает для AmneziaWG и WireGuard и меняется только при отключённом VPN.

Отдельно о том, что фильтр пишет и что хранит, потому что в комментариях к похожим проектам этот вопрос задавали первым. Отказы идут в logcat на уровне debug, а в релизной сборке попадают туда, только если в приложении включено сохранение логов. На диск фильтр ничего не пишет, и всё его состояние живёт в памяти, пока поднят туннель.

Код лежит в трёх PR: amneziawg-go#199, amneziawg-android#104 и amnezia-client#3199. Рассуждения, ловушки, на которые мы наступили, и утилиты проверки собраны в заметках.

Спасибо

Эта работа была бы хуже без нескольких людей. makekryl написал amneziawg-go#174, из которого мы взяли отказ от всего неопознанного и теги сборки, и сделал первое ревью. Ревьюер второго раунда объяснил, почему ядро пропускает пакет, и нашёл, как остановить весь туннель. Wendor в TeapodStream независимо от нас пришёл к той же идее проверки владельца, а когда мы написали ему про INVALID_UID, исправил это в тот же день. И отдельное спасибо авторам статей, которые мы прочитали, прежде чем браться за своё решение: runetfreedom, который весной поднял эту тему, Wendor (раз, два), sogonov и Dertefter, а также комментаторам под этими статьями, чьи вопросы во многом определили, о чём эта статья.

Автор: LuciusWill

Источник