vpnhide/docs/diagnostics.md

150 lines
8.9 KiB
Markdown

# 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](detection-vectors.md); for the app↔backend wire see
[protocol.md](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 == 0`**Ok**
- `hidden > 0 && leaks > 0`**Partial** (hides some, an owned vector still leaks)
- `hidden == 0 && leaks > 0`**Broken** (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](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.