vpnhide/docs/diagnostics.md

8.9 KiB

Diagnostics — how VPN Hide self-tests hiding and attributes the result

This is the self-diagnosis model: how the app checks that hiding actually works for its own process and honestly reports who hid the VPN on each vector. For the hiding side (which backend covers which detection vector) see detection-vectors.md; for the app↔backend wire see protocol.md.

Devices this was validated on: Pixel 4a (sunfish, Magisk, 4.14, kmod/KPM/Zygisk), Pixel 8 Pro (husky, KernelSU-Next, GKI 6.1, KPM), and an Android 13 Zygisk device.

1. The problem it solves

A check result used to be a single tri-state boolean that conflated three unrelated meanings of "pass": (a) the backend hid the VPN, (b) SELinux denied the probe (EACCES), (c) there was nothing to leak on that surface. Counting all "passes" as backend wins made an installed-but-inactive backend read as "Partial" (its SELinux-blocked reads counted as passes) and a mostly-working Java layer read as "Not working" (any one failing probe painted the whole layer red).

2. Root-differential — the reliable classifier

A clean probe result is ambiguous on its own: hook-suppressed and nothing-to-leak look identical. Root (uid 0) is not a hook target, so a privileged read is the ground truth for "what is actually on this surface". The app runs each native probe twice — in-process (its own uid + SELinux domain + hooks) and as root — and diffs them:

root sees app read → outcome
nothing NothingToLeak (empty ground truth wins, even over EACCES)
VPN EACCES HiddenBySelinux
VPN ok, clean HiddenByBackend
app saw VPN Leak

Priority: an empty ground truth is checked before EACCES — if root sees nothing, the SELinux block is moot, it is simply nothing-to-leak.

Ground truth is the same Rust probe binary run as root, not shell ip/cat. GroundTruthProbe extracts vhprobe from the APK, stages it to /data/local/tmp, and execs it via su; it emits the same JSON as the in-process JNI path (run_all_json), so the two views are directly comparable per check id. (This replaced an earlier gobley/UniFFI binding — the whole native surface is now one JSON-returning function built with plain cargo-ndk.)

3. Per-check outcome (CheckOutcome)

Leak · HiddenByBackend · HiddenBySelinux · NothingToLeak · NotMeasured(reason). Wire/log tokens: leak, hidden_backend, hidden_selinux, nothing_to_leak, not_measured_no_network, not_measured_no_ground_truth.

The Rust probe reports Pass / Fail / SelinuxBlocked (EACCES/EPERM, no longer folded into Pass) / NetworkBlocked (ECONNREFUSED from socket() — no network permission). Native checks classify via the root differential above. Java checks have no root differential (framework IPC), so they are binary — clean ⟹ HiddenByBackend, dirty ⟹ Leak — which is honest only because the self-in-tunnel gate (§5) guarantees a VPN artifact was present to hide.

4. Layer status & verdict (dashboard tiles)

Each dashboard tile is a LayerStatus: Absent (no module installed) · Inactive (installed, not loaded this boot) · Active(hidden, leaks). Presence is decided before the checks, so an unloaded backend can never render a verdict — it just reads "not active" (this is the type-level fix for the old "Partial"). An Active tile's verdict:

  • leaks == 0Ok
  • hidden > 0 && leaks > 0Partial (hides some, an owned vector still leaks)
  • hidden == 0 && leaks > 0Broken (loaded but suppressed nothing)

hidden must be a measurement (the root differential), never inferred from a clean probe — otherwise Partial and Broken are indistinguishable. The native tile is judged only on vectors the active backend owns (has a hook for): a leak on a not-owned vector (e.g. /proc/net/dev under a kernel backend — no kernel hook exists) does not turn the tile red; it surfaces as a hero warning instead. So the tile answers "is this module doing its job" and the hero answers "is the VPN hidden at all". The Java tile uses the same rollup; LSPosed owns every Java check, so all its leaks count.

5. Self-in-tunnel gate

Diagnostics are meaningless if VPN Hide itself is not routed through the VPN: split- tunnelled out, there is no VPN artifact for its own probes to be hidden from, so every check would read misleadingly clean. Before running any checks the app asks vhprobe --uid <selfUid> (root, hook-inert) whether its own uid is routed through the VPN. Two passes over the policy rules (both address families): learn the VPN egress table id(s) from rules that egress via a VPN interface (oif tun*), then check whether a uidrange rule steers this uid into exactly that table. This is stricter than the broad netlink_getrule diagnostic predicate — every uid sits in some per-network table (wlan/rmnet, also non-standard), so it must pin the VPN table specifically. If not routed, the UI shows an "add VPN Hide to your tunnel" prompt instead of clean results. A null answer (no root) does not block.

6. Empirical facts that shape the checks

  • SELinux carries part of "protection", and it is invisible without the differential. On both test kernels, 8 of 13 native probes "pass" under enforcing only because SELinux denies the read. Three of those — /proc/net/if_inet6, /proc/net/dev, /sys/class/net — have no backend coverage at all under a kernel backend (no kernel hook exists for those procfs/sysfs paths, by design); on a permissive device they leak tun0. This is why the permissive-SELinux warning exists and why SELinux attribution is dev-facing, not a user alarm.
  • The VPN lives in protected sockets + per-UID policy tables, not the main route table. A split-tunnel VPN app marks its sockets and installs ip rule … uidrange <uid> lookup tun0; it does not put a default route in the main table. So /proc/net/route (main table only) shows no VPN for the target and resolves to NothingToLeak — while RTM_GETRULE (policy rules) is the real routing detection vector, added as a probe mirroring the fib_nl_fill_rule kernel filter.
  • Some checks never fire on any config: both /proc/net/route reads (native + Java) and the removed system-proxy check. They only added false confidence; the route reads are kept because the differential now labels them NothingToLeak honestly, the proxy check was dropped.
  • Suppression counters can distinguish "hook not loaded" from "hook not working" (a per-hook Δ>0 during a probe is proof the hook did real work). They are not used by diagnostics — the root differential already gives the full 4-way + hidden without them — and stay only in the Statistics tab.

7. Native check → owning hook (KPM/kmod), verified on Pixel 4a

The kernel backend's 11 logical hooks map to the diagnostic checks below; the "gaps" rows are SELinux/zygisk territory by design, not bugs. Full hiding matrix in detection-vectors.md.

check id probes kernel hook notes
ioctl_flags, ioctl_mtu SIOCGIF* by name dev_ioctl ENODEV for tun0
ioctl_conf SIOCGIFCONF sock_ioctl tun0 absent from ifconf
getifaddrs, netlink_getlink RTM_GETLINK / getifaddrs rtnl_fill_ifinfo, inet*_fill_ifaddr
netlink_getroute RTM_GETROUTE v4/v6 fib_dump_info, rt6_fill_node
netlink_getrule RTM_GETRULE policy rules fib_nl_fill_rule v4+v6; kernel-only vector
proc_route /proc/net/route fib_route_seq_show main table — empty for split-tunnel VPN
proc_ipv6_route /proc/net/ipv6_route ipv6_route_seq_show
proc_if_inet6 /proc/net/if_inet6 (none) no kernel seq_show hook — zygisk openat or SELinux only
proc_dev /proc/net/dev (none) zygisk openat or SELinux only
sys_class_net /sys/class/net (none) SELinux only

socket_bind_interface intentionally has no app-process root-differential row yet. An honest test needs the hidden interface name/index from the root view, must pass that exact value into the already-targeted app process, and then must inspect the socket from a non-target UID; simply checking the returned errno would bless the broken return-only implementation this hook was designed to avoid. The QEMU bind-probe performs that full state-level test with a raw syscall and an inherited socket. Runtime hits still appear in Statistics. The Zygisk fallback has a separate zygisk_setsockopt hook for libc-routed calls, but the raw diagnostic probe bypasses it by design; Zygisk does not emit per-hook statistics yet.

Java-level checks (LSPosed) cover the framework side — hasTransport(VPN), NET_CAPABILITY_NOT_VPN, VpnTransportInfo, getAllNetworks, LinkProperties, getNetworkForType(TYPE_VPN), the push NetworkCallback (issue #70), and the legacy getActiveNetworkInfo / getNetworkInfo(TYPE_VPN) APIs.