The three files written under /data/system/ to coordinate state between
the LSPosed system_server hook and the app — vpnhide_uids.txt,
vpnhide_hidden_pkgs.txt, vpnhide_observer_uids.txt — were chmodded
0644 root:root. /data/system/ itself is mode 0775 system:system,
traversable by untrusted apps, so any "other"-readable file there is
both enumerable (`ls /data/system/`) and openable by name. Untrusted
apps could:
cat /data/system/vpnhide_uids.txt # all target UIDs
cat /data/system/vpnhide_hidden_pkgs.txt # the hide list
cat /data/system/vpnhide_observer_uids.txt # observer UIDs
If the reader's own UID is in vpnhide_uids.txt, that's a positive
"vpnhide is filtering me right now" detection — strictly stronger than
the presence-of-marker fingerprint we already closed for
vpnhide_hook_active in PR #100.
Switch every write site to mode 0640 + chown root:system. system_server
runs as UID 1000 with `system` (GID 1000) in its supplementary groups,
so it still gets read via the group bit. Untrusted apps fall to the
"other" octet (now ---) and get EACCES on open.
Empirically verified on Pixel:
before: 644 root:root → `cat` from untrusted shell succeeds
after: 640 root:system → untrusted shell EACCES;
`su system -c cat` (uid=1000) reads fine,
mirroring what system_server sees
Boot-time service.sh in both kmod and zygisk modules also include an
idempotent migration block that re-stamps any pre-PR files left at
0644 by an older version on the next boot. Closes#36 in REVIEW.
Six places parsed `pm list packages -U` output with
`grep "^package:${pkg} "`, which treats `pkg` as a regex — dots in
package names cross-match, in theory mapping `com.x.y` to a
hypothetical `comXxXy` package. In practice Android won't let two
such packages coexist, so this has never bit anyone, but the fix is
free and unifies with the literal `awk '$1 == p'` pattern that
portshide/vpnhide_ports_apply.sh has been using all along.
Touched:
* kmod/module/service.sh, zygisk/module/service.sh — boot-time UID
resolution for kmod and lsposed/zygisk targets.
* lsposed/.../{AppPickerScreen,AppHidingScreen,ShellUtils}.kt — three
call-sites that build shell pipelines from Kotlin to resolve UIDs
for /proc/vpnhide_targets, the system_server hook uids file, and
the package-visibility observer uids file.
* lsposed/.../DashboardData.kt — the self-multi-profile detection
that warns when vpnhide is installed in more than one profile.
Android user profiles (work profile, MIUI Second Space, Private Space,
secondary users) each give the same package its own UID in the
namespace `<user>*100000 + <app_id>`. Previously every pkg→UID
resolver used plain `pm list packages -U`, which only emits UIDs for
the primary user, so the work-profile copy of Telegram kept seeing
the VPN even though the user had marked Telegram as a target.
Switch every resolver to `pm list packages -U --user all`. The pm
output format for multi-profile apps is comma-separated on one line:
package:com.android.chrome uid:10187,1010187
Each call site now splits on `,` and emits one UID per line so every
profile's copy is individually matched by the hooks. No UI changes —
"mark Telegram as a target" just now means "in every profile it's
installed in".
Resolvers touched (all places found by an audit, no duplicates left):
Shell (boot-time):
kmod/module/service.sh
zygisk/module/service.sh
portshide/module/vpnhide_ports_apply.sh
Kotlin (save-time via suExec):
AppPickerScreen.kt — buildUidResolver
AppHidingScreen.kt — buildHidingUidResolver
ShellUtils.kt — ensureSelfInTargets
TargetsCache.kt — PM_LIST batch script + parser
Verified on a Pixel 4a with a managed profile (user 10):
- Chrome toggled in LZ on primary → both 10187 and 1010187 land in
/data/system/vpnhide_uids.txt.
- Primary-only apps (Ozon, etc.) still resolve to a single UID.
- ensureSelfInTargets correctly adds both UIDs when vpnhide is
installed across profiles.
Users routinely installed the wrong GKI variant of the kmod zip and
saw no signal beyond "installed, inactive". This adds a full chain
from build to diagnostics so the wrong-variant case is both obvious
to the user and fully captured in bug reports.
Why each piece exists:
- CI stamps `gkiVariant=<kmi>` into each variant's `module.prop`
so the app can identify what was installed without guessing.
- `post-fs-data.sh` records `/data/adb/vpnhide_kmod/load_status`
(boot_id, uname -r, gki_variant, insmod exit+stderr, kprobes,
root manager) and `load_dmesg` at every boot — this survives
reboots and is the only record of insmod failures by the time
the user opens the app.
- Dashboard reads both, always computes the kernel-based
recommendation, and emits targeted issues: wrong-variant,
unknown-variant (pre-stamp zip that also failed to load),
kmod-on-unsupported-kernel, kprobes-missing, or generic
load-failed with the captured stderr.
- Diagnostics screen adds a "Kmod load trace" card so bug
reports can come in as a screenshot, and the debug zip
includes load_status + load_dmesg for deeper analysis.
Also aligns `lsposed/native/Cargo.lock` with Cargo.toml (0.6.1
→ 0.6.2) — a real stale-lock fix surfaced by the gradle build.
`pm list packages` starts responding to IPC very early in boot but
returns only system packages for several more seconds. service.sh's
previous `pm list packages >/dev/null && break` loop exited as soon as
PM was alive — before user-installed packages (including the vpnhide
app itself and any chosen targets) were indexed. The subsequent
`pm list packages -U | grep "^package:$pkg "` returned nothing, so
/data/system/vpnhide_uids.txt was written empty, and the LSPosed hook
in system_server cached an empty target set for the session. Result:
all Java-level filtering silently disabled until the next reboot where
we got lucky on timing.
Gate the boot wait on our own package being visible in the list (with
a 60s budget instead of 30s). That guarantees PM has moved past the
system-only snapshot before we read target names.
Also add per-call diagnostic logs to the three writeToParcel hooks
(NC, NI, LP) — `VpnHide-NC`, `VpnHide-NI`, `VpnHide-LP` tags — so the
next "Java check fails with zygisk on / passes with zygisk off" style
report can be diagnosed from logs alone instead of a live instrumented
build. The per-call volume is modest compared to system_server's own
logging and the logs live inside the LSPosed bridge log.
Magisk before v28 requires META-INF/com/google/android/update-binary
+ updater-script in module zips to extract them; without these the
manager fails with an unpack error (issue #23). Magisk v28+ removed
this requirement, which is why the bug only shows up on older managers.
Added the standard Magisk template (same one already used by the
zygisk module) to portshide and kmod. CI's `(cd module && zip -qr)`
step picks up the new files automatically.
- Detect LSPosed in all known module paths (zygisk_vector, zygisk_lsposed, lsposed)
- Skip LSPosed config warnings when hooks are already active at runtime
- Check all modules for empty targets, not just LSPosed
- Bump version to v0.5.1
Dashboard as new landing screen with module status cards (kmod, zygisk,
LSPosed), aggregated protection checks (native + Java API), and issue
alerts. Uses sealed types for type-safe state modeling (invalid states
are unrepresentable).
Three separate target lists: kmod, zygisk, and lsposed each have
independent targets.txt. Users can configure per-app which layers
protect it (L/K/Z chips in app picker). Service.sh scripts decoupled —
each resolves only its own component, with migration from unified lists.
HookEntry writes /data/system/vpnhide_hook_active with version and
boot_id so the app can detect if LSPosed hooks are active this boot.
VPN Hide app auto-adds itself to all target lists for self-diagnostics.
If just added, shows "restart needed" instead of stale check results.
App hides itself from the app picker and target counts.
Diagnostics tab no longer runs checks without VPN — shows banner only.
Removed "Run All" button (results are cached per process lifetime).
Splash screen follows system dark/light theme via Material3 DayNight.
The VPN Hide app is now the sole UI for target management. WebUI was
KernelSU-Next-only and redundant since the app works on both KSU and
Magisk. Remove webroot/, action.sh, and all references across docs,
install scripts, module descriptions, and code comments.
- WebUI: validate package names against [a-zA-Z0-9_.\-]+ before
interpolating into shell commands (both kmod and zygisk copies)
- zygisk hooks.rs: use RTM_NEWLINK/RTM_NEWADDR from filter.rs instead
of magic constants 16/20
- zygisk lib.rs: read /proc/self/maps via raw libc::open in
scrub_shadowhook_maps to bypass our own hooked_openat
- kmod: add comment explaining why seq->buf access without seq->lock
is safe in fib_route_ret (seq_read holds the mutex around ->show())
- kmod: add comment clarifying MODULE_LICENSE("GPL") vs MIT SPDX
Single VERSION file in repo root as the source of truth. The script
update-version.sh propagates it to all 5 locations: kmod module.prop,
zygisk module.prop, zygisk Cargo.toml, lsposed build.gradle.kts,
test-app build.gradle.kts. versionCode = major*10000 + minor*100 + patch.
The shell variable $UIDS accumulated UIDs with literal \n (two chars)
instead of actual newlines. printf "$UIDS" wrote garbage to
/proc/vpnhide_targets and vpnhide_uids.txt. The empty-targets case
used bare > redirect which never triggers the proc write handler.
Fix: accumulate UIDs with real newlines (like service.sh does), use
echo "$UIDS" for output, and echo (writes \n) for the empty case so
the kmod write handler fires and clears the UID list.
Affects: APK target picker, kmod WebUI, zygisk WebUI.
Unified repository for the complete Android VPN-hiding stack:
- zygisk/ — Rust Zygisk module (inline libc hooks via shadowhook)
- lsposed/ — Kotlin LSPosed module (Java API + system_server hooks)
- kmod/ — C kernel module (kretprobe hooks, invisible to anti-tamper)
CI workflows use path filters to build only the changed component.