Commit graph

67 commits

Author SHA1 Message Date
okhsunrog
c059c64582 fix(zygisk): cover libc socket-interface binds 2026-07-13 21:57:05 +03:00
okhsunrog
bca81b80e7 fix: block socket binds to hidden VPN interfaces 2026-07-13 20:22:51 +03:00
okhsunrog
a29f26be7d refactor: separate activation and config responsibilities 2026-07-10 11:31:38 +03:00
okhsunrog
04f772e7b0 fix: close native backend race and filtering gaps 2026-07-10 10:21:29 +03:00
okhsunrog
112a71cd35 docs: hook binder.javaClass directly (name-resolution can hit a parent-loader copy) 2026-07-05 14:53:32 +03:00
okhsunrog
709297dee5 fix(lsposed): attach ConnectivityService only from the live binder
Path A (resolve + hook the class on the system_server classloader at install
time) reported success on MediaTek A11 — every method matched, mask full — yet
none of the connectivity hooks fired and all five checks still leaked. The
class is hooked before ConnectivityService is constructed, and ART replaces the
method entries when it later initialises/compiles the class, dropping the
hooks. It also grabbed the once-only latch, so path C — which had already seen
the addService registration — was skipped ("C:skipped(already-hooked)").

Drop path A entirely. Only ever attach from the live service binder (B/C/D),
which happens after the instance exists so the hooks stick. Confirmed on a
Pixel 4a (A13): C attaches cleanly, no A entry in the trail, all checks pass.
2026-07-05 14:26:24 +03:00
okhsunrog
6b34834163 docs: add LSPosed hook debugging guide 2026-07-05 14:12:17 +03:00
Danila Gornushko
995f218f16
Fix FolkPatch KPM activation (#237)
* Fix FolkPatch KPM activation

* Keep APatch default supercall version
2026-07-04 20:10:02 +03:00
Danila Gornushko
dde3a58aa0
Diagnostics & dashboard redesign: honest who-hid-the-VPN attribution (#231)
* Replace gobley/UniFFI with a cargo-ndk + JNI/JSON native transport

The native check probes reached the app through UniFFI, built by the gobley
Gradle plugin — which needs an AGP-9 fork on a private maven repo to build at
all. Now that diagnostics also want a root-exec ground-truth probe (a second,
JSON-over-stdout transport), the whole native surface is just "run the checks,
return a blob", so a typed-FFI codegen framework no longer earns its keep. Both
paths now share one JSON schema from one crate: a cdylib with a single JNI
function for the in-process app-view run, and a vhprobe bin for the root-exec
run. Check logic and diagnostics behaviour are unchanged.

- native: drop uniffi; add serde + jni 0.22.4; crate-type cdylib+rlib plus a
  vhprobe bin; run_all_json() and one JNI export
- build: remove the gobley plugins/version and the maven fork repo; add a
  config-cache-safe cargo-ndk Exec task (cdylib -> jniLibs, bin -> assets)
  wired via preBuild
- app: NativeProbe (loadLibrary + JNI + JSON parse) replaces the generated
  bindings; native specs join probe results by id

* Add the root-differential CheckOutcome (who hid the VPN)

Each native check now gets an honest outcome — hidden by the backend, hidden by
SELinux, nothing to leak, a real leak, or not measured — instead of one green
"pass" that conflated all of them. The app runs the probes in-process (its own
filtered view) and execs the same vhprobe binary as root (uid 0 is not a hook
target, so the unfiltered ground truth), then diffs per check: root sees the VPN
and the app doesn't = backend hid it; the app was EACCES-blocked = SELinux hid
it; root also sees nothing = nothing to leak. The legacy passed: Boolean? path
and the dashboard are untouched for now; the outcome is exposed on the agent
bridge for validation.

- native: CheckStatus gains SelinuxBlocked; the four EACCES sites report it
  instead of masquerading as pass
- app: CheckOutcome + classifier, and GroundTruthProbe (extract the asset, stage
  to /data/local/tmp, su-exec, parse), computed per run and carried on CheckResults
- bridge: AgentCheckResult gains an outcome token for the native checks

* Rewire the dashboard onto LayerStatus (backend health, not pass-count)

The native/Java tiles were NativeResult/JavaResult rolled up from the raw
pass-count, so an unloaded backend read "Partial" (from SELinux-only passes) and
a couple of failing Java probes read a blanket "Not working". Replace both with
one LayerStatus { Absent, Inactive, Active(hidden, leaks) } and a verdict
Ok/Partial/Broken. Presence is gated before the checks, so an inactive backend
can only render "Not active", never Partial; and each backend is judged only on
the vectors it owns — a leak on a not-owned vector (e.g. /proc/net/dev under a
kernel backend) goes to the hero, not the tile. hidden/leaks come from the
root-differential CheckOutcome, so the counts are measured, not inferred.

- DashboardData: ProtectionCheck.Checked carries LayerStatus; drop
  NativeResult/JavaResult and toNativeResult/toJavaResult; hero, fully-passed,
  and the checks-failed warning read the verdict
- summarizeNativeLayer / summarizeJavaLayer / verdict in LayerStatus.kt
- DashboardScreen: native and Java tiles share one layerSummaryText/Accent
- bridge: AgentProtectionSummary reports the verdict + hidden/leaks
- strings: add "Not active"; unit tests updated (a few Java fails = Partial)

* feat(lsposed): add RTM_GETRULE policy-rule probe, fix if_inet6 attribution

Policy routing is where Android expresses per-app VPN membership: an
`ip rule ... uidrange N-N lookup tun0` steers a UID into the tunnel's own
table, invisible to /proc/net/route (main table only). The new native
probe mirrors the fib_nl_fill_rule kernel filter (VPN iface name, or this
UID steered into a non-standard table), so a working backend reads as
hidden_backend, SELinux-blocked as hidden_selinux, and root ground-truth
as a leak. Validated on the Pixel 4a across enforcing/permissive/root.

Also drop INET6_FILL_IFADDR from the proc_if_inet6 coverage set — no
kernel seq_show hook exists for /proc/net/if_inet6; only the zygisk
openat filter (or SELinux) owns that vector.

* feat(lsposed): rewire diagnostics screen onto CheckOutcome attribution

Each native check now renders its root-differential outcome instead of a
bare pass/fail: a green card with a 'hidden by backend' pill when a hook
did the work, a green card with a blue 'hidden by SELinux' pill when the
app was EACCES-blocked, 'nothing to hide' when nothing was there, red on
a real leak, grey when not measured. The who-hid-it split is what the
whole redesign is for, and it now reads at a glance on the screen.

CheckOutcome rides along on each native CheckResult so the card is a pure
function of the list; Java checks (no root ground truth) keep the
passed tri-state, shown as clean/leak/not-measured in the same vocabulary.

* feat(lsposed): probe legacy NetworkInfo APIs, drop system-proxy check

The NetworkInfo hook (lsposed_network_info) was installed but exercised by
no check — getActiveNetworkInfo() / getNetworkInfo(TYPE_VPN) were untested,
so a green diagnostics run did not actually verify that hook. Add both
probes: getActiveNetworkInfo() marshals a NetworkInfo across Binder (hits
the parcel hook), getNetworkInfo(TYPE_VPN) is the legacy type query the
result sanitizer nulls (issue #85). Together they close the coverage gap so
the Java layer verifies every VPN-hiding hook.

Drop the system-proxy check: no backend hooks the proxy properties, a
consumer VPN sets no system proxy, so it never fired and only added a
green row that proved nothing.

Note: device was in sideload mode at commit time; on-device verification
(both checks pass with LSPosed active) still pending.

* test(lsposed): cover the root-differential classifier and layer rollup

Pins the truth table verified on-device this cycle: classifyNativeOutcome
(leak / hidden-by-backend / hidden-by-SELinux / nothing-to-leak / not-measured,
incl. empty-ground-truth-wins), CheckStatus.toPassed's SELinux mapping, the
Ok/Partial/Broken verdict, and that a native tile is judged only on the vectors
its backend family owns (kernel vs zygisk masks) — an unowned leak surfaces via
unownedNativeLeaks, not the tile. No device needed.

* feat(lsposed): gate diagnostics on VPN Hide being in the tunnel

Diagnostics measure VPN Hide's own process. If VPN Hide is split-tunnelled
out of the VPN, its own traffic never touches the tunnel, so the checks have
nothing to hide from us and a clean run is meaningless. Gate on it: read the
policy routing rules as root (hook-inert ground truth) and ask whether this
app's uid is steered into the VPN's own routing table.

- vhprobe --uid <n>: report {uid, routed, detail}. Identifies the VPN egress
  table via an 'oif <vpn-iface>' rule (is_vpn_iface, not a hardcoded tun0),
  then checks uidrange membership against exactly that table — so a wlan/rmnet
  per-network uidrange rule no longer counts as 'routed through the VPN'.
- New DiagnosticsCache.State.SelfNotRouted + ProtectionCheck.SelfNotRouted;
  a SelfNotRoutedPrompt ('add VPN Hide to your tunnel') on both the diagnostics
  screen and the dashboard, distinct from 'VPN off'. A null (no-root) answer
  does not block. Agent bridge reports state 'self_not_routed'.

Verified on device: uid 10572 (full tunnel) -> routed, 999999 -> not; the
happy path runs the checks (no self_not_routed transition). The blocking path's
prompt still needs a split-tunnel config to see live.

* feat(lsposed): classify Java checks by outcome (hidden/leak)

With the self-in-tunnel gate guaranteeing a VPN artifact is present, a clean
Java-API check means the LSPosed hook removed it and a dirty one is a leak —
so the Java layer becomes binary Leak / HiddenByBackend (NotMeasured only as a
defensive edge for a probe that could not run). classifyJavaOutcome rides onto
each Java CheckResult via withJavaOutcomes; summarizeJavaLayer now reads
hidden/leaks off the outcome so Partial vs Broken is a measurement, mirroring
the native tile. The diagnostics screen renders the same outcome pills for
Java, and the agent bridge threads the Java outcome token.

On-device pill screenshot still pending (GUI nav flaky on this ROM); logic is
unit-tested and the gate round-trip is verified.

* fix(lsposed): probe IPv6 policy rules in RTM_GETRULE and the self-in-tunnel gate

The kernel's RTM_GETRULE dump is per-family, so requesting AF_INET only
returned IPv4 policy rules. Android installs the per-app VPN uidrange/oif
rules for both families, so an IPv6-only rule set was invisible to both the
netlink_getrule diagnostic and the self-in-tunnel gate.

Add a shared dump_fib_rules helper and dump AF_INET + AF_INET6 on the same
socket (distinct seq), merging the results. Verified on-device: the gate
reports routed=true/false correctly and the rule set now covers both families.

* fix(lsposed): tighten diagnostics attribution honesty

Three refinements so the who-hid-it view can't overclaim:

- Java checks that couldn't observe (no active network / capabilities / link
  properties, or getNetworkForType unreachable via reflection) now report
  passed=null so they classify as NotMeasured instead of a false "hidden by
  backend". The self-in-tunnel gate makes these edges near-impossible, but they
  must not paint a backend success.
- Fold the Java-implemented native-level probes (NetworkInterface enum,
  /proc/net/route via ART) into the unowned-native-leak count. They carry no
  root differential, so a leak there was previously invisible to the dashboard.
- Scope the native tile's hidden count to owned vectors like its leak count, so
  the two describe the same vector set and a cross-backend hidden can't mask an
  owned Broken verdict.

* feat(lsposed): compact the diagnostics screen to a status dot + word, detail on tap

The detailed diagnostics screen was a wall of monospace detail — on an enforcing
device 6-7 cards repeated the same "access denied by SELinux" string, and the long
"Hidden by backend / Hidden by SELinux" pills wrapped the check titles.

Each check now reads as one compact card: name + a coloured status dot + a short
word (OK / SELinux / Leak / No data), matching the status-dot idiom used on the
module rows. The card colour tracks current reality only (green when hidden by the
backend, by SELinux, or nothing to leak; red on a leak; neutral when not measured),
so a normal enforcing device stays all-green and the backend-vs-SELinux attribution
rides on the dot colour, not an alarm. The raw detail is collapsed by default and
revealed on tap; a leak is expanded up front. Drops the misleading "Clean" pill that
showed on access-denied fallback rows.

* ci: resolve the lsposed NDK from ANDROID_NDK_HOME, and rustfmt the probe

Two CI fixes for the branch:

- buildRustProbe hard-coded the NDK path as $SDK/ndk/28.2.13676358, but the CI
  image installs the NDK standalone at $ANDROID_NDK_HOME (r28c == that build
  number), not under the SDK. Prefer ANDROID_NDK_HOME/ANDROID_NDK_ROOT like the
  zygisk build already does, falling back to the SDK-managed path for local dev.
- Run rustfmt over the RTM_GETRULE/gate changes in the probe so `cargo fmt
  --check` passes.

* ci: drop orphaned string resources the diagnostics rewire left unused

Android lint (lintDebug, previously skipped because rustfmt failed first) flags
badge_pass/fail/info and dashboard_protection_hooks_inactive as UnusedResources —
they were left behind when the diagnostics screen moved to the outcome model and
the tiles unified onto LayerStatus. Remove them from both locales.

* docs: add docs/diagnostics.md — the self-diagnosis / attribution model

Curated reference for how the app self-tests hiding and attributes who hid the
VPN: the root-differential classifier, the CheckOutcome taxonomy, the
LayerStatus/verdict rollup, the self-in-tunnel gate, the empirical facts that
shape the checks (SELinux carries part of protection; the VPN lives in policy
tables not the main route table), and the native check→hook map. Indexed from
AGENTS.md; complements detection-vectors.md (the hiding side).

* fix(lsposed): show "not checked" on both tiles for every unmeasured state

VPN-off, self-not-routed and needs-restart all mean the layer's hiding checks
did not run, so the Native / Java API tiles now read a plain "not checked" (neutral
accent) in all three — instead of cramming the hero's "VPN is off" title into the
tile for the VPN-off case only. The hero and banner remain the single place that
says why (turn on VPN / add to tunnel / restart); the tiles just report that the
layer was not measured.
2026-07-04 14:42:04 +03:00
Danila Gornushko
589bd4cf0a
refactor(lsposed): canonical config is the single source of truth for debug logging (#229)
Some checks failed
CI / kmod (android15-6.6) (push) Has been cancelled
CI / kmod (android16-6.12) (push) Has been cancelled
CI / kmod-qemu (android12-5.10) (push) Has been cancelled
CI / kmod-qemu (android13-5.10) (push) Has been cancelled
CI / kmod-qemu (android13-5.15) (push) Has been cancelled
CI / kmod-qemu (android14-5.15) (push) Has been cancelled
CI / kmod-qemu (android14-6.1) (push) Has been cancelled
CI / kmod-qemu (android15-6.6) (push) Has been cancelled
CI / kmod-qemu (android16-6.12) (push) Has been cancelled
CI / kpm-qemu (android12-5.10) (push) Has been cancelled
CI / kpm-qemu (android13-5.10) (push) Has been cancelled
CI / kpm-qemu (android13-5.15) (push) Has been cancelled
CI / kpm-qemu (android14-5.15) (push) Has been cancelled
CI / kpm-qemu (android14-6.1) (push) Has been cancelled
CI / kpm-qemu (android15-6.6) (push) Has been cancelled
CI / kpm-qemu (android16-6.12) (push) Has been cancelled
CI / kpm-qemu-legacy (4.14) (push) Has been cancelled
CI / kpm-qemu-legacy (4.19) (push) Has been cancelled
CI / kpm-qemu-legacy (5.4) (push) Has been cancelled
CI / kpm (push) Has been cancelled
CI / zygisk (push) Has been cancelled
CI / lsposed (push) Has been cancelled
CI / portshide (push) Has been cancelled
CI / release (push) Has been cancelled
CI / kmod-activator (push) Has been cancelled
CI / kmod (android12-5.10) (push) Has been cancelled
CI / kmod (android13-5.10) (push) Has been cancelled
CI / kmod (android13-5.15) (push) Has been cancelled
CI / kmod (android14-5.15) (push) Has been cancelled
CI / kmod (android14-6.1) (push) Has been cancelled
* refactor(lsposed): canonical config is the single source of truth for debug logging

The debug flag lived in two stores — the app's SharedPreferences and the
canonical JSON — kept in sync by a startup reconcile plus a drift safety-net,
which was the source of a class of edge cases. Collapse it to one store: the
canonical JSON, with two distinct fields — `debug` (the effective flag every
sink gates on: app logger, system_server HookLog, native backends) and
`debugSwitch` (the user's toggle intent). The Settings toggle writes both; a
capture temporarily forces only `debug` and restores it to `debugSwitch` in a
finally; an interrupted capture self-heals on the next launch via a
`debug <- debugSwitch` reconcile within the same store — no cross-process drift,
no SharedPreferences.

- Add `debugSwitch` to the canonical config (migrates from `debug` when absent);
  serialize both. buildCanonicalConfig inherits `debugSwitch` from the existing
  config, so rebuild paths (Save / auto-hide / manual-hidden) never clobber the
  user's intent when it diverges from the effective flag during a capture.
- The app reads the effective `debug` from the canonical snapshot; the Settings
  toggle reflects `debugSwitch`.
- Captures force only `debug` via a targeted copy of the parsed canonical config
  (never a rebuild from the legacy targets snapshot), so settings/autoHiddenPackages
  are preserved, and restore to `debugSwitch`.
- Replace the prefs<->canonical reconcile and the drift safety-net with a single
  startup `debug <- debugSwitch` reconcile; delete SharedPreferences,
  runtimeReconcileCanonicalConfig, and reapplyDebugLoggingIfDrifted.
- VpnHideLog gains an always-on e() for errors; stray Log.e routed through it.
- Tests: debugSwitch migration/round-trip, capture session, and that the
  auto-hide rebuild preserves debugSwitch.

* refactor(lsposed): centralize log tags and gating, drop double-activator

- add LogTags registry so call sites, HookLog and the debug-capture
  logcat filter share one spelling instead of scattered literals
- extract the shared gate-and-level policy into GatedLogger; VpnHideLog
  and HookLog keep their own sinks and per-process flag sources
- startup debug reconcile no longer runs the native activator twice
  (the reconciled write already runs it; the no-op path runs it once)

* fix(lsposed): re-read canonical before writing the debug toggle

setDebugLoggingEnabled read the cached RootSnapshotCache StateFlow and
then invalidated it on success, with nothing on the Settings screen
repopulating it. A second toggle in the same visit read a null snapshot
and silently no-opped (no write; the switch snapped back). Read via
getOrLoad() so the write always sees fresh canonical state, matching the
capture and startup-reconcile sites.
2026-07-02 18:50:18 +03:00
Danila Gornushko
5902c0f86c
Upgrade diagnostic captures (#227)
Some checks are pending
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
* 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
Danila Gornushko
1582efa067
docs: refresh installation/backend docs + 1.0.0 screenshots (#224)
* docs: refresh installation and backend docs

* docs: fix review findings and add KPM to update-json

Cross-checked the docs refresh against the code and corrected:
- protocol.md: config parser is vpnhide_parse_config (not the legacy
  vpnhide_parse_target_uids); path is kmod/shared/vpnhide_logic.h; drop the
  unverified 0x2026 KPatch-Next marker (activator drives it via the CLI).
- zygisk/README: module declares Zygisk API v2 (not v5); add the recv hook
  row and the if<N> renamed-netdev match.
- README (en/ru): live-check counts are 13 native / 12 Java; Diagnostics is
  under Settings, not a tab; mention the Statistics tab; note KPM beta parity.
- detection-vectors: KPM host-route helper is kpm_is_public_host_route{4,6};
  restore CONFIG_KPROBES and the Zygisk qualifiers; codegen renders four targets.
- storage.md: legacy files are no longer user-managed (migration inputs), not
  'gone'. CLAUDE.md: activator has four bins (kmod/kpm/zygisk/ports).
- development/ROADMAP/kmod/lsposed/portshide READMEs + AGENTS.md: assorted
  accuracy fixes (KPM needs a KernelPatch runtime, per-KMI kmod zip name, etc.).
- CONTRIBUTING.md: clang-format uses scripts/clang-format-c.sh (covers KPM src).
- portshide customize.sh: 'Hiding -> Ports' (renamed tab).
- update-json.sh: emit update-kpm.json so KPM gets Magisk/KSU update checks.

* docs: correct diagnostics check count (25, not 26 vectors)

* docs: refresh README screenshots for the 1.0.0 UI

Replace the v0.5.3-era screenshots (old per-tab Protection UI) with a
new 1.0.0 set: VPN-hidden dashboard, the unified Hiding list with
J/N/A/P, the Statistics tab + per-hook breakdown, per-app hook selection,
diagnostics, settings, and the community screen. Extra shots
(statistics-apps, settings-advanced, diagnostics-java, settings-developer)
are included for the release notes.

* docs: fix the how-it-works layer breakdown

The old 'Layer 3 — additional app-level controls' bucket was wrong:
interface hiding is what Layers 1+2 already do, app-hiding is done by the
LSPosed/Java layer (system_server PackageManager hooks), and ports is a
separate module. Fold app-hiding into Layer 1 (LSPosed) and make Layer 3
the standalone portshide module.

* docs: conceptual-coherence pass on the 5 core docs

Second review pass, this time for architectural coherence rather than
code-facts. Fixes:
- README (en/ru): 'What vpnhide hides' stopped reifying interface hiding
  as a standalone 'layer'/'role' and now ties the three hiding goals to
  the four J/N/A/P roles (interface = Java + Native, toggled independently)
- README (ru): add the KPM-beta hook-parity caveat (parity with the EN side)
- protocol.md: app-hiding is a normal lsposed mask bit, not a separate
  'package/observer records' entry in the §6 profile table
- detection-vectors.md: a complete install is two components (native + lsposed),
  not 'two roles'; name both lsposed jobs
- storage.md: scope the text protocol to the config wire (LSPosed reuses the
  format for its state file); 'by function' not 'by role'; note the Apps role
  shares the same resolver; heading no longer files ports under 'Native layer'
2026-07-02 00:03:17 +03:00
Danila Gornushko
db77076d0d
fix(kmod): hide VPN ifaces from the SIOCGIFCONF size query, not just the fill (#213)
Some checks failed
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
QEMU Test Images / build-push (android12-5.10) (push) Has been cancelled
QEMU Test Images / build-push (android13-5.10) (push) Has been cancelled
QEMU Test Images / build-push (android13-5.15) (push) Has been cancelled
QEMU Test Images / build-push (android14-5.15) (push) Has been cancelled
QEMU Test Images / build-push (android14-6.1) (push) Has been cancelled
QEMU Test Images / build-push (android15-6.6) (push) Has been cancelled
QEMU Test Images / build-push (android16-6.12) (push) Has been cancelled
QEMU Test Images / build-legacy (push) Has been cancelled
The classic two-step SIOCGIFCONF enumeration first calls with ifc_req == NULL
to learn the buffer size (the kernel returns ifc_len = N * sizeof(struct ifreq)
without copying anything), then calls again with a sized buffer. sock_ioctl_ret
filtered the fill but bailed on the NULL probe, so the size query returned the
unfiltered count — a target comparing the two back-to-back saw a one-interface
discrepancy that revealed a hidden VPN.

sock_ioctl_ret now also handles ifc_req == NULL: it rcu-enumerates the socket's
netns netdevs (net captured at entry from the socket, matching dev_ifconf's
sock_net) and subtracts sizeof(struct ifreq) per IPv4-addressed VPN iface,
matched by ifa_label — the exact set and naming inet_gifconf would have emitted
— so the size query agrees with the filtered fill.

Validated by a new harness vector (ifconf_size_probe, via kmod/test/ifconf-probe.c)
asserting a target's size query equals its filtered fill and drops below the
non-target size query: 19/19 PASS, no panic, on GKI 6.1 and 6.6 in QEMU; CI
covers the remaining KMIs once the ddk-qemu image rebuilds with the new probe.

The 32-bit compat SIOCGIFCONF path (compat_sock_ioctl) remains unhooked and is
tracked in docs as low priority. The KPM backend's filter_ifconf still bails on
the NULL probe (raw netdev walking by per-kver offset is not worth it for the
legacy probe) and is noted as WIP parity.
2026-06-30 15:52:13 +03:00
Danila Gornushko
09e02bf625
kmod host-route: cover the legacy kernels (4.14/4.19/5.4), document the 32-bit SIOCGIFCONF gap (#211)
* feat(kpm): extend the public host-route concealment to the legacy kernels

I had disabled the host-route check on the non-GKI kernels (4.14/4.19/5.4) on the
mistaken belief they weren't QEMU-tested — but kpm-qemu-legacy covers exactly
those, and they ARE the kernels the KPM exists to serve. Enable it properly:

- IPv4 now works on every supported kernel. On <5.6 (5.4/4.19/4.14) fib_dump_info
  passes the __be32 dst and dst_len as args 6/7 instead of a fib_rt_info*, so
  kpm_is_public_host_route4 reads them from the right place per ABI. (The __be32
  value in the arg register stores to network-order bytes, so the same
  vpnhide_is_public_ipv4 applies.)
- IPv6 now works on 5.4 and 4.19 too: their fib6_info head matches 5.10, so
  fib6_dst is at +64 (derived from the AOSP/vanilla sources, QEMU-validated on
  the legacy images). 4.14 stays disabled — it uses rt6_info, whose
  cacheline-aligned rt6i_dst has no hand-derivable offset.
- Harness: test hostroute4 on every kernel and hostroute6 on every kernel except
  4.14 (init-kpm.sh gate by uname -r).

Validated A/B in QEMU locally on 5.4, 4.19, 4.14 (legacy images, cortex-a57) and
re-checked 5.10 GKI for no regression on the refactored 5.6+ path.

* feat(kpm): also hide the public IPv6 host-route on 4.14 (rt6_info path)

Completes host-route coverage across every supported kernel. 4.14 predates
fib6_info — rt6_fill_node takes a struct rt6_info*, and the route's destination
is rt6_info.rt6i_dst (a rt6key), not fib6_info.fib6_dst. Add a per-kver rt6_dst
offset and read the rt6key there on the rt6_via_dst path.

The offset is derivable, not guessed: struct dst_entry is 160 bytes (its __pads
keep the size constant regardless of CONFIG_XFRM/CLASSID), the rt6_info prefix
runs to +220, and rt6i_dst is ____cacheline_aligned_in_smp, so it rounds up to
+256 (same for a 64- or 128-byte cacheline). QEMU-validated A/B on the legacy
4.14 image (hostroute6 now hidden for the target, 18/18, no panic), and the
harness host-route gate is dropped — every KPM kver now covers v4 + v6.

* docs(roadmap): record the 32-bit compat SIOCGIFCONF gap as low-priority

* docs(changelog): note the legacy-kernel KPM host-route coverage
2026-06-30 11:32:26 +03:00
Danila Gornushko
5cbdc54e4f
Drop dead proc diagnostics, fix misaligned reads, align parser parity (#206)
* fix(native): drop dead proc diagnostics, fix misaligned reads, parser parity

Three native-correctness fixes from the codebase audit.

Diagnostics: /proc/net/{tcp,tcp6,udp,udp6,fib_trie} expose the VPN only as a
hex local address, never as an interface name, so the name-matching probe could
only ever report a green "no VPN entries" regardless of an actual leak — and
from inside the targeted (self-hidden) process there is no reference tunnel
address to decode and compare. Remove those five checks rather than keep a
meaningless always-pass; the address-leak vector on these files is covered by
zygisk's socket-table filters. /proc/net/{route,ipv6_route,if_inet6,dev}, which
do carry interface names, are unchanged.

UB: SIOCGIFCONF and the netlink dumps reinterpret the kernel's bytes as ifreq /
nlmsghdr / rtattr over plain [u8; N] buffers (align 1), so forming those refs is
undefined behaviour. Back the three buffers with an 8-byte-aligned wrapper so
every such reference is well-formed.

Protocol parity: the Rust parse_header skipped a non-ASCII first significant
line and searched on, while C (kmod/KPM) and Kotlin (LSPosed) reject the whole
payload — same wire bytes, different accept decision across the mutually
exclusive backends. Distinguish "blank/comment" (skip) from "significant but
non-ASCII" (reject) so all three agree. Clarify the §4.2 corner in the spec and
add header-position golden vectors (cfg + kind) that all three parsers now pass.

* style: rustfmt protocol parse_header
2026-06-30 11:30:11 +03:00
Danila Gornushko
2ddab4124f
kmod kernel-behavior: host-route parity, config race, shared compactor, stats-slot race (#210)
* fix(kmod): bound KPM ctl0 reply and reject overflowing version

- KPM ctl0 stats/status: vpnhide_format_* report the full intended length,
  which can exceed the 4096-byte stack buffer; clamp to sizeof(buf) before
  clamp_to_line/copy_to_user so a generous outlen can't drive an out-of-bounds
  read of the kernel stack (info leak / panic). The .ko already bounds this way.
- vpnhide_tok_decimal: reject values that overflow unsigned long instead of
  wrapping mod 2^64 and slipping past the version fuse — same contract as the
  hex token parser.

Host shared-logic and protocol-vector tests pass.

* fix(kpm): hide the public IPv4 host-route; share the predicates with the .ko

The KPM route hooks only hid a route when its output device was itself a VPN
interface, missing the public /32 host-route a VPN client pins to the physical
uplink to reach the server — the .ko hides it, the KPM did not, breaking the
backend-parity contract and leaking the VPN server's address through a
routing-table dump even with the tun interface hidden.

Rather than duplicate the .ko's predicates into the KPM, lift the pure
address/iface logic into shared/vpnhide_logic.h — vpnhide_is_public_ipv4
(network-order bytes), vpnhide_is_public_ipv6, and vpnhide_iface_is_physical
(self-contained case-insensitive prefix match) — so BOTH backends share one
implementation, the way the rest of the filtering logic already is. The .ko and
the KPM now call the shared predicates; each keeps only its kernel-struct read.
Host unit tests cover the predicates (test_vpnhide_logic.c), and the QEMU
harness gains a public host-route vector on BOTH backends (init.sh + init-kpm.sh
/ run-kpm.sh).

IPv4 needs no per-kver offset: fib_rt_info.dst/dst_len sit at a constant +12/+16
on every 5.6+ kernel (verified against the GKI sources), and the fri is
stack-resident so the reads are in-bounds. Validated A/B in QEMU — the .ko on
android12-5.10 (freshly DDK-built) and the KPM on android12-5.10 + android16-6.12
(hostroute4 hidden for the target, 17/17, no panic).

The IPv6 /128 analogue in the KPM is left a documented TODO: it needs the
per-kver fib6_info.fib6_dst offset, which varies. The .ko already hides it and
now also uses the shared vpnhide_is_public_ipv6.

* feat(kpm): also hide the public IPv6 host-route via a physical uplink

Completes the host-route parity with the .ko. The KPM now hides a public /128
host-route pinned to a physical uplink (the IPv6 analogue of the /32 just added),
the way the .ko's is_public_host_route6_via_physical does, using the shared
vpnhide_is_public_ipv6 / vpnhide_iface_is_physical predicates.

Unlike IPv4, this needs a per-kver offset: fib6_info.fib6_dst (rt6key { addr@0;
int plen@16 }) sits at +64 on 5.10/5.15/6.1/6.6 and +80 on 6.12 (the new
gc_link), derived from the GKI sources and the existing fib6_info offsets in the
table. A new fib6_info_fib6_dst field carries it; it is 0 (disabled) on the
pre-fib6_info 4.14 rt6_info path and on the non-GKI 5.4/4.19 kernels the QEMU
matrix can't validate — a wrong offset would fault, and the table's doctrine is
proven-not-guessed.

Validated A/B in QEMU on every distinct GKI offset struct — KPM on
android12-5.10, android13-5.15, android14-6.1, android16-6.12 and the .ko on
android12-5.10 (new hostroute6 vector hidden for the target, 18/18, no panic).
android15-6.6 shares the 6.1 struct and is covered by CI's per-KMI matrix.

* fix(kpm): publish config via a lock-free double-buffer, not in place

vpnhide_kpm_ctl0 parsed a CONFIG payload straight into the live targets[] array
and only then updated nr_targets, while every hooked syscall reads that same
array lock-free via hook_active() on other CPUs. A hook firing mid-update could
read a torn target (new uid with stale hookmask, or a half-overwritten entry
against the old, larger count) — momentarily filtering the wrong app: a target's
VPN briefly visible, or a non-target briefly hidden, on every config replace. The
.ko avoids this by parsing into a staging buffer and publishing under a spinlock;
the KPM had dropped both.

KP has no kernel spinlock/RCU, so mirror it lock-free: hold the config in a
two-element double-buffer, parse each update into a stack-local staging set, fill
the inactive buffer, and publish it with a single release-store of the active
index. hook_active() acquire-loads that index once and reads only that snapshot,
so the mask gate and the per-uid scan are always from one consistent config —
never a mix. Writers are the serialized control path (ctl0 / init load-args), so
they never pick the same inactive buffer.

Validated in QEMU on android12-5.10 (kver-independent — no offsets involved):
all hide/keep vectors still pass (18/18, no panic), exercising the rewritten
hook_active hot path.

* refactor(kmod): share the seq compactor and add a lock-free hook_active gate

Two .ko changes for parity with the KPM, no behavior change.

#29: fib_route_ret / ipv6_route_ret hand-rolled the /proc/net/route line
compaction that shared/vpnhide_logic.h already provides as
vpnhide_compact_seq_lines — the exact function the KPM calls for the same hooks.
The header even documents the inline copies as "intentionally byte-identical" to
the shared one. Replace both with the shared call (FIELD_FIRST / FIELD_LAST), so
the compaction lives in one place instead of three.

#30: hook_active() called target_mask() unconditionally, taking the global
targets_lock spinlock on every hooked syscall (e.g. every socket ioctl) even
with no targets configured. Add an active_hook_mask — the OR of all targets'
hookmasks, recomputed under targets_lock on each config apply — and short-circuit
hook_active() on a lock-free read before locking, matching the KPM's fast path.

Validated A/B in QEMU on android12-5.10 (freshly DDK-built): proc_route_v4/v6
hidden for the target, non-VPN routes preserved exactly (compactor output
byte-identical), 18/18, no panic.

* fix(kpm): avoid double-allocating a stats slot for one uid under a CAS race

stats_slot_for_uid() could hand the same uid two slots, splitting its per-hook
counters across two stats lines. Two races: a concurrent claimer mid-init (slot
state 2, uid not yet readable) was skipped by the second scan, which then
allocated a fresh slot for the same uid; and on a lost CAS the code moved on to
the next slot instead of re-checking the one the winner took.

Wait out the transient init state (state 2) so the uid becomes readable before
deciding, and re-examine a slot after losing its CAS instead of skipping ahead.
A residual window remains only if two first-hits for one uid claim two different
free slots at the same instant — that just splits a counter, never a crash; a
perfect lock-free find-or-insert needs a lock KP deliberately does without.

Validated in QEMU on android12-5.10: stats vectors unchanged, 18/18, no panic.

* docs: record the two narrow SIOCGIFCONF gaps (size-query, 32-bit compat)

* fix(kpm): seqlock config instead of double-buffer; gate host-route on GKI 5.6+

CI surfaced three issues in the kmod-behavior batch:

- The lock-free double-buffer (two copies of targets[]) grew the .kpm enough that
  the patched android16-6.12 image stopped booting (KP embeds the KPM into a
  reserved kernel region with limited headroom; 6.12 had the least, 5.10 was
  fine). Replace it with a seqlock: a writer bumps cfg_seq odd, mutates targets[]
  in place, bumps it even; the hook reader retries while odd / on a concurrent
  change. Same torn-read protection (#26/#28/#31), one extra word instead of a
  second targets[]. In-place parse is safe — vpnhide_parse_config only writes
  after a valid header, so a reject-whole leaves the old config intact.
  Re-validated A/B in QEMU: KPM boots and passes on 5.10 and 6.12 (18/18).

- The new hostroute4/hostroute6 vectors failed on the non-GKI legacy kernels
  (4.14/4.19/5.4), where the host-route is intentionally disabled (IPv4 needs the
  5.6+ fib_rt_info path; IPv6 needs a per-kver fib6_dst offset only populated for
  GKI). Gate them in init-kpm.sh on the running kernel's version (>= 5.6) and
  emit a SKIP marker that run-kpm.sh honors otherwise.

- clang-format the two new hot-path blocks with the pinned 18.x formatter.
2026-06-30 10:10:12 +03:00
Danila Gornushko
211e59ed97
fix(lsposed): clarify KPM-awaiting-superkey and soften no-targets dashboard message (#197)
Add a dashboard warning when KPM is installed under APatch but dormant
because no superkey is saved (runtime=apatch, detail=awaiting_superkey for
the current boot) — it previously read as inactive with no reason. Demote
the no-targets message from error to info, since a fresh, not-yet-configured
install isn't broken. Drop the drifted I1/I2/W2/W4/W5 comment prefixes (the
err/warn/info call already conveys severity) and note the open permissive-
SELinux severity/coverage questions in the roadmap.
2026-06-30 00:44:52 +03:00
okhsunrog
8916d88ab1 docs(roadmap): record that Zygisk per-app stats are intentionally unsupported
Zygisk hooks run inside each target app's own sandbox and can't export counters
anywhere VPN Hide can read without either leaving a self-detectable artifact in
the target's sandbox or adding a world-writable + sepolicy surface — both
degrade stealth for the least-preferred, soon-to-be-retired native backend. KPM
covers the same vectors invisibly and already reports counters, so that's the
path for users who want native statistics.
2026-06-29 22:33:57 +03:00
okhsunrog
034efefbf9 feat(lsposed): per-app probe statistics with detection-method breakdown
Reshape the Statistics screen from per-backend × per-(uid,hook) counter rows
into a per-app view: each app that has triggered a hook is one card showing its
total probe count and a breakdown by detection method. The 25 raw hooks fold
into a small user-facing taxonomy (routes, interfaces, ioctl, policy rules,
NetworkCapabilities, LinkProperties, NetworkInfo, network handle,
ConnectivityService, package enumeration) tagged by surface (Java API / native /
packages). Counts are aggregated across every active backend (Java + the one
installed native backend, including Zygisk's libc hooks).

Scoped to target apps only — the apps the hooks already count. Monitoring of
non-target apps to discover new probers is deferred (docs/ROADMAP.md).

- ProbeStats.kt: DetectionMethod / MethodSurface taxonomy + buildAppProbeStats
  aggregator (pure, unit-tested).
- StatisticsScreen: per-app cards with method chips; hero shows probes / apps /
  methods / active backends.
- Drop the now-unused per-row strings; EN + RU method/surface labels.
2026-06-29 20:40:29 +03:00
okhsunrog
52b137b78e Add backend-specific native hook selection 2026-06-29 19:24:03 +03:00
Danila Gornushko
c68f191a4d
Merge pull request #180 from okhsunrog/feat/ports-policy-ranges
feat(portshide): add configurable port ranges
2026-06-29 16:21:38 +03:00
okhsunrog
31f51a81aa fix(lsposed): avoid app-hiding self-lookups 2026-06-29 16:12:58 +03:00
okhsunrog
0cfa16d331 feat(portshide): add configurable port ranges 2026-06-29 15:56:59 +03:00
okhsunrog
26ba462256 docs: clarify adb su command quoting 2026-06-29 15:56:51 +03:00
okhsunrog
ecedd427e8 feat(lsposed): improve protection help 2026-06-29 14:02:47 +03:00
okhsunrog
364ab53a74 feat(lsposed): add detailed diagnostics and java hook controls 2026-06-29 12:36:57 +03:00
Danila Gornushko
c6a5a33d4c
Merge pull request #177 from okhsunrog/feat/pin-clang-format-18
Some checks failed
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
QEMU Test Images / build-push (android15-6.6) (push) Has been cancelled
QEMU Test Images / build-push (android12-5.10) (push) Has been cancelled
QEMU Test Images / build-push (android13-5.10) (push) Has been cancelled
QEMU Test Images / build-push (android13-5.15) (push) Has been cancelled
QEMU Test Images / build-push (android14-5.15) (push) Has been cancelled
QEMU Test Images / build-push (android14-6.1) (push) Has been cancelled
QEMU Test Images / build-push (android16-6.12) (push) Has been cancelled
QEMU Test Images / build-legacy (push) Has been cancelled
Pin C formatting to clang-format 18
2026-06-29 03:01:35 +03:00
okhsunrog
27d9bbd089 Pin C formatting to clang-format 18 2026-06-29 02:55:57 +03:00
okhsunrog
552762a246 Add LSPosed interception stats 2026-06-29 02:43:23 +03:00
Danila Gornushko
94b1eb3fb5
Merge pull request #174 from okhsunrog/feat/native-intercept-stats
Add native interception stats
2026-06-29 02:40:01 +03:00
okhsunrog
596a93f1f9 Add native interception stats 2026-06-29 01:47:12 +03:00
okhsunrog
3bf7509f56 Auto-hide VPN apps for app hiding 2026-06-29 01:36:23 +03:00
Danila Gornushko
72edd7895f
Merge pull request #167 from okhsunrog/fix/qemu-image-cache-runtime
Trim QEMU image cache exports
2026-06-29 01:34:21 +03:00
okhsunrog
1361a6e283 fix: report saved apatch superkey from key file 2026-06-29 01:09:39 +03:00
okhsunrog
37705e1ecf ci: trim qemu image cache exports 2026-06-29 00:50:34 +03:00
okhsunrog
fef99af08a fix: tighten kpm follow-up diagnostics 2026-06-28 22:53:37 +03:00
okhsunrog
bccdc324e7 docs(kpm): refresh runtime activation notes 2026-06-28 19:32:56 +03:00
okhsunrog
0f829e52bd fix(kpm): support APatch supercall activation 2026-06-28 19:28:12 +03:00
okhsunrog
69f0a92716 feat(storage): apply ports through activator 2026-06-28 19:01:44 +03:00
okhsunrog
c6cf8607d7 fix(storage): clean up legacy config leftovers 2026-06-28 18:51:47 +03:00
okhsunrog
05a3c0520f fix(storage): move boot wait into activator 2026-06-28 18:33:23 +03:00
okhsunrog
b5790766e7 feat(storage): migrate app to canonical config 2026-06-28 18:27:51 +03:00
okhsunrog
c593c6f9ea docs: storage & activation architecture (target design)
Capture the storage-layer design we converged on, above the wire protocol:

- docs/storage.md (new) — the authority. One JSON canonical
  (/data/system/vpnhide_config.json) as the single source of truth; the text
  protocol stays the runtime IPC for the native backends only; a Rust activator
  (workspace: protocol lib + zygisk cdylib + activator lib with three thin
  src/bin/{kmod,kpm,zygisk}.rs) projects JSON -> wire, each native module shipping
  only its own bin. LSPosed self-reads the canonical (it's an APK+hook, not a
  module, and the Java layer must work standalone), resolving uids via
  packages.list to avoid PM-hook re-entry. Stats stay kmod/KPM-only; zygisk
  per-hook stats deferred (no daemon-free cross-process aggregation). The APatch
  superkey gets an opt-in flag persisting it root-only at /data/adb/vpnhide/superkey
  (DE storage, boot-readable) to unlock APatch boot activation. SELinux layout,
  the file-count collapse, and migration are spelled out.

- protocol.md / state.md — pointers to storage.md and notes on the statements it
  supersedes (LSPosed reading the wire, APatch-can't-boot, the storage model). The
  frozen v1 wire format is unchanged.

- CLAUDE.md — index storage.md + protocol.md.

This is the target design; the current branch's code is the interim
protocol-on-every-backend step it converges toward.
2026-06-28 17:23:17 +03:00
okhsunrog
16dfc70c5a docs + cleanup: legacy protocol references and node names
Finish the migration: docs/state.md, docs/protocol.md, detection-vectors.md and
the kmod/lsposed READMEs now describe the folded /proc/vpnhide_ctl node and the
`vpnhide 1 config` wire on every channel; the kmod ABI section documents
config-in / status+stats-out. The KPM exit drops the dead remove_proc_entry
calls (it has no /proc node — its channel is ctl0). The shared header notes that
the decimal vpnhide_parse_target_uids is the legacy load-args/host-test path,
not the protocol channel. Changelog fragment added.

Refs #23.
2026-06-28 14:54:35 +03:00
okhsunrog
6984136848 docs(protocol): freeze version 1 — resolve OPEN-1/2/4/7
- OPEN-1: 0x prefix mandatory (parse anchor, no optional-spelling drift).
- OPEN-2: positional-after-keyword (the keyword self-describes; no key=value).
- OPEN-4: one folded .ko node renamed /proc/vpnhide_ctl (write=config,
  read=status+stats; debug folds in too so /proc/vpnhide_debug goes away).
  Renamed for semantics, NOT stealth — the node is 0600 and the threat model
  is an unprivileged app (root-level detection is explicitly out of scope).
- OPEN-7: in-band '# vpnhide v1 ...' comment header on reads (free; an agent
  learns the grammar + replace-whole semantics from one cat).

All seven OPENs resolved; version 1 is frozen.
2026-06-28 03:32:04 +03:00
okhsunrog
9aec3d48f1 docs(protocol): backend-selection model + resolve OPEN-3/6
Fold in the architecture decisions from the design discussion:

- §1.5 backend selection: one always-on Java (LSPosed) layer + exactly one
  active native backend, priority kmod>KPM>Zygisk, app picks from status and
  idles the rest; kernel guard (conflicting_backend) as the deadlock backstop.
- OPEN-3 resolved: u64 cumulative-since-load counters (non-destructive reads,
  app computes deltas); count is u64, uid/hookmask/hook_id are u32 (§4.4).
- OPEN-6 resolved: debug folded into the config snapshot (drops per-backend
  debug files + the /proc/vpnhide_debug node).
- §4.3: UID is the key on every channel including Zygisk (one grammar; Zygisk
  matches getuid()), package->UID resolution is the producer's job.
- §7.4 KPM distribution: thin flashable module; keyless KPatch-Next does it all
  in the boot script (recommended path), APatch needs the app + session-only
  superkey (boot can only flag awaiting_superkey).
- §9: resident root hub/daemon reconsidered (vs vpnhide_next) and re-rejected;
  reasoning recorded so it is not re-litigated.
2026-06-28 03:23:34 +03:00
okhsunrog
d190ea39ea docs: add control & stats protocol spec (arbiter)
Bring PROTOCOL.md into docs/ as the wire-format arbiter for the
control-protocol work. Updated from the design draft with what device
testing resolved:

- OPEN-5 resolved on-device: the KernelPatch CLI forwards out_msg to
  stdout (subcommand is 'kpm ctl0', not 'control'); data exits only via
  out_msg, the long return is for short codes. CLI binary must match the
  runtime (c02 vs d05); APatch is superkey-based, KPatch-Next d05 keyless.
- New kind = status (backend health + errors, §4.3) with a codegen'd error
  enum (§5.1), driven by the .ko<->KPM mutual-exclusion finding (§1.2):
  running both kernel backends at once hard-froze a device, so each must
  detect the other and refuse with status.error = conflicting_backend.
2026-06-28 02:07:41 +03:00
okhsunrog
ebd4e84ef6 Clarify kmod and zygisk are alternatives, not stacked
kmod and zygisk are two implementations of the same native layer (install
one, not both); a complete setup is one native layer plus lsposed. The
earlier wording said 'install all three', which is wrong.
2026-06-26 13:58:12 +03:00
okhsunrog
00135ddf8b Add detection-vector coverage map and fix README coverage table
Consolidate "which app probe is hidden by which component" into a single
authoritative doc, and correct the README coverage table, which marked the
netlink path as SELinux-blocked and predated the issue #86 fix.

- Add docs/detection-vectors.md: threat model, the kmod/zygisk/lsposed/SELinux
  layers, a per-vector coverage matrix (interface enum, routes, socket procfs,
  framework Java APIs, package visibility), importance ranking, known gaps, and
  where each layer is defined in code
- Fix README.md / README.en.md table: netlink RTM_GETLINK/GETADDR/GETROUTE are
  not SELinux-restricted; RTM_GETROUTE is now covered by kmod and zygisk;
  RTM_GETADDR/ipv6_route/tcp coverage corrected; rewrite the conclusion to
  explain the netlink path and per-device SELinux variance
- Note that SELinux policy differs across devices, ROMs, and Android versions,
  so the layers never rely on it
- Cross-link the new doc from CLAUDE.md, ROADMAP.md, and state.md
2026-06-26 13:11:57 +03:00
okhsunrog
a4513c95e8 fix(kmod): harden route hooks (drop unreliable rt_fill_info, add IPv6 host-route hint)
Two refinements on top of the netlink route-dump hooks:

1. Drop the rt_fill_info hook. It is a static, directly-called function,
   so the compiler may ignore AAPCS64 and place its args in arbitrary
   registers. Verified in QEMU on a no-LTO android12-5.10 build: regs[3]
   held table_id (254), not the struct rtable* the source signature
   places there — a fixed regs[N] read is build-dependent guesswork that
   differs between LTO device builds and no-LTO builds. The vector is also
   low value: IPv4 route enumeration (RTM_GETROUTE + NLM_F_DUMP, what
   detection apps use) goes through the global, ABI-stable fib_dump_info
   hook; single lookups respect the caller's routing, which is physical
   under the recommended split-tunnel setup. ROADMAP documents the
   rtnl_unicast-based alternative if single-lookup hiding is ever wanted.

2. Extend rt6_fill_node with an IPv6 analogue of the physical host-route
   hint: a public /128 route pinned to a physical interface leaks the VPN
   server's IPv6 even when the tun interface is hidden.

Verified: all 7 GKI KMIs (5.10..6.12) compile; route hooks filter VPN
routes for target UIDs on no-LTO 5.10 in QEMU and on a Pixel 8 Pro
(android14-6.1), no panics.
2026-06-24 06:58:10 +03:00