docs: align README and dev guide with current code and CI

Six unrelated drift fixes that accumulated since they were last
synced. Each is independent of the rest:

* README{.en,}.md — kmod claim "filters /proc/net/*" trimmed to
  /proc/net/route. The other /proc/net files are SELinux-blocked
  for untrusted apps and the coverage table already says so.

* kmod/README.md — hook table and architecture note updated from
  dev_ifconf to sock_ioctl. dev_ifconf gets inlined by Clang LTO
  on GKI 5.10 so the kretprobe silently never fires; sock_ioctl
  has been the actual hook target since the vpnhide_kmod.c fix.

* zygisk/README.md — five inline hooks now, not four (recv was
  added separately because bionic's recv tail-calls recvfrom).
  Also clarified pre_app_specialize runs in the forked child, not
  zygote, matching the lifecycle block in lib.rs.

* docs/development.md — JDK requirement matches CI image (17, not
  21); document ANDROID_NDK_ROOT quirk for Gobley; CI lint list
  expanded to match what ci.yml actually runs.

* docs/development.md + lsposed/README.md — explain Gobley (the
  Gradle plugin pair that builds lsposed/native/ and bundles the
  .so + UniFFI Kotlin bindings into the APK). Previously absent
  from all *.md.
This commit is contained in:
okhsunrog 2026-04-26 15:32:30 +03:00
parent f1479442f0
commit 8025be14cc
6 changed files with 34 additions and 17 deletions

View file

@ -27,7 +27,7 @@ vpnhide solves both problems with a layered architecture:
**Layer 1 — Java API (lsposed module):** hooks `system_server`, not the target app. `NetworkCapabilities`, `NetworkInfo`, and `LinkProperties` are filtered at the Binder level *before* data reaches the app's process. The app receives clean data over IPC — no injection into its process, nothing for anti-tamper to detect.
**Layer 2 — Native (kmod or zygisk):** covers every native detection path:
- **kmod** (recommended) — kernel-level `kretprobe` hooks. Filters `ioctl` (SIOCGIFFLAGS, SIOCGIFNAME, SIOCGIFCONF), `getifaddrs`/netlink dumps (RTM_GETLINK, RTM_GETADDR), and `/proc/net/*` reads — all before the syscall returns to userspace. Zero in-process footprint. No library injection. Nothing to detect.
- **kmod** (recommended) — kernel-level `kretprobe` hooks. Filters `ioctl` (SIOCGIFFLAGS, SIOCGIFNAME, SIOCGIFCONF), `getifaddrs`/netlink dumps (RTM_GETLINK, RTM_GETADDR), and `/proc/net/route` reads — all before the syscall returns to userspace. Other `/proc/net/*` files (`tcp*`, `udp*`, `dev`, `if_inet6`, etc.) are blocked by SELinux for untrusted apps on Android 10+, so no hook is needed there. Zero in-process footprint. No library injection. Nothing to detect.
- **zygisk** (alternative) — inline-hooks `libc.so` inside the app process. Same native coverage as kmod but runs in-process, so it's theoretically detectable by advanced anti-tamper. Use this if your kernel isn't supported by kmod.
**Layer 3 — Additional app-level controls (integrated into the VPN Hide app):**

View file

@ -27,7 +27,7 @@ vpnhide решает обе проблемы многослойной архит
**Уровень 1 — Java API (модуль lsposed):** хукает `system_server`, а не целевое приложение. `NetworkCapabilities`, `NetworkInfo` и `LinkProperties` фильтруются на уровне Binder *до того*, как данные попадут в процесс приложения. Приложение получает чистые данные через IPC — никаких инъекций в его процесс, нечего обнаруживать.
**Уровень 2 — нативный (kmod или zygisk):** покрывает все нативные пути обнаружения:
- **kmod** (рекомендуется) — хуки `kretprobe` на уровне ядра. Фильтрует `ioctl` (SIOCGIFFLAGS, SIOCGIFNAME, SIOCGIFCONF), `getifaddrs`/netlink-дампы (RTM_GETLINK, RTM_GETADDR) и чтение `/proc/net/*` — всё до возврата системного вызова в пользовательское пространство. Нулевой след в процессе. Никаких инъекций библиотек. Нечего обнаруживать.
- **kmod** (рекомендуется) — хуки `kretprobe` на уровне ядра. Фильтрует `ioctl` (SIOCGIFFLAGS, SIOCGIFNAME, SIOCGIFCONF), `getifaddrs`/netlink-дампы (RTM_GETLINK, RTM_GETADDR) и чтение `/proc/net/route` — всё до возврата системного вызова в пользовательское пространство. Остальные файлы `/proc/net/*` (`tcp*`, `udp*`, `dev`, `if_inet6` и т.д.) на Android 10+ заблокированы SELinux для untrusted-приложений, так что отдельный хук не нужен. Нулевой след в процессе. Никаких инъекций библиотек. Нечего обнаруживать.
- **zygisk** (альтернатива) — inline-хуки `libc.so` внутри процесса приложения. То же нативное покрытие, что и kmod, но работает в процессе, поэтому теоретически обнаружим продвинутой anti-tamper защитой. Используйте, если ваше ядро не поддерживается kmod.
**Уровень 3 — дополнительные механизмы в самом приложении VPN Hide:**

View file

@ -4,9 +4,9 @@ How to build vpnhide from source.
## Prerequisites
- **JDK 21** — required by the Android Gradle Plugin in `lsposed/`
- **JDK 17 or later** — what the CI image installs (`openjdk-17-jdk-headless`); local builds with JDK 21 also work. The `lsposed/app` Gradle build sets `sourceCompatibility = 17` and `jvmTarget = "17"`.
- **Android SDK** — install `platforms;android-35`, `build-tools;35.0.0`, `platform-tools` (via Android Studio or `cmdline-tools`). Export `ANDROID_HOME`.
- **Android NDK r27c or later** — export `ANDROID_NDK_HOME` (or drop it in `$ANDROID_HOME/ndk/<version>/`, the scripts auto-detect).
- **Android NDK r27c or later** — export `ANDROID_NDK_HOME` (or drop it in `$ANDROID_HOME/ndk/<version>/`, the scripts auto-detect). The Gobley Gradle plugin used by `lsposed/app` reads `ANDROID_NDK_ROOT`, not `ANDROID_NDK_HOME`, so export both (or alias one to the other) when invoking Gradle directly.
- **Rust** (latest stable) with the Android target:
```sh
rustup target add aarch64-linux-android
@ -16,6 +16,8 @@ How to build vpnhide from source.
- **`zip`** — packaging module zips.
- **`adb`** — installing builds on a device.
[Gobley](https://github.com/gobley/gobley) (Gradle plugins `dev.gobley.cargo` + `dev.gobley.uniffi`) is what builds the Rust crate at `lsposed/native/` via cargo-ndk and bundles the resulting `libvpnhide_checks.so` plus its UniFFI-generated Kotlin bindings (package `dev.okhsunrog.vpnhide.checks`) into the APK. The plugins are auto-resolved by Gradle from Maven Central — no manual install. Version is pinned in `lsposed/gradle/libs.versions.toml`.
## Repository layout
| Path | Component |
@ -88,19 +90,28 @@ After flashing kmod or zygisk, reboot the device.
## CI lints (run before pushing)
CI runs the same checks:
CI runs the same checks. See [.github/workflows/ci.yml](../.github/workflows/ci.yml) for the authoritative list.
```sh
# Codegen drift — run after editing data/interfaces.toml; CI fails on diff
python3 scripts/codegen-interfaces.py
git diff --quiet # must be clean
# Rust
cd zygisk && cargo fmt --check && cargo ndk -t arm64-v8a clippy -- -D warnings
cd lsposed/native && cargo ndk -t arm64-v8a clippy -- -D warnings
cd ../lsposed/native && cargo fmt --check && cargo ndk -t arm64-v8a clippy -- -D warnings
cd ../zygisk && cargo test
cd ../lsposed/native && cargo test
# C
# C (kernel module)
clang-format --dry-run --Werror kmod/vpnhide_kmod.c
# Host-side test of the generated VPN-iface matcher used by the kernel module
gcc -O2 -Wall -Werror -o /tmp/test_iface_lists kmod/test_iface_lists.c && /tmp/test_iface_lists
# Kotlin
ktlint "lsposed/**/*.kt"
cd lsposed && ./gradlew :app:lint
cd lsposed && ./gradlew --no-daemon :app:lint
cd lsposed && ./gradlew --no-daemon :app:testDebugUnitTest
```
## Build versions

View file

@ -8,8 +8,8 @@ Zero footprint in the target app's process -- no modified function prologues, no
| kretprobe target | What it filters | Detection path covered |
|---|---|---|
| `dev_ioctl` | `SIOCGIFFLAGS`, `SIOCGIFNAME`: returns `-ENODEV` for VPN interfaces | Direct `ioctl()` calls from native code (Flutter/Dart, JNI, C/C++) |
| `dev_ifconf` | `SIOCGIFCONF`: compacts VPN entries out of the returned interface array | Interface enumeration via `ioctl(SIOCGIFCONF)` |
| `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` | Returns `-EMSGSIZE` for VPN devices during RTM_NEWLINK netlink dumps, causing the kernel to skip them | `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 |
@ -102,11 +102,15 @@ int dev_ioctl(struct net *net, // x0
**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 dev_ifconf is a separate hook from dev_ioctl
### Why sock_ioctl, not dev_ifconf, for SIOCGIFCONF
`SIOCGIFCONF` does NOT go through `dev_ioctl()` on GKI 6.1. The call path is `sock_ioctl → dev_ifconf()` -- a completely separate function. We confirmed this by grepping the kernel source: `dev_ioctl` handles `SIOCGIFFLAGS`, `SIOCGIFNAME`, etc., but `SIOCGIFCONF` is dispatched before `dev_ioctl` is ever called.
`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.
`dev_ifconf(struct net *net, struct ifconf __user *uifc)` iterates all netdevs and writes ifreq entries directly to the userspace buffer. Our return handler reads back the userspace buffer, compacts out VPN entries, and updates `ifc_len` via `put_user`.
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: -EMSGSIZE trick

View file

@ -76,7 +76,9 @@ adb logcat | grep VpnHide
./gradlew assembleDebug
```
Requires JDK 17. Output: `app/build/outputs/apk/debug/app-debug.apk`.
Requires JDK 17 or later. Output: `app/build/outputs/apk/debug/app-debug.apk`.
The build cross-compiles `lsposed/native/` (Rust crate) for `aarch64-linux-android` via cargo-ndk and bundles the resulting `libvpnhide_checks.so` into the APK's `jniLibs/`, plus auto-generated UniFFI Kotlin bindings under package `dev.okhsunrog.vpnhide.checks`. Both steps are driven by [Gobley](https://github.com/gobley/gobley) Gradle plugins (`dev.gobley.cargo` + `dev.gobley.uniffi`) — no manual `cargo` invocation needed. See [../docs/development.md](../docs/development.md#prerequisites) for the full prereq list.
## License

View file

@ -23,8 +23,8 @@ PLT hooks patch the caller library's procedure linkage table. At `post_app_speci
### Flow
1. **`pre_app_specialize`** -- runs on zygote before uid drop. Reads `args.nice_name`, checks against `/data/adb/vpnhide_zygisk/targets.txt`. Non-targeted apps get `DlCloseModuleLibrary` (zero cost after unload).
2. **`post_app_specialize`** -- on targeted processes only: `shadowhook_init`, install four inline hooks (`ioctl`, `getifaddrs`, `openat`, `recvmsg`), then scrub maps.
1. **`pre_app_specialize`** -- runs in the already-forked child, before the kernel drops it to the app's UID and SELinux context (still has zygote privileges at this point). Reads `args.nice_name`, checks against `/data/adb/vpnhide_zygisk/targets.txt`. Non-targeted apps get `DlCloseModuleLibrary` (zero cost after unload). See `src/lib.rs`'s top-level doc block for the full Zygisk lifecycle and why every Rust `static` is fresh per app launch.
2. **`post_app_specialize`** -- on targeted processes only: `shadowhook_init`, install five inline hooks (`ioctl`, `getifaddrs`, `openat`, `recvmsg`, `recv`), then scrub maps. `recv` is hooked separately because bionic's `recv()` is `b recvfrom` (tail-call) — patching `recvfrom`'s prologue would break `recv`.
### Thread-local guard
@ -125,7 +125,7 @@ VPN interface prefixes: `tun`, `ppp`, `tap`, `wg`, `ipsec`, `xfrm`, `utun`, `l2t
## Files
- `src/lib.rs` -- module entry point, target gating, hook installer, maps scrubbing
- `src/hooks.rs` -- hook replacements for ioctl, getifaddrs, openat, recvmsg
- `src/hooks.rs` -- hook replacements for ioctl, getifaddrs, openat, recvmsg, recv
- `src/filter.rs` -- VPN interface name matching and proc/net content filters (unit tested)
- `src/shadowhook.rs` -- minimal FFI to shadowhook
- `build.rs` -- drives CMake on the shadowhook submodule