RKNHardering

July 26, 2026 · View on GitHub

Language: Russian | English | فارسی | 中文

RKNHardering

Matrix Telegram

Android app for detecting VPNs and proxies on a device. Implements the Roskomnadzor-style methodology for identifying censorship circumvention tools.

Minimum Android version: 8.0 (API 26).

Check reference

Detailed Native signs and IP comparison explanations are published in the RKNHardering reference. Articles distinguish project verified, community reported, and external project claim evidence. The latter two labels are not guarantees of effectiveness or safety.

You can download the project from:

GitHub F-Droid
Download from GitHub Download from F-Droid

Community Help Wanted

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 Pull Request, or reach out in the Matrix chat/Telegram describing the method, the conditions under which it applies, and its limitations. Any information is valuable, from theoretical ideas to working PoCs.

Architecture

Independent check modules run in parallel. The final verdict is calculated in VerdictEngine.

IpComparisonChecker is stored in the result and shown in the UI as a diagnostic block. It does not participate directly in VerdictEngine, but its data feeds into IpConsensusBuilder.

VpnCheckRunner
├── GeoIpChecker             — GeoIP + hosting/proxy signals
├── IpComparisonChecker      — RU/non-RU IP checkers (diagnostics)
├── DirectSignsChecker       — NetworkCapabilities, system proxy, TUN probe, installed VPN apps
├── IndirectSignsChecker     — interfaces, routes, DNS, dumpsys, proxy-tech signals
├── CallTransportChecker     — STUN/MTProto (leaks and connectivity)
├── CdnPullingChecker        — HTTPS requests to CDN/redirector
├── LocationSignalsChecker   — MCC/SIM/cell/Wi-Fi/BeaconDB
├── BypassChecker            — localhost proxy, Xray gRPC API, Clash/sing-box REST API, SOCKS5-auth probe, underlying-network leak
├── RttTriangulationChecker  — SNITCH (β): RTT triangulation against RU/foreign hosts
├── IcmpSpoofingChecker      — operator ICMP spoofing (blocked host replies to ping)
├── DomainReachabilityChecker — DNS→TCP→TLS pipeline for DPI blocking detection
├── NativeSignsChecker       — JNI checks (routes, interfaces, host-route /32, TUN/TAP by type, hooks, root, emulator, isolation)
└── IpConsensusBuilder       — cross-module IP consensus
        └── VerdictEngine    — final verdict logic

Check Modules

1. GeoIP (GeoIpChecker)

Sources:

  • https://api.ipapi.is/ — primary source for GeoIP fields and proxy/VPN/Tor/datacenter signals
  • https://www.iplocate.io/api/lookup — fallback source for GeoIP fields and an additional vote for hosting (privacy.is_hosting)

Logic:

SignalWhat the code doesResult
countryCode != RUThe IP is treated as foreignneedsReview if neither hosting nor proxy is present
hostingUses majority vote across compatible answers for the same IP (ipapi.is, iplocate.io)detected = true if most compatible sources say hosting=true
proxyUses compatible HTTPS providers (ipapi.is, iplocate.io)detected = true if at least one compatible provider reports proxy/VPN/Tor
country, isp, org, as, queryTaken from ipapi.is, and missing fields are filled from iplocate.io only for a compatible IPno direct effect

Final category result:

  • detected = isHosting || isProxy
  • needsReview = foreignIp && !isHosting && !isProxy

HTTP(S) connect and read timeout: 10 seconds. GeoIpChecker uses only HTTPS providers and returns an error only if no GeoIP provider returns data.


2. IP checker comparison (IpComparisonChecker)

This module compares responses from RU and non-RU public IP checkers. It is shown in the UI as a diagnostic block. It does not participate directly in VerdictEngine, but its data feeds into IpConsensusBuilder, whose results are used in R3.

Service groups:

GroupServices
RUYandex IPv4, 2ip.ru, Yandex IPv6
NON_RUifconfig.me IPv4, ifconfig.me IPv6, checkip.amazonaws.com, ipify, ip.sb IPv4, ip.sb IPv6

Logic:

  • inside each group, a canonicalIp is built if the services agree;
  • IP mismatches within a group, partial responses, and IPv4/IPv6 family conflicts move the group to needsReview or detected depending on data completeness;
  • the overall detected flag is set only if both groups reached full internal consensus but RU and non-RU groups returned different canonical IPs;
  • expected IPv6 endpoint errors can be ignored and do not break IPv4 consensus.

3. Direct signs (DirectSignsChecker)

System signs without active localhost network scanning.

3.1 NetworkCapabilities (checkVpnTransport)

API: ConnectivityManager.getNetworkCapabilities(activeNetwork)

CheckMethod/fieldResult
NetworkCapabilities.TRANSPORT_VPNcaps.hasTransport(TRANSPORT_VPN)detected = true
IS_VPNcaps.toString().contains("IS_VPN")detected = true
VpnTransportInfocaps.toString().contains("VpnTransportInfo")detected = true

IS_VPN and VpnTransportInfo are checked through the string representation of NetworkCapabilities.

When VpnTransportInfo is present (API 29+, via reflection getType()), the transport type is added to findings: SERVICE (VpnService-based app), PLATFORM (always-on / IKEv2), LEGACY (legacy VPN framework), or OEM. This is an informational field and does not affect detected/needsReview.

3.2 System proxy (checkSystemProxy)

Uses:

  • System.getProperty("http.proxyHost") with fallback to Proxy.getDefaultHost()
  • System.getProperty("http.proxyPort") with fallback to Proxy.getDefaultPort()
  • System.getProperty("socksProxyHost")
  • System.getProperty("socksProxyPort")

Logic:

StateResult
host is missingproxy is treated as not configured
host exists but port is invalidneedsReview = true
host exists and port is validdetected = true
port matches a known proxy portan extra finding is added

Known proxy ports: 80, 443, 1080, 3127, 3128, 4080, 5555, 7000, 7044, 8000, 8080, 8081, 8082, 8888, 9000, 9050, 9051, 9150, 12345, and the range 16000..16100.

3.3 TUN Active Probe (checkTunActiveProbe)

If a TUN interface is detected during initialization, UnderlyingNetworkProber sends HTTP requests through the VPN network to both RU and non-RU targets. A DNS path mismatch (different IPs on VPN vs direct path) or explicit per-app VPN exclusion (tun0 present but vpnActive = false) yields detected = true. This signal enters VerdictEngine via EvidenceSource.TUN_ACTIVE_PROBE.

3.4 Installed VPN/Proxy apps (InstalledVpnAppDetector)

The module checks three sources:

  • known package signatures from VpnAppCatalog;
  • apps that declare VpnService.SERVICE_INTERFACE through PackageManager.queryIntentServices;
  • apps with "VPN" in the name.

These are diagnostic signals of installation or VpnService declaration, not confirmation of an active tunnel. Matches move the category into needsReview, but do not by themselves make DirectSignsChecker.detected = true.


4. Indirect signs (IndirectSignsChecker)

4.1 NOT_VPN capability (checkNotVpnCapability)

ConnectivityManager.getNetworkCapabilities(activeNetwork).toString() is checked for the presence of NOT_VPN.

ResultOutcome
NOT_VPN presentnormal
NOT_VPN absentdetected = true

4.2 Network interfaces (checkNetworkInterfaces)

API: NetworkInterface.getNetworkInterfaces(). Only active (isUp) interfaces are checked.

VPN-like interface patterns:

  • tun\d+
  • tap\d+
  • wg\d+
  • ppp\d+
  • utun\d* — macOS/iOS-style TUN
  • zt.* — ZeroTier
  • tailscale\d* — Tailscale
  • svpn\d* — Pulse Secure / Ivanti
  • gre\d+ — GRE tunnels
  • l2tp\d+ — L2TP
  • he-ipv6.* — Hurricane Electric IPv6 tunnel
  • (ipsec|xfrm).* — IPsec / kernel XFRM

Any active interface matching these patterns yields detected = true.

4.3 MTU anomaly (checkMtu)

Logic:

ConditionResult
VPN-like interface with MTU 1..1499detected = true
Non-standard active interface (not wlan.*, rmnet.*, eth.*, lo) with MTU 1..1499detected = true

4.4 Routing (checkRoutingTable)

Data sources:

  • primarily LinkProperties.routes from the Android API;
  • fallback: /proc/net/route if the default route could not be obtained via API.

Detections:

  • default route through a non-standard interface;
  • dedicated non-default routes through VPN/non-standard interfaces;
  • split tunneling pattern: tunnel routes visible together with a normal default route through a standard network.

A default route through wlan.*, rmnet.*, eth.*, lo is treated as normal if the network itself is not marked as VPN.

4.5 DNS (checkDns)

API: ConnectivityManager.getLinkProperties(activeNetwork).dnsServers.

DNS is evaluated together with underlying network snapshots when they are available.

SignalResult
loopback DNS (127.x.x.x, ::1)detected = true
private DNS inherited from the same private/ULA subnet as the main non-VPN networknormal
private DNS while VPN is active and different from the underlying networkdetected = true
private DNS without enough contextneedsReview = true
public DNS replaced while VPN is activeneedsReview = true
link-local (169.254.x.x, fe80::/10)informational

4.6 Additional proxy-technical signals (checkProxyTechnicalSignals)

Checks:

  • installed proxy-only utilities from VpnAppCatalog with LOCAL_PROXY signal but without VPN_SERVICE;
  • local listeners from /proc/net/tcp, /proc/net/tcp6, /proc/net/udp, /proc/net/udp6 on known proxy ports;
  • a large number of localhost listeners on high ports.

Logic:

  • a listener on a known localhost proxy port yields detected = true;
  • a proxy-only utility or many localhost listeners yields needsReview = true.

A separate limitation is recorded: checks for processes, iptables/pf, and system certificates are incomplete without root/privileged access.

4.7 dumpsys vpn_management (checkDumpsysVpn)

Android 12+ only (API 31+). Runs dumpsys vpn_management.

If the parser (VpnDumpsysParser) finds active VPN entries, they yield detected = true. A package is extracted from the entries and matched against VpnAppCatalog:

  • known package: high confidence;
  • unknown package: detected = true and also needsReview = true.

Empty output, Permission Denial, or service unavailability are treated as no detection.

4.8 dumpsys activity services android.net.VpnService (checkDumpsysVpnService)

Runs dumpsys activity services android.net.VpnService.

If active VpnService instances are found, activeApps and evidence are created:

  • known package from the catalog: high confidence;
  • unknown package: detected = true and needsReview = true.

Empty output or no VpnService entries produce no detection.


5. Location signals (LocationSignalsChecker)

This module collects signs confirming that the device is physically in Russia or, conversely, that telephony signals look atypical.

Sources:

  • TelephonyManager.networkOperator, networkCountryIso, networkOperatorName
  • TelephonyManager.simOperator, simCountryIso, isNetworkRoaming
  • requestCellInfoUpdate / allCellInfo
  • WifiManager.scanResults and current BSSID
  • BeaconDB (https://api.beacondb.net/v1/geolocate) for cell/Wi-Fi geolocation
  • reverse geocoding for countryCode

Permissions:

  • ACCESS_FINE_LOCATION is needed for cell lookup;
  • on Android 13+, NEARBY_WIFI_DEVICES is needed for Wi-Fi lookup.

Logic:

SignalResult
networkMcc == 250adds the internal finding network_mcc_ru:true
BeaconDB/reverse geocode returned RUadds cell_country_ru:true and location_country_ru:true
networkMcc != 250needsReview = true
missing permissions or radio datainformational

In the current implementation, LocationSignalsChecker.detected is always false. Its main role in VerdictEngine is to confirm Russia and strengthen a foreign GeoIP signal.


6. Bypass check (BypassChecker)

Four checks run in parallel:

  • ProxyScanner
  • XrayApiScanner
  • ClashApiScanner
  • UnderlyingNetworkProber

6.1 Proxy scanner (ProxyScanner + ProxyProber)

Scans 127.0.0.1 and ::1.

Modes:

ModeDescription
AUTOfirst common ports, then full range
MANUALcheck a single specified port

Popular ports in AUTO are built from VpnAppCatalog.localhostProxyPorts and additionally include 1081, 7890, 7891.

Full scan:

  • range 1024..65535
  • parallelism 200
  • connect timeout 80 ms
  • read timeout 120 ms

Only proxies without authentication are detected:

TypeHow it is detected
SOCKS5greeting 0x05 0x01 0x00 and reply 0x05 0x00
HTTP CONNECTCONNECT ifconfig.me:443 HTTP/1.1 and reply HTTP/1.x 200

An open localhost proxy is not treated as confirmed bypass by itself: it is recorded as needsReview. Bypass confirmation is set only if both a direct IP and a proxy IP can be obtained and they differ.

Additionally:

  • if SOCKS5 is found, but HTTP IP retrieval through it fails and the port does not look like Xray, MtProtoProber is launched;
  • a successful MTProto probe adds an informative finding, but does not affect the final verdict.

Proxy authentication probe (ProxyProber, optional). Enabled via "Probe local proxy authentication" (pref_proxy_auth_probe_enabled, off by default). Applies only to SOCKS5 endpoints on loopback addresses:

  • weak credential dictionary brute-force (RFC 1929): empty pair, admin/admin, user/password, proxy/proxy, test/test, etc. — only when the proxy demands authentication;
  • UDP ASSOCIATE probe on proxies that require no authentication.

Successful credential match or open UDP ASSOCIATE yields detected = true (EvidenceSource.PROXY_AUTH_BYPASS, counted in HARD_DETECT_BYPASS).

6.2 Xray gRPC API scanner (XrayApiScanner + XrayApiClient)

Scans 127.0.0.1 and ::1.

Parameters:

  • range 1024..65535
  • parallelism 100
  • TCP connect timeout 200 ms
  • gRPC deadline 2000 ms with retry on an increased deadline

The check is performed not through a raw HTTP/2 preface, but through a real gRPC call HandlerServiceGrpc.listOutbounds(...).

On success:

  • the endpoint yields detected = true;
  • findings include up to 10 outbound summaries (tag, protocol, address, port, sni) plus a counter for remaining ones.

6.3 Underlying network leak / VPN network binding (UnderlyingNetworkProber)

If VPN is active on the device, the module:

  • iterates through all ConnectivityManager.allNetworks;
  • looks for an internet-capable network without TRANSPORT_VPN;
  • binds HTTP(S) requests to that network;
  • requests the public IP through ifconfig.me, checkip.amazonaws.com, ipv4-internet.yandex.net, ipv6-internet.yandex.net.

If the underlying network is reachable while VPN is active, this is treated as VPN gateway leak and yields detected = true.

6.4 Clash/sing-box REST API scanner (ClashApiScanner + ClashApiClient)

Optional check, enabled via "Clash/sing-box REST API scan" (pref_clash_api_scan_enabled, on by default). Scans the loopback (127.0.0.1, ::1) for the REST API of Clash, mihomo, and sing-box managers.

Parameters:

  • ports 9090, 19090, 9091, 9097
  • TCP connect probe 200 ms, then connect/read 600 ms

Logic:

  • GET /configs — if valid JSON is returned the API is considered live;
  • GET /connections — VPN server IPs are extracted from metadata.destinationIP (up to 10 unique);
  • GET /proxies — proxy node names are collected.

A live API or a non-empty destination IP list yields detected = true (EvidenceSource.CLASH_API, counted in HARD_DETECT_BYPASS).

Final category result:

  • detected = confirmed split tunnel || xrayApiFound || clashApiFound || proxyAuthBypass || vpnGatewayLeak || vpnNetworkBinding
  • needsReview = true if an open proxy is found but the bypass is not confirmed

7. CDN Pulling (CdnPullingChecker)

Sends HTTPS requests to known redirectors and trace endpoints (e.g., Google Video, Cloudflare trace, Meduza) to see what public IP or network metadata is exposed.

8. Call Transport (CallTransportChecker)

Checks UDP/STUN accessibility across global and regional endpoints, and tests TCP MTProto reachability via local proxies. This can reveal mapped public IPs or underlying leaks that bypass conventional tunnels.

9. SNITCH — RTT Triangulation (RttTriangulationChecker) β

Sends ICMP pings to a set of Russian and foreign hosts and compares median round-trip times.

Russian targets: yandex.ru, mail.ru, vk.com, sberbank.ru, gosuslugi.ru.

Foreign targets: facebook.com, github.com, twitter.com, reddit.com, instagram.com.

Logic:

  • if the median RTT to RU hosts exceeds the threshold (80 ms), the device is likely not located in Russia;
  • high jitter (> 60 ms) reduces confidence in the conclusion;
  • the result upgrades the verdict to NEEDS_REVIEW but does not by itself produce DETECTED.

The check is optional and disabled by default.


10. ICMP Spoofing (IcmpSpoofingChecker)

Detects whether the operator is spoofing ICMP replies for blocked hosts.

Default targets:

  • instagram.com — blocked host (BLOCKED);
  • google.com — control host (CONTROL).

Targets are configurable via custom checks. At least one BLOCKED and one CONTROL target are required.

Logic:

ConditionResult
BLOCKED host replied to pingneedsReview = true — possible operator ICMP spoofing
CONTROL host replied with RTT < 10 msneedsReview = true — suspiciously low latency (possible local interception)
Both conditions simultaneouslyneedsReview = true with a stronger signal
CONTROL host did not replyinconclusive
None of the abovenormal

IcmpSpoofingChecker.detected is always false. The result can upgrade the verdict from NOT_DETECTED to NEEDS_REVIEW via R6 in VerdictEngine. Enabled by default. Signals are automatically suppressed when home-routed roaming is detected.


11. Domain Reachability (DomainReachabilityChecker)

Checks each domain from the user-supplied list through a DNS → TCP → TLS pipeline:

StepDetected blockingTimeout
DNSNXDOMAIN, timeout8 s
TCP :443Connection refused, timeout8 s
TLS (SNI)Connection reset — DPI indicator10 s

The TLS step uses a trust-all X.509 manager because the goal is to detect DPI-level connection resets, not to validate certificates. An SSLHandshakeException due to an invalid certificate is treated as a successful TLS step.

Results do not affect the verdict. The module is disabled by default (domainReachabilityEnabled = false) and only activates when a non-empty domain list is provided in the custom check settings.


12. Native Signs (NativeSignsChecker)

Performs low-level JNI checks directly from C++:

  • Native interface listing and getifaddrs() checks
  • Direct /proc/net/route parsing
  • /proc/self/maps scanning for known hook markers
  • libc symbol resolution integrity
  • Root detection (su binaries, magisk properties, selinux, rw /system, etc.)

Native findings can translate into needsReview or generic indirect routing signals.

12.1 TUN/TAP by interface type

For every interface, /sys/class/net/<name>/type is read. A value of 65534 (ARPHRD_TUNTAP) on an active interface whose name does not match any known VPN pattern indicates a TUN/TAP interface masquerading as a regular one. Result: detected = true (EvidenceSource.NATIVE_INTERFACE).

12.2 Host-route /32 heuristic

The routing table (NETLINK) is scanned for a fully described unicast route with global or link scope, a /32 prefix (IPv4) or /128 (IPv6), and a publicly routable destination via a regular physical interface. The route must not be kernel-created or belong to the local table; incomplete rows are not evidence. Such a row may be a VPN client's bypass route to its server, but a FIB row alone cannot reliably distinguish it from a system or carrier route, especially on rmnet*, ccmni*, pdp*, and seth*. The result therefore only requires review (EvidenceSource.NATIVE_HOST_ROUTE) and does not confirm a VPN by itself.

12.3 Emulator detector

JNI checks (nativeDetectEmulator): QEMU system properties (ro.kernel.qemu*, ro.boot.qemu), goldfish/ranchu hardware, pipe devices (/dev/qemu_pipe, /dev/socket/genyd for Genymotion), goldfish driver in /proc/tty/drivers, BlueStacks artifacts. Additionally, Build heuristics (FINGERPRINT, MODEL, HARDWARE, PRODUCT, MANUFACTURER == "Genymotion").

Network tests are unreliable on an emulator, so the result is needsReview = true (EvidenceSource.NATIVE_EMULATOR), never detected.

12.4 Isolation detector

Identifies contexts where another user's or profile's VPN is invisible to network detectors:

  • secondary Android user (userId > 0, extracted from the dataDir path);
  • app clone / dual-app (userId == 999 or the MIUI range 950..959);
  • work profile (DevicePolicyManager.isProfileOwnerApp).

Any of these signals yields needsReview = true (EvidenceSource.SANDBOX_ISOLATION), never detected.

12.5 VPN Signals (evaluateVpnSignals)

Comprehensive VPN detection via native JNI calls. All checks work on non-rooted devices — when permissions are missing (SELinux/capabilities), the check is marked as unavailable and does not crash.

Properties and files (nativeDetectVpnProperties):

CheckWhat we look forSource
DNS propertiesnet.dns1-4, net.vpn.dns1-2, dhcp.tun0.dns1-2__system_property_get
VPN propertiesnet.vpn.default_iface, vpn.enable, net.tun0.dns1-2, net.ppp0.dns1-2__system_property_get
vpnhide files/data/local/vpnhide, /data/adb/vpnhide, /data/local/bypass etc.access(F_OK)
LSPosed/Xposed/data/adb/lspd, /data/adb/modules/lsposed, /data/adb/ksu/modules/lsposedaccess(F_OK)
Hook propertiespersist.sys.lspd, persist.sys.lsposed, ro.lsposed.hidden__system_property_get

High confidence: vpn_prop, vpnhide, hook_prop. Medium: others.

Leaks via /proc (nativeDetectVpnLeaks):

CheckWhat we look forSource
TCP VPN portsConnections on ports 443, 1194, 51820, 8443, 1723, 500, 4500/proc/net/tcp[6]
UDP VPN portsSockets on ports 51820 (WireGuard), 1194 (OpenVPN), 500, 4500/proc/net/udp[6]
if_inet6tun/wg/ppp/tap interfaces in /proc/net/if_inet6/proc/net/if_inet6
Route VPNRoutes via tun/wg/ppp/tap/proc/net/route
FIB trie/32 host entries (non-LOCAL) — VPN host routes/proc/net/fib_trie

High confidence: udp_vpn_port, route_vpn_iface, arp_vpn_iface, inet6_vpn_iface.

Advanced checks (nativeDetectVpnAdvanced):

CheckWhat we look forSource
ARP VPNEntries on tun/wg/ppp interfaces/proc/net/arp
Sysctlrp_filter=0, ip_forward=1, forwarding=1/proc/sys/net/ipv4/conf/*/rp_filter etc.
ESTABLISHED VPNConnections to private IPs on VPN ports/proc/net/tcp

Non-traditional syscall checks (nativeDetectVpnSyscalls):

Checks via direct netlink requests and probe connections. Returns unavailable when permissions are missing:

CheckMethodWhat it detects
RTM_GETRULENetlink dump of routing policy rulesVPN policy routing rules
RTM_GETQDISCNetlink dump of queueing disciplinesVPN qdisc tunnels
RTM_GETNEIGHNetlink dump of neighbor tableHidden MAC addresses (zero LLADDR)
TCP_INFO MSSConnect to 8.8.8.8:443, read snd_mssMSS reduction (tunnel indicator)
SO_BINDTODEVICEProbe bind to non-existent interfaceVPN hook intercepting setsockopt
Loopback port bindTry to claim ports 51820/1194/443/8443Port conflicts (VPN listener)
BPF OBJ GETTry to open /sys/fs/bpf/ mapsAccess to netd BPF maps
IP_RECVERRProbe setsockopt IP_RECVERRVPN hook intercepting IP_RECVERR

High confidence: vpn_policy_rules, hidden_mac_neighbors, tcp_mss_low, loopback_port_conflict, bpf_map_accessible.

12.6 Deep VPN Detector (VpnNativeDetectorChecker)

The new detectors live in a dedicated sub-section inside the Native category, grouped into 4 sub-categories. Data comes from the new JNI method nativeDetectVpnDetector(), with lines prefixed vdet|.

Direct signs — EvidenceSource.NATIVE_INTERFACE:

Check (kind)What it looks forSource
sysfs_vpn_leaktun/wg/ppp/xfrm leak via sysfs/sys/class/net, /sys/devices/virtual/net, `/proc/sys/net/ipv4
getifaddrs_vpnVPN interfaces in getifaddrs() listgetifaddrs()
sysclassnet_vpnVPN interfaces in /sys/class/netstat("/sys/class/net/<if>")
rtm_getlink_vpnVPN interfaces via netlink RTM_GETLINKNetlink RTM_GETLINK dump
proc_if_inet6_vpnVPN interfaces in /proc/net/if_inet6/proc/net/if_inet6
proc_ipv6_route_vpnVPN routes in /proc/net/ipv6_route/proc/net/ipv6_route
proc_net_dev_vpnVPN traffic (RX/TX) in /proc/net/dev/proc/net/dev
ifindexname_vpnVPN interfaces via if_indextoname()if_indextoname() ifindex sweep
vpn_policy_rules_netlinkVPN policy routing rules (table 100–200, oif=tun)Netlink RTM_GETRULE dump

Network signs — EvidenceSource.NATIVE_SOCKET:

Check (kind)What it looks forSource
fib_trie_denied/proc/net/fib_trie unavailable (SELinux EACCES)fopen("/proc/net/fib_trie")
inet_diag_deniedinet_diag netlink denied (SELinux)socket(NETLINK_SOCK_DIAG)
bindtodevice_leakSO_BINDTODEVICE to tun + getsockopt confirmsetsockopt(SO_BINDTODEVICE)
getsockname_leakgetsockname() returns private VPN IPgetsockname() on UDP socket
udp_port_conflict_physicalUDP port conflict (500/4500/1194/1701/51820) on physical IPbind() on physical IP
route_countRoute count and unique interface countNetlink RTM_GETROUTE dump
trim_oraclebind-probe vs RTM_GETLINK iface count mismatchif_indextoname() vs RTM_GETLINK

Indirect signs — EvidenceSource.NATIVE_ROUTE:

Check (kind)What it looks forSource
pmtu_mss_combinedUDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss)connect() + getsockopt(TCP_INFO)
udp_pmtu_ok / udp_pmtu_failSuccess/failure sending 1500 bytes over UDPsendto() 1500 bytes
normal_pmtuPath MTU of primary physical interfacefetchMtu() via getifaddrs()
timing_oracleARM CNTVCT cycles for sendto() (min/max/avg)mrs cntvct_el0 (aarch64)
backpressureThroughput under 50000 UDP packets with scan cancellationNon-blocking sendto() burst + cancellation check every 64 packets
gso_failed / gso_send_failed / gso_okUDP GSO capability diagnostics; the result is not VPN evidenceUDP_SEGMENT=1200 + 4800-byte send
hw_timestampHardware timestamping (SIOCSHWTSTAMP, SO_TIMESTAMPING)ioctl(SIOCSHWTSTAMP)

Environment probes — EvidenceSource.NATIVE_INTERFACE:

Check (kind)What it looks forSource
traceroute_deniedTraceroute probe (TTL=1 UDP) blockedsetsockopt(IP_TTL=1) + sendto()

High confidence (→ 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. Raw measurements and GSO results are informational; other anomalies → needsReview.


Verdict (VerdictEngine)

VerdictEngine does not use all collected blocks equally.

R1 — unconditional detection via bypass evidence:

If any detected evidence has source SPLIT_TUNNEL_BYPASS, XRAY_API, VPN_GATEWAY_LEAK, or VPN_NETWORK_BINDINGDETECTED.

R2 — user-enabled beta checks:

Beta checks are off by default. Only available, unsuppressed BETA evidence with MEDIUM/HIGH confidence participates. Two different independenceGroup values, or one beta group plus an independent stable network source, can produce DETECTED; a single signal or profile isolation alone produces at most NEEDS_REVIEW. LOW, context-only, denied, unsupported, unavailable, error, and suppressed observations are neutral. The implemented families cover execution context, read-only hidden Binder APIs, app-owned socket/netd/routing descriptors, sandbox/linker consistency, and controlled transport/DNS/TLS canaries. Global WireGuard/OpenVPN DCO/XFRM/BPF queries require a separate, default-off consent switch and an explicit Shizuku or root provider; there is no automatic fallback. The provider exposes only fixed read-only AIDL methods and sanitizes results. Canary endpoints are supplied through betaCanary* Gradle properties; without them active probes report UNAVAILABLE. See beta-checks-research.md.

R3 — IP consensus:

IpConsensusBuilder aggregates signals from GeoIP, IpComparison, CDN Pulling, TUN probe, bypass, and callTransportLeaks. If a geoAxis is established (foreign IPs, geo-country mismatch, or Warp indicator) and probeTargetDivergence, probeTargetDirectDivergence, or crossChannelMismatch is present → DETECTED.

R4 — location vs GeoIP:

  • If location signals confirm Russia (network_mcc_ru:true, cell_country_ru:true, or location_country_ru:true) while GeoIP reports a foreign IP (outsideRu = true) → DETECTED, unless home-routed roaming is detected.
  • If location confirms Russia and GeoIP shows hosting/proxy without a foreign IP and no other signals → NEEDS_REVIEW.

The expectedRoamingExit flag (resolved by HomeNetworkCatalog from SIM MCC/MNC and ASN) protects against false positives when a foreign SIM is used on a Russian visited network with home-operator routing.

R5 — three-axis matrix (geo × direct × indirect):

  • geoHit = GeoIP.outsideRu == true (excluding roaming)
  • directHit = detected evidence from DIRECT_NETWORK_CAPABILITIES or SYSTEM_PROXY
  • indirectHit = detected evidence from INDIRECT_NETWORK_CAPABILITIES, ACTIVE_VPN, NETWORK_INTERFACE, ROUTING, DNS, PROXY_TECHNICAL_SIGNAL, NATIVE_INTERFACE, NATIVE_ROUTE, NATIVE_JVM_MISMATCH, or high-confidence NATIVE_SOCKET
GeoDirectIndirectVerdict
nononoNOT_DETECTED
noyesnoNOT_DETECTED
nonoyesNOT_DETECTED
yesnonoNEEDS_REVIEW
noyesyesNEEDS_REVIEW (if geo available), else DETECTED
yesyesnoDETECTED
yesnoyesDETECTED
yesyesyesDETECTED

R6 — fallback to NEEDS_REVIEW:

If the matrix returned NOT_DETECTED but at least one condition holds, the result is upgraded to NEEDS_REVIEW:

  • bypassResult.needsReview (open proxy without bypass confirmation)
  • directSigns.needsReview or indirectSigns.needsReview
  • locationSignalHit (location.detected && !expectedRoamingExit)
  • actionable leak from CallTransportChecker (status NEEDS_REVIEW, not via local proxy)
  • icmpSpoofing.needsReview
  • NativeSignsChecker found hook markers (NATIVE_HOOK_MARKERS) or integrity violation (NATIVE_LIBRARY_INTEGRITY)
  • ipConsensus.needsReview, ipConsensus.channelConflict is non-empty, or ipConsensus.probeTargetDivergence
  • TUN_ACTIVE_PROBE evidence with detected = false (tun present but VPN not active for this app)

Notes:

  • IpComparisonChecker now participates indirectly in R3 via IpConsensusBuilder;
  • INSTALLED_APP and VPN_SERVICE_DECLARATION signals are not part of the matrix and remain diagnostic;
  • DomainReachabilityChecker does not affect the verdict.

Build

Requirements: JDK 17+, Android SDK with Build Tools for API 36.

./gradlew assembleDebug

Result detail modes

Settings → Appearance provides three modes. Simple shows plain-language outcomes without low-level strings, Normal preserves the standard presentation, and Advanced adds collapsed technical data to each result card. The selection is captured when a scan starts, so changing it affects only the next scan and never redraws a running or completed scan. The mode does not change checks, verdicts, timeouts, or network behavior.

The technical snapshot is created only for scans started in Advanced mode. It exists only in process memory, is cleared on cancellation or a new scan, and is not included in JSON/Markdown exports. Each entry is limited to 64 KiB and a scan to 512 KiB; truncation is shown explicitly. Authorization and cookie headers, passwords, tokens, UUIDs, keys, URI userinfo, and sensitive query parameters are removed before storage. Privacy mode additionally masks every IPv4/IPv6 address. Full Clash /configs, /connections, /proxies responses, Xray UUIDs/public keys, BSSIDs, and cell identifiers are never stored.


Acknowledgements

runetfreedom — for per-app-split-bypass-poc, which the per-app split bypass detection is based on.