mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-04-28 22:52:15 +00:00
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.
133 lines
9.2 KiB
Markdown
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.
|