vpnhide/kmod/README.md
okhsunrog d2ff69816e fix(kmod): robustness pass + doc sync (review items #21 #22 #29 + chatgpt)
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.
2026-04-27 01:34:33 +03:00

133 lines
9.2 KiB
Markdown

# vpnhide -- Kernel module
kretprobe-based kernel module that hides VPN interfaces from selected apps. Part of [vpnhide](../README.md).
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](BUILDING.md) for the full guide (DDK Docker build, kernel source preparation, toolchain setup, `Module.symvers` generation).
```bash
./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](../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:**
```bash
# 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](../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](../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](../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)
```c
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](../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.