diff --git a/README.en.md b/README.en.md index da324de..426093a 100644 --- a/README.en.md +++ b/README.en.md @@ -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):** diff --git a/README.md b/README.md index 091370f..e22da47 100644 --- a/README.md +++ b/README.md @@ -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:** diff --git a/docs/development.md b/docs/development.md index 0df8ff5..658785c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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//`, the scripts auto-detect). +- **Android NDK r27c or later** — export `ANDROID_NDK_HOME` (or drop it in `$ANDROID_HOME/ndk//`, 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 diff --git a/kmod/README.md b/kmod/README.md index 27e0cb8..0e702f2 100644 --- a/kmod/README.md +++ b/kmod/README.md @@ -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 diff --git a/lsposed/README.md b/lsposed/README.md index ee0889c..c52bbb2 100644 --- a/lsposed/README.md +++ b/lsposed/README.md @@ -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 diff --git a/zygisk/README.md b/zygisk/README.md index 4243ecf..f7657af 100644 --- a/zygisk/README.md +++ b/zygisk/README.md @@ -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