Eight small touches in vpnhide_kmod.c plus a README sync. No behaviour
change for normal probe paths — the per-UID filter, the matcher, and
all six hooks behave identically. Edits target failure modes and
maintainability around the existing logic.
Code:
arm64 ABI guard
Handlers read syscall arguments via regs->regs[N] (AAPCS64). Add
`#ifndef CONFIG_ARM64 #error … #endif` so a non-arm64 build fails
loudly instead of silently producing a module that reads garbage.
maxactive 20 -> VPNHIDE_KRETPROBE_MAXACTIVE = 64
All six probes used .maxactive = 20, only marginally above the
NR_CPUS*2 default (~18 on a 9-core Pixel 8 Pro). Hot ioctl/netlink
paths under multi-app concurrency can exhaust that and silently
bump nmissed (= leaked iface). 64 buys headroom for ~30 KB total.
dev_ioctl: replace `data->cmd = 0` magic flag with `bool active`
The old code set cmd to 0 in the entry handler when the caller
wasn't a target, then keyed the ret handler on `cmd == 0`. Magic
sentinel; if any future ioctl number ever hashed to 0 the flag
would silently misbehave.
filter_ifconf_buf returns enum, sock_ioctl_ret handles partial
writes
Old function silently bailed on copy_from_user / copy_to_user
failure and could leave userspace with a half-compacted buffer
plus the original (now-stale) ifc_len. Now the function returns
`FILTER_IFCONF_NO_CHANGE / CHANGED / COPY_FAULT` and the caller
skips the put_user(ifc_len) on COPY_FAULT — better to leak all
ifaces visibly than to expose a length-vs-content mismatch.
put_user(ifc_len) error checked
Previously dropped on the floor — if updating ifc_len failed,
userspace would see compacted buffer with old length. Now logs
via vpnhide_dbg and returns; userspace falls back to the
pre-compaction view.
READ_ONCE/WRITE_ONCE around debug_enabled
Single bool, written from /proc/vpnhide_debug, read from every
probe handler. Compiler can't tear or hoist now — kosher style
for unsynchronised flags.
Header comment: dev_ioctl/sock_ioctl
Corrected the file-top hook list — it still claimed `dev_ifconf`
for SIOCGIFCONF, but the actual probe is on `sock_ioctl` (LTO
inlines dev_ifconf on 5.10 + the symbol moves out of
sock_do_ioctl on 6.1+, both rationale already in the inline
comment block at hook 2).
Doc:
README.md `rtnl_fill_ifinfo` table row + the standalone
`-EMSGSIZE trick` and `why NOT -EMSGSIZE` sections were stale
after #103 (which made all three netlink fill probes use
`skb_trim` + return 0). Replaced with one short joint section
pointing at issue #38 for context.
Verified on Pixel 8 Pro (husky, android14-6.1, Android 16):
Enforcing : 26/26 PASS, COLD start ~1020 ms.
Permissive : 22/26 PASS, same 4 by-design FAILs as before, no
regression in netlink_getlink / netlink_getroute /
getifaddrs / ioctl_* / proc_route / proc_fib_trie.
Returning -EMSGSIZE from rtnl_fill_ifinfo for VPN devs hangs RTM_GETLINK
dumps on android14-6.1: the dump iterator retries the same entry forever
on an empty skb. inet6_fill_ifaddr in this same file already documented
and worked around the issue using skb_trim + return-0; mirror that for
rtnl_fill_ifinfo so RTM_GETLINK skips VPN ifaces cleanly.
Reproduced on Pixel 8 Pro (husky, android14-6.1) with SELinux Permissive:
the netlink_getlink check inside dev.okhsunrog.vpnhide hung on splash
screen. After the fix all three netlink-backed checks (getifaddrs,
netlink_getlink, netlink_getroute) PASS in Permissive in <1s.
`proc_create()` returns NULL on failure (typically OOM at boot or
/proc not yet mounted). The previous code stored the NULL into
`targets_entry` and continued — `pr_info(": loaded")` fired, the
kretprobes were registered, but userspace had no way to write the
target UID list, so the module silently filtered nothing.
Treat /proc/vpnhide_targets failure as fatal: log an error,
unregister any probes that did succeed, and return -ENOMEM so
insmod surfaces the failure to the caller. /proc/vpnhide_debug
stays best-effort — losing the debug toggle just means no verbose
logging, the rest of the module is still useful.
The kernel module, zygisk, lsposed-native, and the LSPosed Kotlin module
each had their own hand-written list of VPN interface name prefixes,
and the four had drifted: kmod/zygisk/HookEntry knew utun/l2tp/gre
while lsposed-native and DiagnosticsScreen only knew tun/wg/ppp/tap/
ipsec/xfrm. So the self-test could PASS while the hooks were actually
hiding more interfaces.
Move the rules to data/interfaces.toml and render four matchers from it
via scripts/codegen-interfaces.py — one per language target. A new lint
job re-runs the codegen and fails if anything drifts.
The match grammar is intentionally tiny so each codegen target
implements it without depending on regex (kernel C can't):
exact / prefix / prefix+digits / contains.
Side effect: native diagnostics now agree with the hooks, so the
self-test in DiagnosticsScreen will recognize utun*, l2tp*, gre* and
*vpn* substrings as VPN tunnels (previously it would silently PASS on
those). The /proc/net/route check also moved from raw substring to
whitespace-tokenized matching, which avoids matching VPN-prefix
substrings that show up by chance inside hex-encoded IP addresses.
Existing zygisk filter unit tests still pass unchanged — public API of
is_vpn_iface_bytes / is_vpn_iface_cstr is preserved, only the body now
delegates to the generated matches_vpn().
Cargo.lock files updated incidentally (synced with Cargo.toml versions
that were already 0.7.1 in the manifests).
On GKI 5.10 kernels built with Clang LTO, dev_ifconf() gets inlined
into sock_do_ioctl(). The symbol remains in kallsyms as a dead stub,
so kretprobe registration succeeds but the probe never fires.
Confirmed by disassembly on Xiaomi 13 Lite (5.10.136) and Lenovo
Legion 2 Pro (5.10.101).
On 6.1+, SIOCGIFCONF was moved from sock_do_ioctl() into sock_ioctl()
directly, so hooking sock_do_ioctl would miss it on newer kernels.
sock_ioctl is the file_operations->unlocked_ioctl callback — used as
a function pointer, so LTO cannot inline it. SIOCGIFCONF passes through
it on every kernel version. After it returns, ifconf data is already in
userspace, so we filter uniformly via copy_from_user/copy_to_user with
no version-specific code paths.
dev_ifconf() changed its signature between 5.10 and 5.15:
5.10: dev_ifconf(struct net *, struct ifconf *ifc, int size)
x1 = kernel pointer (caller did copy_from_user)
5.15+: dev_ifconf(struct net *, struct ifconf __user *uifc)
x1 = userspace pointer
The kretprobe handler assumed 5.15+ (copy_from_user on x1), which
silently failed on 5.10 because copy_from_user on a kernel pointer
returns EFAULT. This left SIOCGIFCONF unfiltered — tun0 visible.
Use LINUX_VERSION_CODE to select the right access method at compile
time. Each kmod build already targets a specific GKI generation, so
this is safe.
Reported by users on Android 12 (5.10) and Android 14 (non-GKI 5.10).
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.
- lsposed: filter VPN routes from LinkProperties.mRoutes before
serialization (save-mutate-restore pattern). Previously only
mIfaceName was cleared but routes with VPN interface names leaked.
- kmod: remove SIOCGIFFLAGS/SIOCGIFNAME whitelist from dev_ioctl_ret.
Now all dev_ioctl commands return ENODEV for VPN interfaces, covering
SIOCGIFMTU (MTU fingerprinting), SIOCGIFINDEX, SIOCGIFHWADDR, etc.
- zygisk: replace per-command ioctl checks with a SIOCGIF* range check
(0x8910-0x8930). Same coverage as kmod — any ioctl with a VPN
interface name in ifr_name returns ENODEV.
toString() on NetworkCapabilities is already covered: we mutate the
underlying fields before writeToParcel, so the deserialized object
on the client produces a clean toString() output.
- Add .editorconfig with ktlint config (disable wildcard-import rule,
allow PascalCase for @Composable functions)
- Add kmod/.clang-format from upstream kernel tree
- Run clang-format on vpnhide_kmod.c (kernel coding style)
- Run ktlint --format on all Kotlin files (lsposed + test-app)
- 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
kmod:
- Add explicit rcu_read_lock() around ifa->idev->dev->name dereferences
in inet6_fill_entry, inet_fill_entry, and rtnl_fill_entry
- Remove racy READ_ONCE fast-path in is_target_uid; uncontended spin_lock
is ~5ns on ARMv8 and the optimization had incorrect TOCTOU semantics
- Fix dev_ifconf_ret: return immediately on copy_from_user/copy_to_user
failure instead of breaking the loop and writing back a wrong ifc_len
- Fail module load if zero kretprobes register; warn on partial registration
lsposed:
- Fix isSystemServer check-then-set race: use AtomicBoolean.compareAndSet
to prevent duplicate hook installation from concurrent handleLoadPackage
- Fix NC hook partial state corruption: save all values before mutating,
restore on exception, only set ThreadLocals after all mutations succeed
- Fix NI/LP hooks: replace param.result=null (which skips writeToParcel
and corrupts the Parcel stream) with save-mutate-restore pattern
- Synchronize loadTargetUids() with double-checked locking; always cache
result (even empty) to avoid file I/O on every Binder call
- Fix suExec: drain stderr on background thread, destroy process in finally
zygisk:
- Use std::sync::Once for shadowhook initialization instead of AtomicBool
- Handle write() return value on memfd: loop on short writes, return error
- Make netlink parsers (read_u32_ne/read_u16_ne) return Option instead of
panicking on out-of-bounds access
- /proc/vpnhide_targets: change from 0644 to 0600 (root only).
Apps could read the UID list and discover which apps are targeted.
- Remove /data/local/tmp/vpnhide_targets.txt copies from service.sh
and WebUI (no longer needed after get_module_dir() fix).
Kernel module:
- Add dev_ifconf hook to filter SIOCGIFCONF interface enumeration
(goes through sock_ioctl -> dev_ifconf, not dev_ioctl)
- Add inet6_fill_ifaddr and inet_fill_ifaddr hooks to filter RTM_GETADDR
netlink responses. getifaddrs() was leaking tun0 via the address dump
even though RTM_GETLINK was filtered. Uses skb_trim to undo the fill
and return 0 (not -EMSGSIZE which causes infinite retry on empty skb).
- All 6 kretprobes now cover: ioctl, SIOCGIFCONF, netlink link dumps,
netlink address dumps (IPv4+IPv6), and /proc/net/route.
Test app:
- Treat SELinux EACCES/EPERM as PASS — if the app can't access the
resource, it can't detect VPN through it either.
- Test results: 14/14 passed with VPN active.
Build system:
- Replace hardcoded paths in Makefile with env vars (KERNEL_SRC, CLANG_DIR)
- Add .env.example and .envrc for direnv-based config
- Simplify build-zip.sh to delegate to make instead of duplicating build command
- Rewrite BUILDING.md: 5-step happy path with direnv, standalone prep as appendix
- Remove redundant quick-reference script and step 7 (manual module.lds hack)
Kernel module (vpnhide_kmod.c):
- Fix fib_route_seq_show hook: save seq_file pointer and buffer position in entry
handler instead of reading regs->regs[0] in return handler (which holds the
return value on arm64, not the original argument). Rewrite buffer scanning as
clean forward iteration with memmove compaction.
- Remove dead SIOCGIFCONF case from dev_ioctl hook (confirmed in kernel source:
SIOCGIFCONF goes through sock_ioctl -> dev_ifconf, not dev_ioctl on GKI 6.1)
- Fix header comment: remove false tcp4_seq_show claim, correct rtnl symbol name
Test app:
- Auto-run checks on launch (LaunchedEffect) for easier adb-driven testing
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.