mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-07-25 08:53:52 +00:00
150 lines
8.9 KiB
Markdown
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.
|