discover · compare · collections · requirements · contribute · feed · watching · stats · help with your tokens
xtclovver/RKNHardering · source
Snapshot 0 · 2026-11-04T12:47:28Z · Kotlin · AGPL-3.0
Content fingerprint
bcbc50ea81742597f04901c68183aeb616c0e0042dd7af19689896574a9b94daGitHub description at capture
Обнаружние VPN конфигураторов на пользовательских устройствах с дальнейшей целью защиты от обнаружения
README at capture
> **Язык / Language:** [Русский](README.md) | [English](docs/README.en.md) | [فارسی](docs/README.fa.md) | [中文](docs/README.zh-CN.md)
# RKNHardering
<a href="https://matrix.to/#/%23RKN_Hardering:matrix.kangel.tech"><img src="https://img.shields.io/badge/matrix-%23000000?style=for-the-badge&logo=matrix&logoColor=white" alt="Matrix" width="200"></a>
<a href="https://t.me/RKNHardering"><img src="https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram" width="200"></a>
Android-приложение для обнаружения VPN и прокси на устройстве. Реализует методику РКН по выявлению средств обхода блокировок.
Минимальная версия Android: 8.0 (API 26).
## Справочник проверок
Подробные объяснения Native signs и IP comparison опубликованы в [справочнике RKNHardering](https://xtclovver.github.io/RKNHardering/help/ru/). В статьях отдельно помечены сведения, **проверенные проектом**, **сообщённые сообществом** и **заявленные внешним проектом**. Последние две метки не являются гарантией работоспособности или безопасности рекомендации.
Вы можете скачать наш проект с:
<table>
<tr>
<th align="center">GitHub</th>
<th align="center">F-Droid</th>
</tr>
<tr>
<td align="center">
<a href="https://github.com/xtclovver/RKNHardering/releases/latest">
<img src="https://github.com/machiav3lli/oandbackupx/blob/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Download from GitHub" height="75">
</a>
</td>
<td align="center">
<a href="https://f-droid.org/en/packages/com.notcvnt.rknhardering/">
<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Download from F-Droid" height="75">
</a>
</td>
</tr>
</table>
## Нужна помощь сообщества / Community Help Wanted
> **RU**
Этот проект документирует методы обнаружения VPN и прокси на Android-устройствах. Однако **обратная задача**, как предотвратить детектирование наличия VPN, исследована значительно хуже.
Я ищу людей, готовых помочь собрать, систематизировать и протестировать информацию о способах обхода детектов, включая, но не ограничиваясь:
- **Маскировка сетевых интерфейсов** (как скрыть `tun0`, `wg0` и другие VPN-подобные интерфейсы от `NetworkInterface.getNetworkInterfaces()` и `/proc/net/route`)
- **Подмена NetworkCapabilities** (способы убрать `TRANSPORT_VPN`, `IS_VPN`, `VpnTransportInfo` из ответов `ConnectivityManager`)
- **Скрытие от dumpsys** (предотвращение утечки информации через `dumpsys vpn_management` и `dumpsys activity services android.net.VpnService`)
- **MTU-нормализация** (выставление стандартного MTU (1500) для туннельных интерфейсов на различных клиентах)
- **DNS-утечки** (предотвращение обнаружения loopback/private DNS при активном VPN)
- **Скрытие localhost-прокси** (как предотвратить обнаружение через `/proc/net/tcp` и сканирование портов)
- **Обход нативных проверок** (противодействие JNI-проверкам через `/proc/self/maps`, `getifaddrs()`, `dlsym`)
- **Маскировка установленных приложений** (скрытие пакетов VPN-приложений от `PackageManager`)
Если вы обладаете знаниями в этих областях, пожалуйста, откройте Issue или Pull Request, либо напишите в [чат Matrix](https://matrix.to/#/%23RKN_Hardering:matrix.kangel.tech)/[Telegram](https://t.me/RKNHardering) с описанием метода, условий применимости и ограничений. Любая информация ценна — от теоретических идей до работающих PoC.
> **EN**
This project documents methods for detecting VPNs and proxies on Android devices. However, **the inverse problem** how to prevent the detection of an active VPN has been studied much less thoroughly.
I am looking for people willing to help collect, organize, and test information about ways to bypass detection, including, but not limited to:
- **Network interface masking** (how to hide `tun0`, `wg0`, and other VPN-like interfaces from `NetworkInterface.getNetworkInterfaces()` and `/proc/net/route`)
- **NetworkCapabilities spoofing** (ways to remove `TRANSPORT_VPN`, `IS_VPN`, and `VpnTransportInfo` from `ConnectivityManager` responses)
- **Hiding from dumpsys** (preventing information leakage through `dumpsys vpn_management` and `dumpsys activity services android.net.VpnService`)
- **MTU normalization** (setting a standard MTU of 1500 for tunnel interfaces across different clients)
- **DNS leaks** (preventing detection of loopback/private DNS while a VPN is active)
- **Hiding localhost proxies** (how to prevent detection via `/proc/net/tcp` and port scanning)
- **Bypassing native checks** (countering JNI-based checks through `/proc/self/maps`, `getifaddrs()`, and `dlsym`)
- **Masking installed applications** (hiding VPN app packages from `PackageManager`)
If you have expertise in these areas, please open an Issue or [Сhat in Matrix](https://matrix.to/#/%23RKN_Hardering:matrix.kangel.tech)/[Telegram](https://t.me/RKNHardering), the conditions under which it applies, and its limitations. Any information is valuable, from theoretical ideas to working PoCs.
## Архитектура
Независимые модули проверки запускаются параллельно. Итоговый вердикт рассчитывается в `VerdictEngine`.
`IpComparisonChecker` сохраняется в результат и показывается в UI как диагностический блок. Напрямую в `VerdictEngine` не участвует, но его данные поступают в `IpConsensusBuilder`.
```text
VpnCheckRunner
├── GeoIpChecker — GeoIP + hosting/proxy-сигналы
├── IpComparisonChecker — RU/не-RU IP-чекеры (диагностика)
├── DirectSignsChecker — NetworkCapabilities, системный proxy, TUN-проб, установленные VPN apps
├── IndirectSignsChecker — интерфейсы, маршруты, DNS, dumpsys, proxy-tech signals
├── CallTransportChecker — STUN/MTProto (утечки и доступность)
├── CdnPullingChecker — HTTPS-запросы к CDN/redirector
├── LocationSignalsChecker — MCC/SIM/cell/Wi-Fi/BeaconDB
├── BypassChecker — localhost proxy, Xray gRPC API, Clash/sing-box REST API, SOCKS5-auth проба, underlying-network leak
├── RttTriangulationChecker — SNITCH (β): RTT-триангуляция по RU/иностранным хостам
├── IcmpSpoofingChecker — ICMP-спуфинг оператора (заблокированный хост отвечает)
├── DomainReachabilityChecker — DNS→TCP→TLS pipeline для детекта DPI-блокировки
├── NativeSignsChecker — JNI-проверки (маршруты, интерфейсы, host-route /32, TUN/TAP по типу, хуки, root, эмулятор, изоляция)
└── IpConsensusBuilder — кросс-модульный IP-консенсус
└── VerdictEngine — логика итогового вердикта
```
---
## Модули проверки
### 1. GeoIP (`GeoIpChecker`)
Источники:
- `https://api.ipapi.is/` — основной источник полей GeoIP и сигналов proxy/VPN/Tor/datacenter
- `https://www.iplocate.io/api/lookup` — fallback-источник полей GeoIP и дополнительный голос за hosting (`privacy.is_hosting`)
Логика:
| Сигнал | Что делает код | Итог |
|--------|----------------|------|
| `countryCode != RU` | IP считается иностранным | `needsReview`, если одновременно нет `hosting` и `proxy` |
| `hosting` | Используется majority vote по совместимым ответам одного и того же IP (`ipapi.is`, `iplocate.io`) | `detected = true`, если большинство совместимых источников говорят `hosting=true` |
| `proxy` | Используются совместимые HTTPS-провайдеры (`ipapi.is`, `iplocate.io`) | `detected = true`, если хотя бы один совместимый провайдер говорит о proxy/VPN/Tor |
| `country`, `isp`, `org`, `as`, `query` | Берутся из `ipapi.is`, а недостающие поля заполняются из `iplocate.io` только для совместимого IP | не влияют напрямую |
Итог категории:
- `detected = isHosting || isProxy`
- `needsReview = foreignIp && !isHosting && !isProxy`
Таймаут соединения и чтения для HTTP(S)-запросов: 10 секунд. `GeoIpChecker` использует только HTTPS-провайдеры и возвращает ошибку только если ни один GeoIP-провайдер не ответил данными.
---
### 2. Сравнение IP-чекеров (`IpComparisonChecker`)
Модуль сравнивает ответы RU- и не-RU публичных IP-чекеров. Отображается в UI как диагностический блок. Напрямую в `VerdictEngine` не участвует, но его данные поступают в `IpConsensusBuilder`, результаты которого используются в R3.
Группы сервисов:
| Группа | Сервисы |
|--------|---------|
| `RU` | `Yandex IPv4`, `2ip.ru`, `Yandex IPv6` |
| `NON_RU` | `ifconfig.me IPv4`, `ifconfig.me IPv6`, `checkip.amazonaws.com`, `ipify`, `ip.sb IPv4`, `ip.sb IPv6` |
Логика:
- внутри каждой группы строится `canonicalIp`, если сервисы согласованы;
- несовпадение IP внутри группы, частичные ответы и конфликт семейств `IPv4/IPv6` переводят группу в `needsReview` или `detected` в зависимости от полноты данных;
- общий `detected` ставится только если обе группы дали полный консенсус внутри себя, но RU- и не-RU группы вернули разные canonical IP;
- ожидаемые ошибки IPv6-эндпоинтов могут игнорироваться и не ломают консенсус IPv4.
---
### 3. Прямые признаки (`DirectSignsChecker`)
Системные признаки без активного сетевого сканирования localhost.
#### 3.1 NetworkCapabilities (`checkVpnTransport`)
API: `ConnectivityManager.getNetworkCapabilities(activeNetwork)`
| Проверка | Метод/поле | Итог |
|----------|------------|------|
| `NetworkCapabilities.TRANSPORT_VPN` | `caps.hasTransport(TRANSPORT_VPN)` | `detected = true` |
| `IS_VPN` | `caps.toString().contains("IS_VPN")` | `detected = true` |
| `VpnTransportInfo` | `caps.toString().contains("VpnTransportInfo")` | `detected = true` |
`IS_VPN` и `VpnTransportInfo` проверяются через строковое представление `NetworkCapabilities`.
При наличии `VpnTransportInfo` (API 29+, через reflection `getType()`) в findings добавляется тип транспорта: `SERVICE` (VpnService-приложение), `PLATFORM` (always-on / IKEv2), `LEGACY` (legacy VPN framework) или `OEM`. Это информационное поле, оно не влияет на `detected`/`needsReview`.
#### 3.2 Системный proxy (`checkSystemProxy`)
Используются:
- `System.getProperty("http.proxyHost")` с fallback на `Proxy.getDefaultHost()`
- `System.getProperty("http.proxyPort")` с fallback на `Proxy.getDefaultPort()`
- `System.getProperty("socksProxyHost")`
- `System.getProperty("socksProxyPort")`
- `ConnectivityManager.getDefaultProxy()`
- `ConnectivityManager.allNetworks` + `ConnectivityManager.getLinkProperties(network).httpProxy`
- `ProxyInfo.getPacFileUrl()`, `ProxyInfo.getExclusionList()`, на API 30+ также `ProxyInfo.isValid()`
Логика:
| Состояние | Итог |
|-----------|------|
| host отсутствует | proxy считается ненастроенным |
| host есть, но порт невалиден | `needsReview = true` |
| host есть и порт валиден | `detected = true` |
| порт относится к известным proxy-портам | добавляется отдельная находка |
| `ProxyInfo` содержит `PAC URL` | `detected = true` |
| `ProxyInfo` задан на конкретной сети | в вывод добавляется отдельная строка с interface name |
| `ProxyInfo` содержит exclusion list | exclusions показываются пользователю |
| на API 30+ `!ProxyInfo.isValid()` | `needsReview = true` без detected-evidence |
| на отслеживаемых сетях `ProxyInfo` не найден | добавляется агрегированная строка `ProxyInfo ...: не обнаружен` |
Известные proxy-порты: `80`, `443`, `1080`, `3127`, `3128`, `4080`, `5555`, `7000`, `7044`, `8000`, `8080`, `8081`, `8082`, `8888`, `9000`, `9050`, `9051`, `9150`, `12345`, а также диапазон `16000..16100`.
#### 3.3 TUN Active Probe (`checkTunActiveProbe`)
Если при инициализации обнаружен TUN-интерфейс, `UnderlyingNetworkProber` отправляет HTTP-запросы через VPN-сеть к RU- и non-RU целям. При расхождении IP (DNS path mismatch) или если приложение явно исключено из per-app VPN (tun0 есть, но `vpnActive = false`) — `detected = true`. Этот сигнал поступает в `VerdictEngine` через `EvidenceSource.TUN_ACTIVE_PROBE`.
#### 3.4 Установленные VPN/Proxy-приложения (`InstalledVpnAppDetector`)
Модуль проверяет три источника:
- известные сигнатуры пакетов из [`VpnAppCatalog`](app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppCatalog.kt);
- приложения, которые объявляют `VpnService.SERVICE_INTERFACE` через `PackageManager.queryIntentServices`;
- приложения с "VPN" в названии.
Это диагностические сигналы установки или декларации `VpnService`, а не подтверждение активного туннеля. Совпадения переводят категорию в `needsReview`, но сами по себе не делают `DirectSignsChecker.detected = true`.
---
### 4. Косвенные признаки (`IndirectSignsChecker`)
#### 4.1 Capability `NOT_VPN` (`checkNotVpnCapability`)
`ConnectivityManager.getNetworkCapabilities(activeNetwork).toString()` проверяется на наличие строки `NOT_VPN`.
| Результат | Итог |
|-----------|------|
| `NOT_VPN` присутствует | норма |
| `NOT_VPN` отсутствует | `detected = true` |
#### 4.2 Сетевые интерфейсы (`checkNetworkInterfaces`)
API: `NetworkInterface.getNetworkInterfaces()`. Проверяются активные (`isUp`) интерфейсы.
Паттерны VPN-подобных интерфейсов:
- `tun\d+`
- `tap\d+`
- `wg\d+`
- `ppp\d+`
- `utun\d*` — TUN в стиле macOS/iOS
- `zt.*` — ZeroTier
- `tailscale\d*` — Tailscale
- `svpn\d*` — Pulse Secure / Ivanti
- `gre\d+` — GRE-туннели
- `l2tp\d+` — L2TP
- `he-ipv6.*` — IPv6-туннель Hurricane Electric
- `(ipsec|xfrm).*` — IPsec / ядерный XFRM
Любой активный интерфейс, попавший под эти паттерны, даёт `detected = true`.
#### 4.3 Аномалия MTU (`checkMtu`)
Логика:
| Условие | Итог |
|---------|------|
| VPN-подобный интерфейс с MTU `1..1499` | `detected = true` |
| Нестандартный активный интерфейс (не `wlan.*`, `rmnet.*`, `eth.*`, `lo`) с MTU `1..1499` | `detected = true` |
#### 4.4 Маршрутизация (`checkRoutingTable`)
Источник данных:
- в первую очередь `LinkProperties.routes` из Android API;
- fallback: `/proc/net/route`, если через API не удалось получить default route.
Детекты:
- default route через нестандартный интерфейс;
- выделенные non-default routes через VPN/нестандартный интерфейс;
- паттерн split tunneling: одновременно видны tunnel routes и обычный default route через стандартную сеть.
Default route через `wlan.*`, `rmnet.*`, `eth.*`, `lo` считается нормой, если сама сеть не помечена как VPN.
#### 4.5 DNS (`checkDns`)
API: `ConnectivityManager.getLinkProperties(activeNetwork).dnsServers`.
DNS оценивается вместе со snapshot underlying-сетей, если они доступны.
| Сигнал | Итог |
|--------|------|
| loopback DNS (`127.x.x.x`, `::1`) | `detected = true` |
| private DNS, унаследованный из той же private/ULA-подсети основной non-VPN сети | норма |
| private DNS при активном VPN и отличии от underlying сети | `detected = true` |
| private DNS без достаточного контекста | `needsReview = true` |
| public DNS, заменённый при активном VPN | `needsReview = true` |
| link-local (`169.254.x.x`, `fe80::/10`) | информационно |
#### 4.6 Дополнительные proxy-технические сигналы (`checkProxyTechnicalSignals`)
Проверяются:
- установленные proxy-only утилиты из `VpnAppCatalog` с сигналом `LOCAL_PROXY` без `VPN_SERVICE`;
- локальные listeners из `/proc/net/tcp`, `/proc/net/tcp6`, `/proc/net/udp`, `/proc/net/udp6` на известных proxy-портах;
- большое число localhost listeners на высоких портах.
Логика:
- listener на известном localhost proxy-порту даёт `detected = true`;
- наличие proxy-only утилиты или множества localhost listeners даёт `needsReview = true`.
Отдельно фиксируется ограничение: проверки процессов, `iptables`/`pf` и системных сертификатов неполны без root/privileged access.
#### 4.7 `dumpsys vpn_management` (`checkDumpsysVpn`)
Только Android 12+ (API 31+). Запускается `dumpsys vpn_management`.
Если парсер (`VpnDumpsysParser`) находит активные записи VPN, они дают `detected = true`. Из записей извлекается пакет, затем он сопоставляется с `VpnAppCatalog`:
- известный пакет: высокая уверенность;
- неизвестный пакет: `detected = true` и одновременно `needsReview = true`.
Пустой вывод, `Permission Denial` или недоступность сервиса считаются отсутствием детекта.
#### 4.8 `dumpsys activity services android.net.VpnService` (`checkDumpsysVpnService`)
Запускается `dumpsys activity services android.net.VpnService`.
Если найдены активные `VpnService`, создаются `activeApps` и evidence:
- известный пакет из каталога: высокая уверенность;
- неизвестный пакет: `detected = true` и `needsReview = true`.
Пустой вывод или отсутствие записей `VpnService` детекта не дают.
---
### 5. Сигналы местоположения (`LocationSignalsChecker`)
Модуль собирает признаки, подтверждающие, что устройство физически находится в РФ или, наоборот, что telephony-сигналы выглядят нетипично.
Источники:
- `TelephonyManager.networkOperator`, `networkCountryIso`, `networkOperatorName`
- `TelephonyManager.simOperator`, `simCountryIso`, `isNetworkRoaming`
- `requestCellInfoUpdate` / `allCellInfo`
- `WifiManager.scanResults` и текущий `BSSID`
- `BeaconDB` (`https://api.beacondb.net/v1/geolocate`) для cell/Wi-Fi geolocation
- reverse geocoding для `countryCode`
Разрешения:
- `ACCESS_FINE_LOCATION` нужен для cell lookup;
- на Android 13+ `NEARBY_WIFI_DEVICES` нужен для Wi-Fi lookup.
Логика:
| Сигнал | Итог |
|--------|------|
| `networkMcc == 250` | добавляется служебная находка `network_mcc_ru:true` |
| `BeaconDB`/reverse geocode вернул `RU` | добавляются `cell_country_ru:true` и `location_country_ru:true` |
| `networkMcc != 250` | `needsReview = true` |
| отсутствие разрешений или radio data | информационно |
В текущей реализации `LocationSignalsChecker.detected` всегда `false`. Его основная роль в `VerdictEngine` — подтверждать Россию и усиливать иностранный GeoIP-сигнал.
---
### 6. Bypass-проверка (`BypassChecker`)
Проверки запускаются параллельно:
- `ProxyScanner`
- `XrayApiScanner`
- `ClashApiScanner`
- `UnderlyingNetworkProber`
#### 6.1 Сканер прокси (`ProxyScanner` + `ProxyProber`)
Сканируются `127.0.0.1` и `::1`.
Режимы:
| Режим | Описание |
|-------|----------|
| `AUTO` | сначала популярные порты, затем полный диапазон |
| `MANUAL` | проверка одного указанного порта |
Популярные порты в `AUTO` формируются из `VpnAppCatalog.localhostProxyPorts` и дополнительно включают `1081`, `7890`, `7891`.
Полное сканирование:
- диапазон `1024..65535`
- параллельность `200`
- таймаут соединения `80 мс`
- таймаут чтения `120 мс`
Определяются только proxy без аутентификации:
| Тип | Как определяется |
|-----|------------------|
| `SOCKS5` | greeting `0x05 0x01 0x00` и ответ `0x05 0x00` |
| `HTTP CONNECT` | `CONNECT ifconfig.me:443 HTTP/1.1` и ответ `HTTP/1.x 200` |
Открытый localhost proxy сам по себе не считается подтверждённым обходом: он фиксируется как `needsReview`. Подтверждение обхода ставится только если удалось получить прямой IP и IP через proxy, и они различаются.
Дополнительно:
- если найден `SOCKS5`, но HTTP-получение IP через него не удалось и порт не похож на Xray, запускается `MtProtoProber`;
- успешный MTProto probe добавляет информативную находку, но не влияет на итоговый verdict.
**Проба аутентификации (`ProxyProber`, опциональная).** Включается настройкой «Probe local proxy authentication» (`pref_proxy_auth_probe_enabled`, по умолчанию выключена). Применяется только к `SOCKS5`-эндпоинтам на loopback-адресах:
- перебор словаря слабых учётных данных (RFC 1929): пустая пара, `admin/admin`, `user/password`, `proxy/proxy`, `test/test` и т.д. — только если прокси требует аутентификацию;
- проба `UDP ASSOCIATE` на прокси без аутентификации.
Успешный подбор кредов или открытый `UDP ASSOCIATE` дают `detected = true` (`EvidenceSource.PROXY_AUTH_BYPASS`, входит в `HARD_DETECT_BYPASS`).
#### 6.2 Сканер Xray gRPC API (`XrayApiScanner` + `XrayApiClient`)
Сканируются `127.0.0.1` и `::1`.
Параметры:
- диапазон `1024..65535`
- параллельность `100`
- TCP connect timeout `200 мс`
- gRPC deadline `2000 мс` с повтором на увеличенном дедлайне
Проверка выполняется не через сырой HTTP/2 preface, а через реальный gRPC-вызов `HandlerServiceGrpc.listOutbounds(...)`.
При успехе:
- endpoint даёт `detected = true`;
- в findings добавляются до 10 summary по outbound'ам (`tag`, `protocol`, `address`, `port`, `sni`) и счётчик оставшихся.
#### 6.3 Underlying network leak / VPN network binding (`UnderlyingNetworkProber`)
Если на устройстве активен VPN, модуль:
- перебирает все `ConnectivityManager.allNetworks`;
- ищет internet-capable сеть без `TRANSPORT_VPN`;
- привязывает HTTP(S)-запросы к этой сети;
- запрашивает публичный IP через `ifconfig.me`, `checkip.amazonaws.com`, `ipv4-internet.yandex.net`, `ipv6-internet.yandex.net`.
Если underlying-сеть доступна при активном VPN, это трактуется как `VPN gateway leak` и даёт `detected = true`.
#### 6.4 Сканер REST API Clash/sing-box (`ClashApiScanner` + `ClashApiClient`)
Опциональная проверка, настройка «Clash/sing-box REST API scan» (`pref_clash_api_scan_enabled`, по умолчанию включена). Сканирует loopback (`127.0.0.1`, `::1`) на REST API менеджеров Clash, mihomo и sing-box.
Параметры:
- порты `9090`, `19090`, `9091`, `9097`
- TCP connect probe `200 мс`, далее connect/read `600 мс`
Логика:
- `GET /configs` — если возвращается валидный JSON, API считается живым;
- `GET /connections` — из `metadata.destinationIP` извлекаются IP VPN-серверов (до 10 уникальных);
- `GET /proxies` — собираются имена прокси-узлов.
Живой API или непустой список IP назначений дают `detected = true` (`EvidenceSource.CLASH_API`, входит в `HARD_DETECT_BYPASS`).
Итог категории:
- `detected = confirmed split tunnel || xrayApiFound || clashApiFound || proxyAuthBypass || vpnGatewayLeak || vpnNetworkBinding`
- `needsReview = true`, если найден открытый proxy, но подтверждения обхода нет
---
### 7. CDN Pulling (`CdnPullingChecker`)
Отправляет HTTPS-запросы к известным endpoint-ам типа trace и redirector (Google Video, Cloudflare trace, Meduza), чтобы узнать, какой публичный IP или метаданные сети возвращаются. Различия в ответах могут указывать на туннелирование или проксирование.
### 8. Call Transport (`CallTransportChecker`)
Проверяет доступность UDP/STUN (как глобальных, так и региональных), а также TCP-доступность Telegram MTProto через локальные прокси-серверы. Это позволяет выявить утечки трафика мимо стандартных туннелей или обнаружить подмену IP-адресов.
### 9. SNITCH — RTT-триангуляция (`RttTriangulationChecker`) β
Отправляет ICMP ping к набору российских и иностранных хостов и сравнивает медианные задержки.
Российские цели: `yandex.ru`, `mail.ru`, `vk.com`, `sberbank.ru`, `gosuslugi.ru`.
Иностранные цели: `facebook.com`, `github.com`, `twitter.com`, `reddit.com`, `instagram.com`.
Логика:
- если медианный RTT до RU-хостов превышает порог (`80 мс`) — устройство, вероятно, не находится в РФ;
- высокий jitter (> 60 мс) снижает уверенность вывода;
- результат переводит вердикт в `NEEDS_REVIEW`, но сам по себе не даёт `DETECTED`.
Проверка опциональна и отключена по умолчанию.
---
### 10. ICMP Spoofing (`IcmpSpoofingChecker`)
Проверяет, не подменяет ли оператор ICMP-ответы на заблокированные хосты.
Цели по умолчанию:
- `instagram.com` — заблокированный хост (`BLOCKED`);
- `google.com` — контрольный хост (`CONTROL`).
Цели конфигурируемы через пользовательские проверки. Для работы нужна хотя бы одна пара BLOCKED + CONTROL.
Логика:
| Условие | Итог |
|---------|------|
| BLOCKED-хост ответил на ping | `needsReview = true` — возможен ICMP-спуфинг оператора |
| CONTROL-хост ответил с RTT < 10 мс | `needsReview = true` — подозрительно низкая задержка (возможно, локальный перехват) |
| Оба условия одновременно | `needsReview = true` с усиленным сигналом |
| CONTROL-хост не ответил | результат неопределённый (inconclusive) |
| Ничего из вышеперечисленного | норма |
`IcmpSpoofingChecker.detected` всегда `false`. Результат может перевести вердикт из `NOT_DETECTED` в `NEEDS_REVIEW` через R6 в `VerdictEngine`. Включён по умолчанию. При home-routed роуминге сигналы автоматически подавляются.
---
### 11. Доступность доменов (`DomainReachabilityChecker`)
Проверяет каждый домен из пользовательского списка по цепочке DNS → TCP → TLS:
| Шаг | Детектируемая блокировка | Таймаут |
|-----|--------------------------|---------|
| DNS | NXDOMAIN, timeout | 8 с |
| TCP :443 | Connection refused, timeout | 8 с |
| TLS (SNI) | Connection reset — признак DPI | 10 с |
TLS-шаг использует trust-all X.509 manager, потому что цель — обнаружить сброс соединения DPI, а не проверить сертификат. `SSLHandshakeException` из-за невалидного сертификата трактуется как успех TLS.
Результаты не влияют на вердикт. Модуль отключён по умолчанию (`domainReachabilityEnabled = false`) и активируется только если задан непустой список доменов в настройках пользовательской проверки.
---
### 12. Native Signs (`NativeSignsChecker`)
Выполняет низкоуровневые JNI-проверки прямо из C++:
- Перечисление нативных интерфейсов и работа `getifaddrs()`
- Прямой парсинг `/proc/net/route`
- Сканирование `/proc/self/maps` на известные признаки hook-ов
- Целостность разрешения символов `libc` (dlsym)
- Обнаружение root (su binary, параметры magisk, selinux, rw /system и др.)
Нативные находки транслируются в `needsReview` или общие indirect-признаки.
#### 12.1 TUN/TAP по типу интерфейса
Для каждого интерфейса читается `/sys/class/net/<name>/type`. Значение `65534` (`ARPHRD_TUNTAP`) у активного интерфейса, имя которого **не** совпадает с известными VPN-паттернами, означает TUN/TAP, маскирующийся под обычный интерфейс. Итог: `detected = true` (`EvidenceSource.NATIVE_INTERFACE`).
#### 12.2 Host-route /32 эвристика
В таблице маршрутов (`NETLINK`) ищется полностью описанный `unicast`-маршрут со scope `global` или `link`, префиксом `/32` (IPv4) или `/128` (IPv6) и публичным маршрутизируемым адресом через обычный физический интерфейс. Маршрут должен быть создан не ядром и находиться не в таблице `local`; неполные строки не считаются доказательством. Запись может быть обходным маршрутом VPN-клиента к серверу, но по одному FIB её нельзя надёжно отличить от системного или операторского маршрута, особенно на `rmnet*`, `ccmni*`, `pdp*` и `seth*`. Поэтому результат только требует проверки (`EvidenceSource.NATIVE_HOST_ROUTE`) и сам по себе не подтверждает VPN.
#### 12.3 Детектор эмулятора
JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kernel.qemu*`, `ro.boot.qemu`), goldfish/ranchu hardware, pipe-устройства (`/dev/qemu_pipe`, `/dev/socket/genyd` для Genymotion), goldfish-драйвер в `/proc/tty/drivers`, артефакты BlueStacks. Дополнительно — Build-эвристика (`FINGERPRINT`, `MODEL`, `HARDWARE`, `PRODUCT`, `MANUFACTURER == "Genymotion"`).
В эмуляторе сетевые тесты ненадёжны, поэтому итог — `needsReview = true` (`EvidenceSource.NATIVE_EMULATOR`), **никогда** `detected`.
#### 12.4 Детектор изоляции
Определяются контексты, в которых VPN другого пользователя/профиля невидим сетевым детекторам:
- вторичный пользователь Android (`userId > 0`, извлекается из пути `dataDir`);
- клон приложения / dual-app (`userId == 999` или диапазон `950..959` MIUI);
- рабочий профиль (`DevicePolicyManager.isProfileOwnerApp`).
Любой из сигналов даёт `needsReview = true` (`EvidenceSource.SANDBOX_ISOLATION`), **никогда** `detected`.
#### 12.5 VPN-сигналы (`evaluateVpnSignals`)
Комплексная проверка VPN через нативные JNI-вызовы. Все проверки работают на **нерутованных** устройствах — при отсутствии прав (SELinux/capabilities) проверка помечается как `unavailable` и не крашится.
**Свойства и файлы (`nativeDetectVpnProperties`):**
| Проверка | Что ищем | Источник |
|----------|----------|----------|
| DNS-свойства | `net.dns1-4`, `net.vpn.dns1-2`, `dhcp.tun0.dns1-2` | `__system_property_get` |
| VPN-свойства | `net.vpn.default_iface`, `vpn.enable`, `net.tun0.dns1-2`, `net.ppp0.dns1-2` | `__system_property_get` |
| vpnhide файлы | `/data/local/vpnhide`, `/data/adb/vpnhide`, `/data/local/bypass` и др. | `access(F_OK)` |
| LSPosed/Xposed | `/data/adb/lspd`, `/data/adb/modules/lsposed`, `/data/adb/ksu/modules/lsposed` | `access(F_OK)` |
| Hook-свойства | `persist.sys.lspd`, `persist.sys.lsposed`, `ro.lsposed.hidden` | `__system_property_get` |
Высокая уверенность: `vpn_prop`, `vpnhide`, `hook_prop`. Средняя: остальные.
**Утечки через /proc (`nativeDetectVpnLeaks`):**
| Проверка | Что ищем | Источник |
|----------|----------|----------|
| TCP VPN-порты | Соединения на портах 443, 1194, 51820, 8443, 1723, 500, 4500 | `/proc/net/tcp[6]` |
| UDP VPN-порты | Сокеты на портах 51820 (WireGuard), 1194 (OpenVPN), 500, 4500 | `/proc/net/udp[6]` |
| if_inet6 | Интерфейсы tun/wg/ppp/tap в `/proc/net/if_inet6` | `/proc/net/if_inet6` |
| Route VPN | Маршруты через tun/wg/ppp/tap | `/proc/net/route` |
| FIB trie | `/32 host` записи (не LOCAL) — хост-маршруты VPN | `/proc/net/fib_trie` |
Высокая уверенность: `udp_vpn_port`, `route_vpn_iface`, `arp_vpn_iface`, `inet6_vpn_iface`.
**Продвинутые проверки (`nativeDetectVpnAdvanced`):**
| Проверка | Что ищем | Источник |
|----------|----------|----------|
| ARP VPN | Записи на tun/wg/ppp интерфейсах | `/proc/net/arp` |
| Sysctl | `rp_filter=0`, `ip_forward=1`, `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` и др. |
| ESTABLISHED VPN | Соединения к приватным IP на VPN-портах | `/proc/net/tcp` |
**Нетрадиционные syscall-проверки (`nativeDetectVpnSyscalls`):**
Проверки через прямые netlink-запросы и пробные соединения. При отсутствии прав возвращают `unavailable`:
| Проверка | Метод | Что обнаруживает |
|----------|-------|-------------------|
| RTM_GETRULE | Netlink dump роутинг-правил | VPN policy routing rules |
| RTM_GETQDISC | Netlink dump queueing discipline | VPN qdisc туннели |
| RTM_GETNEIGH | Netlink dump таблицы соседей | Скрытые MAC-адреса (нулевые LLADDR) |
| TCP_INFO MSS | Connect к 8.8.8.8:443, чтение `snd_mss` | Снижение MSS (признак туннеля) |
| SO_BINDTODEVICE | Пробный bind на несуществующий интерфейс | Перехват setsockopt VPN-хуком |
| Loopback port bind | Попытка занять порты 51820/1194/443/8443 | Конфликты портов (VPN listener) |
| BPF OBJ GET | Попытка открыть `/sys/fs/bpf/` карты | Доступ к BPF-картам netd |
| IP_RECVERR | Пробный setsockopt IP_RECVERR | Перехват IP_RECVERR VPN-хуком |
Высокая уверенность: `vpn_policy_rules`, `hidden_mac_neighbors`, `tcp_mss_low`, `loopback_port_conflict`, `bpf_map_accessible`.
#### 12.6 Deep VPN Detector (`VpnNativeDetectorChecker`)
Новые детекты вынесены в отдельную секцию внутри категории Native и сгруппированы по 4 подкатегориям. Данные приходят из нового JNI-метода `nativeDetectVpnDetector(cancellationSignal)`, строки имеют префикс `vdet|`.
**Прямые признаки (Direct signs) — `EvidenceSource.NATIVE_INTERFACE`:**
| Проверка (kind) | Что ищем | Источник |
|-----------------|----------|----------|
| `sysfs_vpn_leak` | Утечка tun/wg/ppp/xfrm через sysfs | `/sys/class/net`, `/sys/devices/virtual/net`, `/proc/sys/net/ipv4|6/conf|neigh` |
| `getifaddrs_vpn` | VPN-интерфейсы в списке `getifaddrs()` | `getifaddrs()` |
| `sysclassnet_vpn` | VPN-интерфейсы в `/sys/class/net` | `stat("/sys/class/net/<if>")` |
| `rtm_getlink_vpn` | VPN-интерфейсы через netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump |
| `proc_if_inet6_vpn` | VPN-интерфейсы в `/proc/net/if_inet6` | `/proc/net/if_inet6` |
| `proc_ipv6_route_vpn` | VPN-маршруты в `/proc/net/ipv6_route` | `/proc/net/ipv6_route` |
| `proc_net_dev_vpn` | VPN-трафик (RX/TX) в `/proc/net/dev` | `/proc/net/dev` |
| `ifindexname_vpn` | VPN-интерфейсы через `if_indextoname()` | `if_indextoname()` перебор ifindex |
| `vpn_policy_rules_netlink` | VPN policy routing rules (table 100–200, oif=tun) | Netlink `RTM_GETRULE` dump |
**Сетевые признаки (Network signs) — `EvidenceSource.NATIVE_SOCKET`:**
| Проверка (kind) | Что ищем | Источник |
|-----------------|----------|----------|
| `fib_trie_denied` | `/proc/net/fib_trie` недоступен (SELinux EACCES) | `fopen("/proc/net/fib_trie")` |
| `inet_diag_denied` | inet_diag netlink запрещён (SELinux) | `socket(NETLINK_SOCK_DIAG)` |
| `bindtodevice_leak` | `SO_BINDTODEVICE` к tun + подтверждение `getsockopt` | `setsockopt(SO_BINDTODEVICE)` |
| `getsockname_leak` | `getsockname()` возвращает приватный VPN-IP | `getsockname()` на UDP-сокете |
| `udp_port_conflict_physical` | Конфликт UDP-портов (500/4500/1194/1701/51820) на физическом IP | `bind()` на физический IP |
| `route_count` | Число маршрутов и уникальных интерфейсов | Netlink `RTM_GETROUTE` dump |
| `trim_oracle` | Расхождение bind-probe vs RTM_GETLINK по числу iface | `if_indextoname()` vs `RTM_GETLINK` |
**Косвенные признаки (Indirect signs) — `EvidenceSource.NATIVE_ROUTE`:**
| Проверка (kind) | Что ищем | Источник |
|-----------------|----------|----------|
| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` |
| `udp_pmtu_ok` / `udp_pmtu_fail` | Успех/неудача отправки 1500 байт по UDP | `sendto()` 1500 байт |
| `normal_pmtu` | Path MTU основного физического интерфейса | `fetchMtu()` через `getifaddrs()` |
| `timing_oracle` | ARM CNTVCT циклы для `sendto()` (мин/макс/сред) | `mrs cntvct_el0` (aarch64) |
| `backpressure` | Пропускная способность при 50000 UDP-пакетах с отменой скана | Неблокирующий `sendto()` burst + проверка cancellation каждые 64 пакета |
| `gso_failed` / `gso_send_failed` / `gso_ok` | Диагностика поддержки UDP GSO; результат не является признаком VPN | `UDP_SEGMENT=1200` + отправка 4800 байт |
| `hw_timestamp` | Аппаратный таймстампинг (`SIOCSHWTSTAMP`, `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` |
**Пробы окружения (Environment probes) — `EvidenceSource.NATIVE_INTERFACE`:**
| Проверка (kind) | Что ищем | Источник |
|-----------------|----------|----------|
| `traceroute_denied` | Traceroute-проба (TTL=1 UDP) заблокирована | `setsockopt(IP_TTL=1)` + `sendto()` |
Высокая уверенность (→ `detected = true`): `sysfs_vpn_leak`, `getifaddrs_vpn`, `sysclassnet_vpn`, `rtm_getlink_vpn`, `proc_if_inet6_vpn`, `proc_ipv6_route_vpn`, `proc_net_dev_vpn`, `ifindexname_vpn`, `vpn_policy_rules_netlink`, `bindtodevice_leak`, `getsockname_leak`, `udp_port_conflict_physical`. Сырые измерения и GSO-результаты информационные; остальные аномалии → `needsReview`.
---
## Вердикт (`VerdictEngine`)
`VerdictEngine` использует не все собранные блоки одинаково.
**R1 — безусловный детект через bypass-evidence:**
Если хотя бы одно detected-evidence имеет источник `SPLIT_TUNNEL_BYPASS`, `XRAY_API`, `VPN_GATEWAY_LEAK` или `VPN_NETWORK_BINDING` → `DETECTED`.
**R2 — включённые пользователем бета-проверки:**
Бета-режим по умолчанию выключен и включается отдельно в «Настройки → β Бета-проверки». В R2 участвуют только доступные, несупрессированные `BETA`-сигналы с `MEDIUM`/`HIGH` confidence. Hard-сигнал обязан быть `HIGH` и разрешён центральной policy. Два сигнала считаются кворумом только из разных `independenceGroup`; stable-сигнал из той же группы не даёт второй голос.
- две независимые сетевые группы либо beta-сеть + независимый stable-сигнал → `DETECTED`;
- изоляция профиля + независимый сетевой сигнал → `DETECTED` с обеими detection-domain;
- две независимые группы подмены/виртуализации → `DETECTED` как подмена окружения, а не как активный VPN;
- изоляция профиля или один beta-сигнал отдельно → не выше `NEEDS_REVIEW` через R6;
- `LOW`, `SYSTEM_NETWORK_CONTEXT`, `UNAVAILABLE`, `PERMISSION_DENIED`, `UNSUPPORTED`, ошибки и супрессированные наблюдения не влияют на вердикт.
Бета-проверки охватывают контекст пользователя и профиля, скрытые read-only Binder API, собственные socket/netd/routing-дескрипторы, sandbox/linker consistency и контролируемые transport/DNS/TLS canary-пробы. Отказ, неполный ответ или смена сети всегда нейтральны. Глобальные WireGuard/OpenVPN DCO/XFRM/BPF-запросы не выполняются в обычном app sandbox: для них есть отдельный выключенный по умолчанию master и явный выбор Shizuku либо root без автоматического fallback. Provider предоставляет только фиксированный read-only AIDL, санитизирует ответ и не принимает команды или пути. Canary endpoint задаётся Gradle-свойствами `betaCanary*`; без конфигурации активные пробы честно возвращают `UNAVAILABLE`. Источники и ограничения зафиксированы в `docs/beta-checks-research.md`.
Подраздел «VPNHide: тайминг хуков» включается отдельным переключателем и по умолчанию выключен. Проба ограничена 900 мс в native-коде и одной секундой вместе с JNI/Kotlin. В первой версии точная высокочастотная оценка поддерживается только на arm64; на других ABI результат явно помечается как `unsupported_arch`.
Путь `libc` сравнивает libc-обёртки `ioctl()` с прямыми системными вызовами и ориентирован на наблюдаемую подмену в VPNHide Zygisk. Экспериментальный путь `kernel` ищет только временные и семантические противоречия между kernel-интерфейсами, поэтому Kernel module и KPM имеют другую наблюдаемость. Согласованная фильтрация одного интерфейса во всех источниках может остаться невыявленной и корректно отображается как `INCONCLUSIVE`.
Сам тайминг не считается доказательством. Только устойчивое временное отклонение вместе с повторяемым семантическим расхождением даёт review-only источник `BETA_HOOK_TIMING`; он может повысить итог до `NEEDS_REVIEW`, но исключён из любого кворума, создающего `DETECTED`.
**R3 — IP-консенсус:**
`IpConsensusBuilder` агрегирует сигналы из GeoIP, IpComparison, CDN Pulling, TUN-проба, bypass и callTransportLeaks. Если определён `geoAxis` (иностранные IP, geo-country mismatch или Warp-индикатор) и одновременно имеет место `probeTargetDivergence`, `probeTargetDirectDivergence` или `crossChannelMismatch` → `DETECTED`.
**R4 — локация vs GeoIP:**
- Если location-сигналы подтверждают РФ (`network_mcc_ru:true`, `cell_country_ru:true` или `location_country_ru:true`), а `GeoIP` одновременно фиксирует иностранный IP (`outsideRu = true`) — `DETECTED`, кроме случая home-routed роуминга.
- Если location подтверждает РФ, а GeoIP показывает hosting/proxy без иностранного IP и нет других сигналов — `NEEDS_REVIEW`.
Флаг `expectedRoamingExit` (определяется `HomeNetworkCatalog` по MCC/MNC SIM-карты и ASN) защищает от ложных срабатываний при международном роуминге с маршрутизацией через домашнего оператора.
**R5 — трёхосевая матрица (geo × direct × indirect):**
- `geoHit` = `GeoIP.outsideRu == true` (кроме роуминга)
- `directHit` = detected-evidence из `DIRECT_NETWORK_CAPABILITIES` или `SYSTEM_PROXY`
- `indirectHit` = detected-evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH` либо high-confidence `NATIVE_SOCKET`
| Geo | Direct | Indirect | Вердикт |
|-----|--------|----------|---------|
| нет | нет | нет | `NOT_DETECTED` |
| нет | да | нет | `NOT_DETECTED` |
| нет | нет | да | `NOT_DETECTED` |
| да | нет | нет | `NEEDS_REVIEW` |
| нет | да | да | `NEEDS_REVIEW` (если geo доступно), иначе `DETECTED` |
| да | да | нет | `DETECTED` |
| да | нет | да | `DETECTED` |
| да | да | да | `DETECTED` |
**R6 — fallback к `NEEDS_REVIEW`:**
Если матрица дала `NOT_DETECTED`, но выполнено хотя бы одно условие — результат повышается до `NEEDS_REVIEW`:
- `bypassResult.needsReview` (открытый proxy без подтверждения обхода)
- `directSigns.needsReview` или `indirectSigns.needsReview`
- `locationSignalHit` (location.detected && !expectedRoamingExit)
- actionable leak из `CallTransportChecker` (статус `NEEDS_REVIEW` не через local proxy)
- `icmpSpoofing.needsReview`
- `NativeSignsChecker` нашёл маркеры хуков (`NATIVE_HOOK_MARKERS`) или нарушение целостности (`NATIVE_LIBRARY_INTEGRITY`)
- `ipConsensus.needsReview`, `ipConsensus.channelConflict` не пуст, `ipConsensus.probeTargetDivergence`
- `TUN_ACTIVE_PROBE` evidence с `detected = false` (tun есть, но VPN не активен для приложения)
- один несупрессированный beta-сигнал `MEDIUM`/`HIGH`, кроме `SYSTEM_NETWORK_CONTEXT`
Примечания:
- `IpComparisonChecker` через `IpConsensusBuilder` теперь косвенно участвует в R3;
- сигналы `INSTALLED_APP` и `VPN_SERVICE_DECLARATION` не входят в матрицу и остаются диагностическими;
- `DomainReachabilityChecker` не влияет на вердикт.
---
## Сборка
Требования: JDK 17+, Android SDK с Build Tools для API 36.
```bash
./gradlew assembleDebug
```
## Режимы подробности результатов
В «Настройки → Внешний вид» доступны три режима. «Простой» показывает понятные итоги без низкоуровневых строк, «Обычный» сохраняет стандартное представление, а «Расширенный» добавляет свёрнутые технические данные к карточкам. Выбор фиксируется при запуске проверки, поэтому изменение настройки применяется только к следующему запуску и не меняет уже выполняющуюся или завершённую проверку. Режим не влияет на проверки, вердикт, таймауты или сетевое поведение.
Технический снимок создаётся только для проверки, запущенной в расширенном режиме. Он хранится только в памяти процесса, очищается при отмене или новой проверке и не добавляется в JSON/Markdown-экспорт. Одна запись ограничена 64 КиБ, весь запуск — 512 КиБ; интерфейс явно отмечает обрезанные записи. Заголовки авторизации и cookie, пароли, токены, UUID, ключи, URI userinfo и чувствительные query-параметры удаляются до сохранения. В приватном режиме дополнительно маскируются все IPv4/IPv6-адреса. Полные ответы Clash `/configs`, `/connections`, `/proxies`, Xray UUID/public keys, BSSID и идентификаторы сот не сохраняются.
---
## Благодарности
[runetfreedom](https://github.com/runetfreedom) — за [per-app-split-bypass-poc](https://github.com/runetfreedom/per-app-split-bypass-poc), на основе которого реализована детекция per-app split bypass.