Six small review-list items rolled together — all CI/dev-tooling, no runtime behaviour change. #12 Dockerfile: pin Rust 1.95.0 and cargo-ndk 4.1.2 (was floating `stable` + latest cargo-ndk on monthly rebuild). Versions live in ENV vars to make the next bump a one-line edit. #13 Add shellcheck to lint job. SC2034/SC3043 excluded — Magisk reads SKIPUNZIP externally; Android's /system/bin/sh (mksh on Pixel) does support `local` despite POSIX. Verified locally that the 11 .sh files (module-side + dev tooling) pass. shellcheck baked into the CI image via apt; inline apt-get fallback covers the window before image rebuild. #24 ci.yml keystore.properties: replace heredoc with `printf '%s\n'`. Heredoc without single-quoted EOF re-expands $, backticks and backslashes in the password — printf takes the value verbatim. #31 scripts/release.py::patch_file now hard-fails when a regex pattern doesn't match (was silently leaving stale versions). #32 Split rotate_fragments_into_history into rotate + delete steps so release.py can save_json + write_md *before* unlinking the fragment files. If anything in between fails, fragments are still on disk and the run is retryable. #37 codegen-interfaces.py: emit `assert!(matches_vpn(…), msg)` / `assert!(!matches_vpn(…), msg)` instead of `assert_eq!(matches_vpn(…), true/false, msg)` — clippy::bool_assert_comparison was firing on every generated row under `cargo clippy --tests`. Both generated test modules regenerated. CI's clippy steps now also pass `--tests` so this class of regression is caught. |
||
|---|---|---|
| .. | ||
| module | ||
| src | ||
| third_party | ||
| build.py | ||
| build.rs | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
vpnhide -- Zygisk module
Native-layer VPN interface hiding via inline libc hooks. Part of vpnhide.
What it hooks
All hooks are inline on libc.so via ByteDance shadowhook:
| Hook | Detection path | What it does |
|---|---|---|
ioctl |
SIOCGIFFLAGS |
Returns ENODEV for VPN interfaces (pre-screens input name). |
ioctl |
SIOCGIFNAME |
Calls through; rewrites result to ENODEV if returned name is VPN. |
ioctl |
SIOCGIFCONF |
Calls through; compacts VPN entries out of the returned ifreq array. |
getifaddrs |
NetworkInterface.getNetworkInterfaces(), Dart VM, direct C/C++ |
Unlinks VPN entries from the returned linked list. |
openat |
/proc/net/{route,ipv6_route,if_inet6,tcp,tcp6} |
Returns a memfd with VPN entries stripped out. |
recvmsg |
Netlink RTM_NEWADDR / RTM_NEWLINK dump responses |
Removes VPN interface entries from netlink messages. |
Architecture
Why inline hooks instead of PLT
PLT hooks patch the caller library's procedure linkage table. At post_app_specialize time, libflutter.so / libapp.so / late-loaded JNI libraries are not yet mapped -- only ~350 Android system libraries are present, and none of them have PLT relocations for ioctl (the call sites are inside libc itself). Inline-hooking libc's entry points rewrites the function prologue in-place, so every caller in the process -- regardless of when it was loaded -- lands on our trampoline.
Flow
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). Readsargs.nice_name, checks against/data/adb/vpnhide_zygisk/targets.txt. Non-targeted apps getDlCloseModuleLibrary(zero cost after unload). Seesrc/lib.rs's top-level doc block for the full Zygisk lifecycle and why every Ruststaticis fresh per app launch.post_app_specialize-- on targeted processes only:shadowhook_init, install five inline hooks (ioctl,getifaddrs,openat,recvmsg,recv), then scrub maps.recvis hooked separately because bionic'srecv()isb recvfrom(tail-call) — patchingrecvfrom's prologue would breakrecv.
Thread-local guard
The ioctl hook uses a thread-local IN_GETIFADDRS flag to pass through without filtering while libc's internal getifaddrs implementation is running. Without this, our SIOCGIFFLAGS filter returns ENODEV for VPN interfaces during libc's own ifaddrs list construction, which corrupts the list and breaks downstream consumers (including NFC/HCE payment flows).
Maps scrubbing
After hook installation, scrub_shadowhook_maps() renames [anon:shadowhook-island] and [anon:shadowhook-enter] regions via prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, ..., ""). This makes them show as plain [anon:] in /proc/self/maps, indistinguishable from hundreds of other anonymous mappings -- even to anti-tamper SDKs that read maps via raw svc #0 syscalls.
shadowhook fork
We carry a small fork at okhsunrog/android-inline-hook (branch vpnhide-zygisk), vendored as a git submodule under third_party/android-inline-hook/, with two changes on top of upstream:
SHADOWHOOK_STATIC=ON-- buildslibshadowhook.ainstead of a shared library so it can be embedded directly into this Rust cdylib.sh_linker_init()stub -- on Android 16 (API 36) the hardcoded symbol table in upstream's linker hook no longer matches the newer linker layout, causingSHADOWHOOK_ERRNO_INIT_LINKER. We don't need the deferred-hook feature (libc.so is always preloaded), so the stub skips this path entirely.
Compatibility
The module declares Zygisk API v5 but only calls v1-era functions (pre_app_specialize, post_app_specialize, args.nice_name, set_option(DlCloseModuleLibrary)). The inline hooks happen via shadowhook inside the process, not through the Zygisk API.
| Setup | Works |
|---|---|
| Stock Magisk (API v5) + LSPosed | Yes |
| Magisk + ZygiskNext + LSPosed | Yes |
| Magisk + NeoZygisk + LSPosed | Yes |
| KernelSU + ZygiskNext + LSPosed | Yes |
| KernelSU-Next + NeoZygisk + LSPosed/Vector | Yes (tested baseline) |
| APatch + any Zygisk implementation + LSPosed | Yes (untested in CI) |
Hard requirements:
- arm64 /
aarch64-linux-androidonly --build.rshard-fails on other targets. - A Zygisk implementation that exposes API >= v1.
- LSPosed/Vector for the Java-side companion.
Build
Requirements:
- Rust >= 1.85 (edition 2024)
rustup target add aarch64-linux-androidcargo install cargo-ndk- Android NDK (auto-detected under
~/Android/Sdk/ndk/; any recent NDK that shipslibclang_rt.builtins-aarch64-android.aworks) - CMake >= 3.22, Ninja
git submodule update --init --recursive
Build and package:
./build.py
# Output: target/vpnhide-zygisk.zip (~180 KB)
build.rs invokes the NDK's CMake toolchain on the shadowhook submodule, pulls in libclang_rt.builtins-aarch64-android.a for __clear_cache, and statically links everything into libvpnhide_zygisk.so.
Log level
Logging goes through the log crate + android_logger. The compile-time ceiling is controlled by a Cargo feature; calls below the ceiling are statically elided.
| Feature | Default | Effect |
|---|---|---|
log-off |
No logs at all | |
log-error |
Errors only | |
log-warn |
Errors, warnings | |
log-info |
Yes | Errors, warnings, info |
log-debug |
+ debug (e.g. on_load traces) |
|
log-trace |
+ trace |
Override the default:
cargo ndk -t arm64-v8a build --release \
--no-default-features --features log-debug
Install
adb push target/vpnhide-zygisk.zip /sdcard/Download/- KernelSU/Magisk manager -> Modules -> Install from storage -> pick the zip.
- Reboot.
- Pick target apps:
- VPN Hide app (recommended): open the VPN Hide app (the lsposed APK). Lists all installed apps with icons, search, and checkboxes. Works on both KernelSU and Magisk.
- Shell: edit
/data/adb/vpnhide_zygisk/targets.txtdirectly (one package name per line,#for comments). A base package namecom.example.appalso matches subprocesses likecom.example.app:background.
- Force-stop target apps:
adb shell am force-stop <pkg> - Verify:
adb logcat | grep vpnhide-zygisk
Filter logic
VPN interface prefixes: tun, ppp, tap, wg, ipsec, xfrm, utun, l2tp, gre, plus anything containing the substring vpn. Matches the list in the LSPosed companion.
Known limitations
- Direct
svc #0syscalls bypass the hook. Apps issuing raw syscalls skip libc entirely. Use vpnhide-kmod for these apps. - arm64 only. No 32-bit arm, no x86.
getifaddrshook leaks a few bytes per call. Unlinked VPN entries in the ifaddrs linked list are intentionally leaked rather than tracked with a shadow allocator. Acceptable tradeoff --getifaddrsis called infrequently.- Tested on Android 16 (API 36). Should work back to API 24 in principle, but nothing older has been exercised.
Files
src/lib.rs-- module entry point, target gating, hook installer, maps scrubbingsrc/hooks.rs-- hook replacements for ioctl, getifaddrs, openat, recvmsg, recvsrc/filter.rs-- VPN interface name matching and proc/net content filters (unit tested)src/shadowhook.rs-- minimal FFI to shadowhookbuild.rs-- drives CMake on the shadowhook submodulethird_party/android-inline-hook/-- submodule (our shadowhook fork)module/-- KernelSU/Magisk module metadatabuild.py-- cross-compile + package script
License
MIT. See LICENSE.