vpnhide/kmod
okhsunrog 96dd1bf307 feat(logging): include kmod in debug-logging toggle fan-out
The "Debug logging" toggle in Diagnostics drove three sinks: app
process (VpnHideLog.enabled), system_server hooks (/data/system/
vpnhide_debug_logging via inotify FileObserver), and the Zygisk
module (/data/adb/modules/vpnhide_zygisk/debug_logging at fork).
The kernel module's /proc/vpnhide_debug was its own little channel,
flipped only inside exportDebugZip's capture window — so a user
who enabled "Debug logging" persistently still got silent kmod,
and the kmod-flip in exportDebugZip would even reset it back to
0 at the end regardless of the user's preference.

Make kmod the fourth sink:

- writeDebugFlagFiles now drives /proc/vpnhide_debug alongside the
  other two file flags, batched into a single su invocation so the
  toggle UI doesn't pay three round-trips.
- /proc/vpnhide_debug is per-boot in-kernel state, so kmod's
  service.sh re-seeds it from the canonical /data/system/
  vpnhide_debug_logging at every boot — same model as targets.txt
  → /proc/vpnhide_targets.
- Manual echo 1/echo 0 > /proc/vpnhide_debug calls in exportDebugZip
  go away — applyDebugLoggingRuntime + the existing loggingWasForced
  save/restore block now handle kmod uniformly with the other sinks,
  fixing the "kmod stays OFF after capture even if user toggle was
  ON" bug as a side effect.
- Updated en + ru toggle description to mention dmesg, since the
  toggle is no longer logcat-only.
- docs/state.md §3 and §4 updated; new "Debug-logging fan-out"
  subsection in §3 with a diagram.

Behaviour change worth a changelog entry: a user with toggle ON
will now get verbose kmod entries in dmesg too.
2026-04-28 00:09:49 +03:00
..
generated chore(codegen): drop unused suffix forms digits_optional / any 2026-04-26 05:16:18 +03:00
module feat(logging): include kmod in debug-logging toggle fan-out 2026-04-28 00:09:49 +03:00
.clang-format style: add clang-format, ktlint, editorconfig and format all code 2026-04-12 23:26:36 +03:00
.env.example refactor: overhaul kmod build system, fix kernel module bugs 2026-04-11 18:50:18 +03:00
.envrc refactor: overhaul kmod build system, fix kernel module bugs 2026-04-11 18:50:18 +03:00
build.py ci+chore: add ruff (format + lint) for python scripts 2026-04-26 23:48:37 +03:00
BUILDING.md build: align native cdylib on 16 KiB; unify kmod/zygisk build scripts 2026-04-26 23:26:30 +03:00
Makefile fix(build): port build scripts to Python to allow Windows contributors to build subprojects (#83) 2026-04-25 19:53:15 +03:00
README.md fix(kmod): robustness pass + doc sync (review items #21 #22 #29 + chatgpt) 2026-04-27 01:34:33 +03:00
test_iface_lists.c fix(filter): catch tunnels renamed to if<N> (issue #86) 2026-04-26 04:08:36 +03:00
vpnhide_kmod.c fix(kmod): robustness pass + doc sync (review items #21 #22 #29 + chatgpt) 2026-04-27 01:34:33 +03:00

vpnhide -- Kernel module

kretprobe-based kernel module that hides VPN interfaces from selected apps. Part of vpnhide.

Zero footprint in the target app's process -- no modified function prologues, no framework classes, no anonymous memory regions. Invisible to aggressive anti-tamper SDKs.

What it hooks

kretprobe target What it filters Detection path covered
dev_ioctl SIOCGIFFLAGS, SIOCGIFNAME, and other per-interface ioctls: returns -ENODEV for VPN interfaces Direct ioctl() calls from native code (Flutter/Dart, JNI, C/C++)
sock_ioctl SIOCGIFCONF: compacts VPN entries out of the returned interface array Interface enumeration via ioctl(SIOCGIFCONF)
rtnl_fill_ifinfo Trims VPN entries from RTM_NEWLINK netlink dumps via skb_trim and returns 0 getifaddrs() (which uses netlink internally), any netlink-based interface enumeration
inet6_fill_ifaddr Trims VPN entries from RTM_GETADDR IPv6 responses via skb_trim IPv6 address enumeration over netlink
inet_fill_ifaddr Trims VPN entries from RTM_GETADDR IPv4 responses via skb_trim IPv4 address enumeration over netlink
fib_route_seq_show Forward-scans for VPN lines and compacts them out with memmove /proc/net/route reads

All filtering is per-UID: only processes whose UID appears in /proc/vpnhide_targets see the filtered view. Everyone else (system services, VPN client, NFC subsystem) sees the real data.

Why kernel-level?

Some anti-tamper SDKs read /proc/self/maps via raw svc #0 syscalls (bypassing any libc hook) and check ELF relocation integrity. No userspace interposition can hide from them.

Kernel kretprobes modify kernel function behavior, not userspace code. The target app's process memory, ELF tables, and /proc/self/maps are completely untouched.

GKI compatibility

All symbols used (register_kretprobe, proc_create, seq_read, etc.) are part of the stable GKI KMI, so the same Module.symvers CRCs work across all devices running the same GKI generation. The C source is identical across generations -- only the kernel headers and CRCs differ.

CI builds are provided for all 7 GKI generations: android12-5.10 through android16-6.12.

Build

See BUILDING.md for the full guide (DDK Docker build, kernel source preparation, toolchain setup, Module.symvers generation).

./kmod/build.py --kmi android14-6.1

Install

  1. adb push vpnhide-kmod.zip /sdcard/Download/
  2. KernelSU-Next manager -> Modules -> Install from storage
  3. Reboot

On boot:

  • post-fs-data.sh runs insmod to load the kernel module
  • service.sh resolves package names from targets.txt to UIDs via pm list packages -U and writes them to /proc/vpnhide_targets

Target management

VPN Hide app (recommended): open the VPN Hide app (the lsposed APK). It lists all installed apps with icons, search, and checkboxes. Saves targets for both kmod and zygisk, resolves UIDs, and writes to /proc/vpnhide_targets immediately. Works on both KernelSU and Magisk.

Shell:

# Write package names to the persistent config
adb shell su -c 'echo "com.example.targetapp" > /data/adb/vpnhide_kmod/targets.txt'

# Or write UIDs directly to the kernel module
adb shell su -c 'echo 10423 > /proc/vpnhide_targets'

The app writes to three places simultaneously:

  1. targets.txt -- persistent package names (survives module updates)
  2. /proc/vpnhide_targets -- resolved UIDs for the kernel module (live, no reboot)
  3. /data/system/vpnhide_uids.txt -- resolved UIDs for the lsposed module's system_server hooks (live reload via inotify)

Combined use with system_server hooks

For apps with aggressive anti-tamper SDKs, full VPN hiding requires covering both native and Java API detection paths -- without placing any hooks in the target app's process:

  • vpnhide-kmod (this module) covers the native side: ioctl, getifaddrs() (netlink), /proc/net/route, and netlink address enumeration.
  • lsposed hooks writeToParcel() on NetworkCapabilities, NetworkInfo, LinkProperties inside system_server -- stripping VPN data before Binder serialization reaches the app.

Together they provide complete VPN hiding without any hooks in the target app's process.

Setup

  1. Install vpnhide-kmod as a KSU module (this module).
  2. Install lsposed as an LSPosed/Vector module and add "System Framework" to its scope (no other apps in scope).
  3. Pick target apps in the VPN Hide app -- it manages targets for both the kernel module and the system_server hooks.

Architecture notes

Why kretprobes work here

kretprobes instrument kernel functions by replacing their return address on the stack. Unlike userspace inline hooks (which modify instruction bytes), kretprobes:

  • Don't modify the target function's code in a way visible to userspace -- /proc/self/maps and the function's ELF bytes are unchanged
  • Can't be detected by the target app -- the app can only inspect its own process memory, not kernel data structures
  • Work on any function visible in /proc/kallsyms, including static (non-exported) functions

dev_ioctl calling convention (GKI 6.1, arm64)

int dev_ioctl(struct net *net,       // x0
              unsigned int cmd,       // x1
              struct ifreq *ifr,      // x2 -- KERNEL pointer
              void __user *data,      // x3 -- userspace pointer
              bool *need_copyout)     // x4

Important: x2 is a kernel-space pointer (the caller already did copy_from_user). Using copy_from_user on it will EFAULT on ARM64 with PAN enabled. The return handler reads via direct pointer dereference.

Why sock_ioctl, not dev_ifconf, for SIOCGIFCONF

SIOCGIFCONF does NOT go through dev_ioctl(). The call path is sock_ioctl → dev_ifconf() -- a completely separate function from dev_ioctl, which handles SIOCGIFFLAGS, SIOCGIFNAME, etc.

The natural choice would be to hook dev_ifconf directly, but on GKI 5.10 (Clang LTO) the linker inlines dev_ifconf into sock_do_ioctl. The dev_ifconf symbol stays in kallsyms as a dead stub, so register_kretprobe succeeds but the probe never fires. Confirmed by disassembly on Xiaomi 13 Lite (5.10.136) and Lenovo Legion 2 Pro (5.10.101): no bl dev_ifconf in sock_do_ioctl. On 6.1+, SIOCGIFCONF was moved out of sock_do_ioctl and is dispatched directly from sock_ioctl, so hooking sock_do_ioctl would miss it on newer kernels too.

sock_ioctl is the correct hook point because (1) it is the file_operations->unlocked_ioctl callback for socket fds — used as a function pointer, so LTO can never inline it; (2) all socket ioctls, including SIOCGIFCONF, pass through it on every kernel version (5.10 through 6.12+); (3) after sock_ioctl returns, the ifconf data (ifreq array + ifc_len) is already in userspace, so we filter it uniformly via copy_from_user/copy_to_user regardless of kernel version.

The entry handler stashes the userspace argp; the return handler reads back the buffer, compacts out VPN entries, and updates ifc_len via put_user. Cost is one cmd == SIOCGIFCONF compare per socket ioctl for non-target paths.

rtnl_fill_ifinfo / inet_fill_ifaddr / inet6_fill_ifaddr: skb_trim

All three netlink fill functions are skipped the same way: the entry handler saves skb->len before the fill writes anything; the return handler calls skb_trim(skb, saved_len) to undo whatever was written, then returns 0 (success). The dump iterator sees a successful entry of zero new bytes and advances to the next interface/address.

We do not return -EMSGSIZE to skip a VPN entry. On Android 14 / 6.1 GKI kernels, the dump iterator interprets -EMSGSIZE on an empty skb as "buffer too small for even one entry" and retries the same entry forever — observed in production as a hang of getifaddrs() (issue #38). The skb_trim-and-return-0 path avoids the retry loop on every netlink dump function uniformly.

fib_route_seq_show: seq_file buffer compaction

fib_route_seq_show(struct seq_file *seq, void *v) appends one or more tab-separated route lines to seq->buf. Each call can write multiple lines (one per fib_alias in the routing table entry).

The kretprobe entry handler saves the seq pointer and seq->count (current buffer position) in ri->data. The return handler scans the newly written region [saved_count, seq->count) line by line, extracts the first tab-delimited field (interface name), and compacts out VPN lines using memmove. Finally, seq->count is adjusted to reflect the reduced content.

Why we save seq in the entry handler: in a kretprobe return handler, regs->regs[0] (x0 on arm64) contains the function's return value, not the original first argument. The original code tried to read seq from x0 in the return handler, which was reading the return value (0) as a pointer -- a bug that would crash or silently fail. The fix is standard kretprobe practice: save arguments in ri->data during the entry handler.

License

MIT. See LICENSE.

The compiled module declares MODULE_LICENSE("GPL") as required by the Linux kernel to resolve EXPORT_SYMBOL_GPL symbols (register_kretprobe, proc_create, etc.) at runtime.