vpnhide/docs/ROADMAP.md
Danila Gornushko 5902c0f86c
Some checks are pending
CI / setup (push) Waiting to run
CI / lint (push) Blocked by required conditions
CI / kmod-activator (push) Blocked by required conditions
CI / kmod (android12-5.10) (push) Blocked by required conditions
CI / kmod (android13-5.10) (push) Blocked by required conditions
CI / kmod (android13-5.15) (push) Blocked by required conditions
CI / kmod (android14-5.15) (push) Blocked by required conditions
CI / kmod (android14-6.1) (push) Blocked by required conditions
CI / kmod (android15-6.6) (push) Blocked by required conditions
CI / kmod (android16-6.12) (push) Blocked by required conditions
CI / kmod-qemu (android12-5.10) (push) Blocked by required conditions
CI / kmod-qemu (android13-5.10) (push) Blocked by required conditions
CI / kmod-qemu (android13-5.15) (push) Blocked by required conditions
CI / kmod-qemu (android14-5.15) (push) Blocked by required conditions
CI / kmod-qemu (android14-6.1) (push) Blocked by required conditions
CI / kmod-qemu (android15-6.6) (push) Blocked by required conditions
CI / kmod-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu (android12-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.10) (push) Blocked by required conditions
CI / kpm-qemu (android13-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-5.15) (push) Blocked by required conditions
CI / kpm-qemu (android14-6.1) (push) Blocked by required conditions
CI / kpm-qemu (android15-6.6) (push) Blocked by required conditions
CI / kpm-qemu (android16-6.12) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.14) (push) Blocked by required conditions
CI / kpm-qemu-legacy (4.19) (push) Blocked by required conditions
CI / kpm-qemu-legacy (5.4) (push) Blocked by required conditions
CI / kpm (push) Blocked by required conditions
CI / zygisk (push) Blocked by required conditions
CI / lsposed (push) Blocked by required conditions
CI / portshide (push) Blocked by required conditions
CI / release (push) Blocked by required conditions
Upgrade diagnostic captures (#227)
* feat: upgrade diagnostic captures

Collect richer debug and full-logcat bundles with device, backend, hook, portshide, and kernel partition evidence.

Add an explicit kernel image export for maintainer-requested kernel analysis.

* fix: harden diagnostic capture sessions

* fix(lsposed): harden diagnostic export error paths from review

Address correctness findings from a review of the diagnostic-capture upgrade,
all validated on a Pixel 4a via the agent bridge (kernel/debug/logcat exports).

- Kernel image export: wrap exportKernelImagesZip so a zip/IO failure returns
  null (and cleans the parts dir) instead of escaping and stranding the export
  button disabled; add try/finally at the DiagnosticsScreen call site. Cap the
  per-partition dd (skip unknown/oversized partitions, bound with count=) so a
  mislabelled or huge partition can't fill /data.
- HookDiagnostics: drive counter deltas off an explicit "baseline captured"
  flag instead of baselineCounters.isEmpty(), so a fresh boot with an empty
  baseline no longer reports every delta as n/a. Add HookDiagnosticsTest.
- DebugShellSnapshot: raise the 20s snapshot timeout to 60s and preserve+flag a
  timeout-truncated section instead of silently dropping it and every later one.
- LogcatRecorder: delete the intermediate raw logcat file after zipping and on
  failed start, so repeated captures stop leaking multi-MB files into cache.
- DebugExport: rethrow CancellationException before the generic catch so
  navigating away mid-capture cancels cleanly instead of logging a false error.
- DebugCaptureLogging: when the on-disk canonical config is present but
  unparseable, refuse the targets-snapshot rebuild (which would reset settings
  and clear autoHiddenPackages); fall back to an app-process-only toggle.
- Dashboard: don't warn "ports rules not active" for a module the user disabled
  via their manager (new ports_disabled probe, mirrors the activator's guard).
- activator write_atomic: per-process temp name so a boot-time and app-triggered
  activation can't corrupt each other's load_status.
- Drop the wasted multi-user pm enumeration from the counter baseline command.
2026-07-02 16:25:53 +03:00

9.6 KiB
Raw Permalink Blame History

Roadmap

This document tracks larger product directions that are too broad for a single changelog entry. It is not a release commitment; concrete work should still be tracked in GitHub issues and pull requests.

For the current state — which detection vectors exist and which component covers each — see detection-vectors.md. This file is the forward-looking counterpart: vectors that are partially covered or intentionally deferred are described there and cross-linked from here.

VPN Hiding Modes

VPN-preserving concealment

Current Java Network handle hiding is optimized for the recommended split-tunnel setup: target apps are kept outside the VPN, so returning a physical Network handle is consistent with where their traffic should go.

There is a second valid use case: target apps must keep their traffic inside the VPN while the VPN remains hidden from local detection APIs. This matters for users who intentionally route banking, media, work, or home-network apps through a VPN endpoint in a specific country.

The current physical-handle replacement can affect that use case when a target app explicitly binds traffic to the returned Network with APIs such as bindProcessToNetwork, Network.bindSocket, or Network.openConnection. Ordinary sockets still follow Android VPN policy, but explicitly-bound traffic may bypass the VPN on setups that allow physical-network use.

Follow-up work:

  • Add a VPN-preserving concealment mode that hides VPN state without steering explicitly-bound target-app traffic away from the VPN.
  • Decide whether this should be a global mode, per-target setting, or automatic behavior based on VPN bypassability / split-tunnel policy.
  • Extend Diagnostics with a routing-oriented check that can distinguish "VPN hidden and traffic outside VPN" from "VPN hidden and traffic still inside VPN".

Tracking: issue 130.

Network handle edge cases

The physical replacement path used by LSPosed Java hooks intentionally prefers connectivity over perfect concealment in rare fallback cases: if no non-VPN replacement exists for getActiveNetwork(), the original active network is left unchanged instead of reporting that there is no active network.

Follow-up work:

  • Watch real app compatibility reports for APIs that are intentionally suppressed to null, especially getNetworkForType(TYPE_VPN).
  • Consider short-lived caching for the selected replacement network if real devices show measurable overhead from repeated ConnectivityService lookups.

Kernel Module (kmod)

Single-lookup route concealment (low priority)

The kmod hides VPN routes from netlink route dumps (RTM_GETROUTE with NLM_F_DUMP, via the global fib_dump_info hook) and from /proc/net/route and /proc/net/ipv6_route. It does not hide single resolved-route lookups (ip route get <dst>, RTM_GETROUTE without NLM_F_DUMP), served by the static rt_fill_info.

rt_fill_info is intentionally left unhooked. As a directly-called static function it has no stable argument→register ABI: a fixed regs[N] read is correct on some builds and wrong on others (verified to differ between a real LTO device build and a no-LTO QEMU build, where regs[3] held table_id instead of the struct rtable *). The vector is also low value — detection apps enumerate routes via dumps (covered by fib_dump_info), and single lookups respect the caller's own routing, which under the recommended split-tunnel setup resolves to the physical interface anyway.

Follow-up work (low priority):

  • If single-lookup concealment is ever wanted, hook the global rtnl_unicast (ABI-stable, runs in caller context) — the choke point for every single-reply rtnetlink response, IPv4 and IPv6 — and rewrite RTA_OIF in the reply skb to a physical ifindex, instead of reading a fixed register off the static rt_fill_info.

32-bit (compat) SIOCGIFCONF enumeration (low priority)

The kmod .ko filters SIOCGIFCONF interface enumeration via a kretprobe on sock_ioctl, including the 64-bit ifc_req == NULL size-query path. A 32-bit app's SIOCGIFCONF still enters the kernel through compat_sock_ioctlcompat_dev_ifconf and never reaches sock_ioctl, so its enumeration is not filtered (it sees VPN interfaces). KPM also compacts the filled ifreq array but does not yet reduce the ifc_req == NULL size query — a separate, 64-bit gap, not the 32-bit/compat case this heading names. Both remaining cases are tracked in detection-vectors.md (the SIOCGIFCONF notes).

This is low priority: most current Android apps are 64-bit, and modern interface enumeration uses netlink getifaddrs (covered by the rtnl/inet fill hooks), not the legacy SIOCGIFCONF. zygisk (its own getifaddrs/procfs filtering) is unaffected.

Follow-up work (low priority — may revisit):

  • Register a second kretprobe on compat_sock_ioctl and filter SIOCGIFCONF there using the compat struct ifconf / struct ifreq layout (32-bit pointers and ifr_name offset), mirroring the sock_ioctl_ret compaction.

Diagnostics And Observability

  • Add optional per-hook interception counters so users can see which apps are probing VPN state and through which API family.
  • Keep install-time hook status detailed enough to diagnose Android framework drift, especially private-field changes in new Android releases.

Permissive SELinux — dashboard severity and coverage

When the device is in SELinux Permissive mode, the dashboard raises a warning: permissive exposes roughly six detection vectors VPN Hide relies on SELinux to block (RTM_GETROUTE, /proc/net/{tcp,tcp6,udp,udp6,dev,fib_trie}, /sys/class/net; see the coverage table in the top-level README). That warning is the current, deliberate behaviour.

Open questions to settle later:

  • Severity. Is a warning the right weight, or should it be an error (these vectors are genuinely uncovered while permissive) — or downgraded if the user has a backend that already covers them another way?
  • Behaviour. Should VPN Hide do anything beyond warning under permissive — e.g. attempt to cover the SELinux-relied vectors at the app/native layer instead of leaving them to SELinux, or surface which specific vectors are currently open?
  • Detection. getenforce is global; per-domain permissive (permissive type rules) isn't caught. Worth deciding whether that matters.

The Statistics screen surfaces the per-uid × per-hook interception counters every active backend already reports (Java + the one installed native backend), aggregated per app and labelled by detection method. A capture-session mode baselines the counters and shows live deltas while the user exercises a target app — "start capture → open the app → see which methods it probed", without any per-event timestamps or persistence (counters are u64; a backend restart that drops counts below the baseline is detected and re-baselined).

This is intentionally scoped to target apps only — the apps the user already protects, which are the only ones the hooks currently count. That is where the value is: confirming the hiding works and seeing which protected apps probe hardest.

Follow-up work (low priority):

  • Monitoring / capture of non-target apps — counting probes from callers the user has not added, to discover new apps attempting VPN detection (issue 119). This needs an opt-in "observe" mode in the Java backend (system_server sees every caller; kernel backends only count their target UIDs, and Zygisk is only injected into its own targets), plus write-coalescing on the hot path. Deferred until the target-app statistics prove their value.

Zygisk per-app statistics — intentionally not supported

The Statistics screen has no native per-app counters when Zygisk is the active backend (it shows an explanatory banner; the Java/LSPosed counters are still reported). This is a deliberate non-feature, not a gap to fill.

Zygisk hooks run inside each target app's own process under that app's SELinux sandbox (untrusted_app, the app's UID). Such a process cannot write anywhere VPN Hide can read it back — not VPN Hide's private dir, not /data/adb. The existing zygisk heartbeat only works because VPN Hide keeps its own UID in the Zygisk target config, so its own injected process writes to its own data dir (native self-targeting for status, not LSPosed module scope). Exporting per-app counters from other apps' injected processes would require one of:

  • writing a stats file into the target app's own sandbox — which that app can list and use as a "I'm being hooked" detection signal, directly undercutting the stealth this whole project exists for; or
  • a shared world-writable location plus a sepolicy.rule granting untrusted_app write — extra SELinux surface and moving parts.

Both degrade stealth or add attack surface for the least-preferred, most- detectable native backend (priority is kmod > KPM > Zygisk). KPM covers the same native vectors invisibly and already reports in-kernel counters, so the right path for a Zygisk user who wants native statistics is to move to KPM. Revisit only if Zygisk itself is kept long-term; the likelier outcome is that Zygisk is retired once KPM is proven and its users have migrated.

Configuration

  • Consider user-defined VPN interface prefixes for uncommon tunnels or renamed interfaces, while keeping generated defaults as the primary path.
  • Keep split-tunnel guidance prominent in user-facing docs because server-side IP, DNS, and latency checks cannot be fixed client-side.