diff --git a/changelog.d/fixed-a-failing-native-probe-no-longer-5734.md b/changelog.d/fixed-a-failing-native-probe-no-longer-5734.md new file mode 100644 index 00000000..18b41b17 --- /dev/null +++ b/changelog.d/fixed-a-failing-native-probe-no-longer-5734.md @@ -0,0 +1,9 @@ +_2026-08-24_ + +## English + +A failing native probe no longer takes the app down with it. The probe library now unwinds instead of aborting, so an unexpected kernel reply surfaces as one failed check run — with the panic message and its source line in logcat — instead of killing the process at startup. + +## Русский + +Сбой нативной проверки больше не роняет приложение. Библиотека проверок теперь разворачивает стек вместо аварийного завершения: неожиданный ответ ядра приводит к одной неудачной проверке (с сообщением и строкой исходника в logcat), а не к падению приложения при запуске. diff --git a/changelog.d/fixed-changing-a-setting-no-longer-risks-6699.md b/changelog.d/fixed-changing-a-setting-no-longer-risks-6699.md new file mode 100644 index 00000000..33100873 --- /dev/null +++ b/changelog.d/fixed-changing-a-setting-no-longer-risks-6699.md @@ -0,0 +1,9 @@ +_2026-08-24_ + +## English + +Changing a setting no longer risks dropping an app's hiding roles. Toggling the SuperKey or experimental protection rebuilt the whole app list from a projection that resolved app-hiding targets through the installed-app list, so an app in a profile the scan could not read lost its role on save. + +## Русский + +Изменение настройки больше не может потерять роли скрытия у приложения. Переключение SuperKey или экспериментальной защиты пересобирало весь список приложений из проекции, в которой роль Apps проходила через список установленных приложений, — и приложение из профиля, который не удалось прочитать, теряло роль при сохранении. diff --git a/changelog.d/fixed-the-bind-interface-vector-is-covered-dc2b.md b/changelog.d/fixed-the-bind-interface-vector-is-covered-dc2b.md new file mode 100644 index 00000000..e0cacf5b --- /dev/null +++ b/changelog.d/fixed-the-bind-interface-vector-is-covered-dc2b.md @@ -0,0 +1,9 @@ +_2026-08-24_ + +## English + +The bind-interface vector is covered on kernels below 5.9 again. Backends only hooked the resolved-ifindex helper on 5.7-5.8 and otherwise relied on the kernel refusing an unprivileged bind — a LineageOS 5.4 build let an app bind a socket to the VPN interface anyway. The helper is now found by symbol, and when a kernel exposes neither, the backend no longer claims to cover the vector. + +## Русский + +Вектор привязки сокета к интерфейсу снова закрыт на ядрах ниже 5.9. Раньше хук ставился только на ядра 5.7-5.8, а в остальных случаях мы полагались на то, что ядро само отвергнет привязку без прав — на сборке LineageOS 5.4 приложение всё равно смогло привязаться к VPN-интерфейсу. Теперь нужная функция ищется по имени в символах ядра, а если её нет вовсе, бэкенд больше не отчитывается о защите этого вектора. diff --git a/changelog.d/fixed-the-zygisk-backend-no-longer-risks-f812.md b/changelog.d/fixed-the-zygisk-backend-no-longer-risks-f812.md new file mode 100644 index 00000000..498bea52 --- /dev/null +++ b/changelog.d/fixed-the-zygisk-backend-no-longer-risks-f812.md @@ -0,0 +1,9 @@ +_2026-08-24_ + +## English + +The Zygisk backend no longer risks crashing a target app that passes a bad pointer to an interface ioctl. The interface name is now read through the same fault-contained path the setsockopt hook uses, so such a call gets the kernel's own EFAULT instead of a segfault inside the app. + +## Русский + +Zygisk-бэкенд больше не может уронить целевое приложение, если оно передаёт некорректный указатель в ioctl по интерфейсу. Имя интерфейса читается тем же защищённым от сбоя способом, что и в хуке setsockopt, поэтому такой вызов получает штатный EFAULT от ядра, а не segfault внутри приложения. diff --git a/changelog.d/fixed-the-zygisk-backend-now-hides-interface-fb22.md b/changelog.d/fixed-the-zygisk-backend-now-hides-interface-fb22.md new file mode 100644 index 00000000..a3ff4176 --- /dev/null +++ b/changelog.d/fixed-the-zygisk-backend-now-hides-interface-fb22.md @@ -0,0 +1,9 @@ +_2026-08-25_ + +## English + +The Zygisk backend now hides interface binds on kernels where it previously stayed silent. It used to decide from the kernel version, assuming older kernels refuse an unprivileged bind on their own; where that is not true, the interface stayed bindable. It now measures what the running kernel answers for an interface that does not exist and gives a hidden one exactly the same answer. + +## Русский + +Zygisk-бэкенд теперь скрывает привязку к интерфейсу на ядрах, где раньше молчал. Решение принималось по версии ядра, исходя из того, что старые ядра сами отвергают привязку без прав; там, где это не так, интерфейс оставался доступным. Теперь бэкенд измеряет, что работающее ядро отвечает на несуществующий интерфейс, и отдаёт скрытому ровно такой же ответ. diff --git a/docs/detection-vectors.md b/docs/detection-vectors.md index 6b8ee5ed..1e1a387d 100644 --- a/docs/detection-vectors.md +++ b/docs/detection-vectors.md @@ -240,14 +240,30 @@ return-only kretprobe would be too late; KPM uses KernelPatch's pre-hook on the syscall path — and the wrapper must not trust the ABI `level` argument (LTO drops it as dead, since `sk_setsockopt` never reads it), because reaching either function already proves the call is `SOL_SOCKET`. Both freeze the 5.9+ -`sockptr_t` input before validation to close userspace TOCTOU. On 5.7-5.8, KPM instead hooks the resolved-ifindex mutation -helper; if LTO removes that static symbol, status is deliberately partial. -Before 5.7, the kernel itself rejects the first interface bind without -`CAP_NET_RAW`, and KPM preserves that native result exactly rather than adding -a distinguishable errno. Android common 5.4 backports `SO_BINDTOIFINDEX` but -keeps that capability gate; 4.x lacks the option entirely. The legacy QEMU -checks record the gated paths as native protection and the absent option as -not applicable. +`sockptr_t` input before validation to close userspace TOCTOU. Below 5.9 the +option value is still a raw user pointer, so KPM hooks the resolved-ifindex +mutation helper instead — after the copy and the name lookup, before +`sk_bound_dev_if` changes. Which helper exists is a property of the tree, not of +the version: upstream renamed `sock_setbindtodevice_locked` to +`sock_bindtoindex_locked` in 5.8, and Android/vendor 5.4 trees backport the newer +name, so KPM probes both by symbol and takes whichever answers. A tree exporting +neither leaves the hook bit clear — the mask then says, honestly, that this +backend does not cover the vector. + +Below 5.9 the KPM hook is a pre-hook on the mutation helper, so it runs before +that helper's own `CAP_NET_RAW` check. It therefore asks the same question first +(`capable(CAP_NET_RAW)`, no struct offsets involved) and denies only callers that +would otherwise have succeeded: where the kernel refuses everyone, every bind +keeps failing identically instead of singling the VPN name out with a different +errno. Same rule the Zygisk hook follows, arrived at from the same counterexample. + +The kernel's own `CAP_NET_RAW` gate is **not** treated as a substitute. It used +to be: below 5.7 this vector was deliberately left unhooked on the grounds that +`sock_bindtoindex_locked()` rejects an unprivileged bind anyway. A LineageOS +5.4 build (sm8350) disproved it — the gate is compiled in there, and an +untrusted app still bound a socket to `tun0`. Whatever satisfies `ns_capable()` +on such a ROM, the lesson is that a kernel-side policy we do not control cannot +back a coverage claim. When neither kernel backend is available, Zygisk inline-hooks bionic's `setsockopt` entry point and applies the same pre-syscall `ENODEV` policy. It @@ -255,9 +271,17 @@ copies the untrusted option value through a fault-contained self-read, so a bad pointer still reaches the kernel for native `EFAULT` handling instead of crashing the target process. This is deliberately **best effort**: a caller issuing `__NR_setsockopt` through raw `svc #0` never enters bionic and bypasses -the hook. On pre-5.7 kernels it stays inert because the kernel rejects an -unprivileged bind before inspecting the name; returning a name-dependent error -there would create a new oracle. +the hook. It no longer decides what to do from the release string. Instead it +measures, once per process, what this kernel returns for a bind to a name that +cannot exist, and denies a hidden interface with exactly that errno — EPERM on +trees that check `CAP_NET_RAW` before parsing the name, ENODEV on trees that +resolve the name first. Mirroring the measured answer is what keeps the reply +from being an oracle: where every bind is refused, a name-specific ENODEV would +announce the interface; where binds succeed, staying inert would leak it (the +LineageOS 5.4 case above). If the measurement is unusable the hook falls back to +the old version heuristic, so behaviour is never worse than before it existed. +`bind-probe` records the same value per kernel family in the QEMU lanes +(`bind_absent_name`). This vector is deliberately tested by a raw `svc` probe. A second, non-target UID inspects the same inherited socket after the target call, so a backend that diff --git a/kmod/kpm/vpnhide_kpm.c b/kmod/kpm/vpnhide_kpm.c index d213b2e5..aef1bc87 100644 --- a/kmod/kpm/vpnhide_kpm.c +++ b/kmod/kpm/vpnhide_kpm.c @@ -156,6 +156,11 @@ static unsigned long (*_copy_to_user)(void *, const void *, unsigned long); static uint64_t (*_read_sanitised_ftr_reg)(uint32_t); static void (*_skb_trim)(void *, unsigned int); static int (*_netdev_get_name)(void *, char *, int); +/* Does the caller hold CAP_NET_RAW? Upstream asks ns_capable(net->user_ns, ...); + * capable() is the same question against init_user_ns, which is where Android + * app processes live — and it needs no struct offsets, so it cannot go stale on + * a vendor kernel the way an offset table can. */ +static int (*_capable)(int); static char *(*_dentry_path_raw)(void *, char *, int); static int (*_vfs_statfs)(const void *, void *); static void (*_path_put)(const void *); @@ -684,6 +689,7 @@ static void rtnl_fill_after(hook_fargs12_t *fargs, void *udata) /* ================================================================== */ #define VPNHIDE_ENODEV ((uint64_t)(-19)) +#define VPNHIDE_CAP_NET_RAW 13 /* arm64: TTBR1 (kernel) addresses have the top 16 bits set; user ptrs don't. */ static int ptr_is_kernel(const void *p) @@ -723,18 +729,27 @@ static int sockopt_takes_sk(void) return (unsigned int)kver >= VPNHIDE_KVER(6, 1, 0); } +/* + * Below the sockptr_t era the setsockopt wrapper cannot be hooked safely (the + * option value is still a raw user pointer, so validating it there would be a + * TOCTOU). Hook the resolved-ifindex mutation helper instead: it runs after the + * copy and the name lookup, but before sk_bound_dev_if changes. + * + * Which helper exists is a property of the tree, not of the version number. + * Upstream renamed sock_setbindtodevice_locked -> sock_bindtoindex_locked in + * 5.8, but Android/vendor 5.4 trees backport the newer one (confirmed on a + * LineageOS sm8350 5.4.302-qgki build). Probe by symbol and take whichever + * answers, rather than deriving the name from kver and missing both. + */ static int socket_bind_uses_index_hook(void) { - return (unsigned int)kver >= VPNHIDE_KVER(5, 7, 0) && - (unsigned int)kver < VPNHIDE_KVER(5, 9, 0); + return (unsigned int)kver < VPNHIDE_KVER(5, 9, 0); } -static const char *socket_bind_index_hook_name(void) -{ - return (unsigned int)kver < VPNHIDE_KVER(5, 8, 0) ? - "sock_setbindtodevice_locked" : - "sock_bindtoindex_locked"; -} +static const char *const socket_bind_index_hook_names[] = { + "sock_bindtoindex_locked", /* 5.8+, and 5.4/5.7 backports */ + "sock_setbindtodevice_locked", /* 5.3-5.7 upstream */ +}; static int copy_sockopt_bytes(hook_fargs8_t *fargs, void *dst, unsigned int len) { @@ -817,6 +832,15 @@ static void socket_bind_index_before(hook_fargs4_t *fargs, void *udata) if (!hook_active(VPNHIDE_HOOK_SOCKET_BIND_INTERFACE)) return; + /* This is a PRE-hook on the mutation helper, so it runs before the + * helper's own CAP_NET_RAW check. On a kernel where that check refuses + * the caller, every bind fails with EPERM — answering ENODEV for a VPN + * name alone would announce the interface instead of hiding it. Let the + * kernel refuse those callers itself and stay indistinguishable; deny + * only the callers that would otherwise have succeeded. A kernel without + * a resolvable capable() keeps the previous unconditional denial. */ + if (_capable && !_capable(VPNHIDE_CAP_NET_RAW)) + return; if (socket_bind_ifindex_hidden((void *)fargs->arg0, ifindex)) deny_socket_bind(fargs); } @@ -840,10 +864,13 @@ static void socket_bind_before_common(hook_fargs8_t *fargs, int takes_sk) if (optname != VPNHIDE_SO_BINDTODEVICE && optname != VPNHIDE_SO_BINDTOIFINDEX) return; - /* Before 5.7 the native capability gate owns this vector. On 5.7-5.8 - * the resolved-index hook owns it so the old user-pointer ABI cannot - * introduce a check/use race. This wrapper is authoritative only once - * sockptr_t can carry our immutable kernel snapshot. */ + /* Below 5.9 the resolved-index hook owns this vector, so this wrapper is + * never installed there (see socket_bind_uses_index_hook). The guard stays + * as defence: this path is authoritative only once sockptr_t can carry our + * immutable kernel snapshot, and validating a raw user pointer here would + * be a check/use race. Do NOT re-add "the kernel's CAP_NET_RAW gate covers + * old kernels" as a reason to skip the vector — a LineageOS 5.4 build let an + * app bind to tun0 with that gate compiled in. */ if (!sockopt_uses_sockptr()) return; @@ -1773,6 +1800,7 @@ static int resolve_symbols(void) if (!_skb_trim) _skb_trim = (void *)kallsyms_lookup_name("__skb_trim"); _netdev_get_name = (void *)lookup_fn("netdev_get_name"); + _capable = (void *)lookup_fn("capable"); if (!_skb_trim) { logki(MODNAME ": skb trim helper unavailable\n"); @@ -2008,14 +2036,25 @@ static long vpnhide_kpm_init(const char *args, const char *event, int bind_ok; if (socket_bind_uses_index_hook()) { - /* 5.7-5.8 still pass a raw user pointer to sock_setsockopt. - * Hook the resolved index instead, after the user copy and name - * lookup but before sk_bound_dev_if changes. A missing static - * symbol leaves status partial rather than accepting a TOCTOU. */ - bind_ok = install_hook( - socket_bind_index_hook_name(), 2, - (void *)socket_bind_index_before, 0, - VPNHIDE_HOOK_SOCKET_BIND_INTERFACE); + unsigned int i; + + /* A tree that exports neither helper leaves status partial + * rather than accepting a TOCTOU on the user pointer. It also + * stops us claiming a vector we do not actually cover: the + * kernel's own CAP_NET_RAW gate is NOT a substitute, as a + * LineageOS 5.4 build showed by letting an app bind to tun0 + * with the gate compiled in. */ + bind_ok = 0; + for (i = 0; + !bind_ok && + i < sizeof(socket_bind_index_hook_names) / + sizeof(socket_bind_index_hook_names + [0]); + i++) + bind_ok = install_hook( + socket_bind_index_hook_names[i], 2, + (void *)socket_bind_index_before, 0, + VPNHIDE_HOOK_SOCKET_BIND_INTERFACE); } else { bind_ok = install_hook( "sock_setsockopt", 6, diff --git a/kmod/test/bind-probe.c b/kmod/test/bind-probe.c index 5affbe59..5b498666 100644 --- a/kmod/test/bind-probe.c +++ b/kmod/test/bind-probe.c @@ -370,5 +370,11 @@ int main(int argc, char **argv) run_name_case("BIND_BADLEN", vpn_name, (socklen_t)-1); run_index_case("BIND_INDEX", vpn_ifindex); run_name_case("BIND_KEEP", "eth0", 4); + /* Ground truth for "what does an interface that does not exist look like + * here": EPERM on trees that test CAP_NET_RAW before parsing the name, + * ENODEV on trees that resolve the name first. Hiding an interface means + * answering exactly this, so the zygisk backend measures the same thing at + * runtime (hidden_bind_errno) instead of deriving it from the version. */ + run_name_case("BIND_ABSENT", "zzvpnhideprobe", 15); return 0; } diff --git a/kmod/test/init.sh b/kmod/test/init.sh index ae2ac8d0..d157d97d 100755 --- a/kmod/test/init.sh +++ b/kmod/test/init.sh @@ -225,7 +225,7 @@ bind_field() { # inspect the same socket afterwards. check_socket_bind() { if [ ! -x /bind-probe ]; then - for _vec in bind_device_raw bind_device_nul bind_bad_pointer bind_bad_length bind_ifindex keep_bind_device; do + for _vec in bind_device_raw bind_device_nul bind_bad_pointer bind_bad_length bind_ifindex keep_bind_device bind_absent_name; do echo "RESULT $_vec=SKIP (no socket bind probe available)" done return @@ -236,7 +236,7 @@ check_socket_bind() { set_target 5555 _vpn_ifindex=$(cat /sys/class/net/vpn0/ifindex 2>/dev/null) if [ -z "$_vpn_ifindex" ]; then - for _vec in bind_device_raw bind_device_nul bind_bad_pointer bind_bad_length bind_ifindex keep_bind_device; do + for _vec in bind_device_raw bind_device_nul bind_bad_pointer bind_bad_length bind_ifindex keep_bind_device bind_absent_name; do echo "RESULT $_vec=FAIL (vpn0 ifindex unavailable)" FAIL=$((FAIL + 1)) done @@ -323,6 +323,24 @@ check_socket_bind() { echo "RESULT keep_bind_device=FAIL (nt_errno=$_nt_errno nt_state=$_nt_state tg_errno=$_tg_errno tg_state=$_tg_state)" FAIL=$((FAIL + 1)) fi + + # What does an interface that does not exist look like on THIS kernel? + # EPERM where CAP_NET_RAW is checked before the name is parsed, ENODEV + # where the name is resolved first. Hiding an interface means answering + # exactly this, so the zygisk backend measures the same thing at runtime + # rather than deriving it from the release string. Both values are + # correct; a bind that SUCCEEDS is not, and neither is a bound socket. + _nt_errno=$(bind_field "$_nt" BIND_ABSENT_ERRNO) + _nt_state=$(bind_field "$_nt" BIND_ABSENT_STATE) + [ -n "$_nt_errno" ] || _nt_errno=-1 + [ -n "$_nt_state" ] || _nt_state=-1 + if [ "$_nt_errno" -ne 0 ] && [ "$_nt_state" -eq 0 ]; then + echo "RESULT bind_absent_name=PASS (errno=$_nt_errno unbound)" + PASS=$((PASS + 1)) + else + echo "RESULT bind_absent_name=FAIL (errno=$_nt_errno state=$_nt_state)" + FAIL=$((FAIL + 1)) + fi } # vector -> hook it exercises diff --git a/kmod/test/run-kpm.sh b/kmod/test/run-kpm.sh index ac5f1c22..75422f84 100755 --- a/kmod/test/run-kpm.sh +++ b/kmod/test/run-kpm.sh @@ -331,6 +331,21 @@ else echo "RESULT keep_bind_device=FAIL (nt_errno=$nt_keep_errno nt_state=$nt_keep_state tg_errno=$tg_keep_errno tg_state=$tg_keep_state)"; FAIL=$((FAIL+1)) fi +nt_absent_errno="$(bind_field BIND_ABSENT_ERRNO "$NT_LOG")" +nt_absent_state="$(bind_field BIND_ABSENT_STATE "$NT_LOG")" +if [ -z "$nt_absent_errno" ] || [ -z "$nt_absent_state" ]; then + echo "RESULT bind_absent_name=SKIP (socket bind probe unavailable)" + SKIP=$((SKIP+1)) +elif [ "$nt_absent_errno" -ne 0 ] && [ "$nt_absent_state" -eq 0 ]; then + # The measured "no such interface" answer for this kernel family: EPERM + # where CAP_NET_RAW is checked before the name is parsed, ENODEV where the + # name is resolved first. Recorded because the zygisk backend mirrors it + # at runtime (hidden_bind_errno) instead of deriving it from the version. + echo "RESULT bind_absent_name=PASS (errno=$nt_absent_errno unbound)"; PASS=$((PASS+1)) +else + echo "RESULT bind_absent_name=FAIL (errno=$nt_absent_errno state=$nt_absent_state)"; FAIL=$((FAIL+1)) +fi + if [ -z "$IFC" ]; then echo "RESULT ifconf_tail=SKIP (no ifconf probe available)" SKIP=$((SKIP+1)) diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt index 199370f9..979bf1b5 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt @@ -1747,19 +1747,9 @@ internal suspend fun loadDashboardState( val installedOptionalHooks = installedNativeOptionalHooks(nativeBackend.id, shellSnapshot, currentBootId) - // A kernel backend that loaded but could not resolve every hook target. Not an - // error (what did install still works, and no reinstall fixes a kernel that - // renamed the symbol), but the leaks it causes are otherwise unexplained. - partialHookGap(nativeBackend, installedOptionalHooks)?.let { gap -> - warn( - res.getString( - R.string.dashboard_issue_native_partial_hooks, - gap.installed, - gap.expected, - gap.missing.joinToString(", ") { it.hookName }, - ), - ) - } + // Held so the partial-hook warning below can ask whether a missing hook is + // actually costing us a vector on this device. + var measuredReport: DiagnosticReport? = null // Single source of truth: the cache does all the gating (VPN off / needs-restart / // self-not-routed) through the one fold. awaitTerminal returns the terminal state // itself, so the reason for "no results" (blocked gate vs a failed run) is carried @@ -1784,6 +1774,7 @@ internal suspend fun loadDashboardState( complete = true, installedOptionalHooks = installedOptionalHooks, ) + measuredReport = report ProtectionCheck.Checked(report.native.status, report.java.status) } @@ -1794,6 +1785,24 @@ internal suspend fun loadDashboardState( } } + // A kernel backend that loaded but could not resolve every hook target. Warn + // only when a missing hook is costing us something measurable: on kernels that + // never had the symbol at all, the vector is usually closed by SELinux or a + // capability check anyway, and an alarm there is noise. No reinstall fixes a + // kernel that renamed or dropped a function, so this is a warning, not an error. + partialHookGap(nativeBackend, installedOptionalHooks) + ?.takeIf { gap -> measuredReport?.let { gap.costsAnyVector(it) } != false } + ?.let { gap -> + warn( + res.getString( + R.string.dashboard_issue_native_partial_hooks, + gap.installed, + gap.expected, + gap.missing.joinToString(", ") { it.hookName }, + ), + ) + } + lsposedVersionMismatch?.let { text -> if (protectionFullyPassed(protection)) { info(text) diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/FilesystemHidingSettings.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/FilesystemHidingSettings.kt index e9f34ae3..5b36dcad 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/FilesystemHidingSettings.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/FilesystemHidingSettings.kt @@ -191,7 +191,9 @@ private fun filesystemHidingStatusText(state: FilesystemHidingState): String = private suspend fun writeFilesystemHidingSetting(enabled: Boolean): Int { val snapshot = TargetsCache.snapshot.value ?: return 1 - val base = buildCanonicalConfigFromTargetsSnapshot(snapshot) + // The config as stored, not a rebuild from the snapshot's per-role sets — + // flipping an optional feature must leave the app list byte-identical. + val base = snapshot.canonicalConfig ?: CanonicalConfig() val canonical = base.copy( settings = diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LogTags.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LogTags.kt index cfb2fea3..82e219b4 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LogTags.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LogTags.kt @@ -27,6 +27,10 @@ internal object LogTags { /** Diagnostics self-test tag (distinct capitalisation, matched separately). */ const val TEST = "VPNHideTest" + /** The Rust probe crate: panic reports from its own hook, and the Kotlin + * side's report of a probe run that threw. */ + const val NATIVE = "VpnHide-Native" + /** App-process (and system_server) tags captured in debug bundles. */ val APP_TAGS = listOf( @@ -43,6 +47,7 @@ internal object LogTags { STATISTICS, APP_LIST, DEBUG_CONFIG, + NATIVE, ) /** Native-backend logcat tags (kmod / ports / zygisk / shadowhook). */ diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeBackendData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeBackendData.kt index ff9b631d..d9d07e49 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeBackendData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/NativeBackendData.kt @@ -99,7 +99,21 @@ internal data class PartialHookGap( val installed: Int, val expected: Int, val missing: List, -) +) { + /** + * Is any vector actually leaking because one of [missing] never installed? + * + * A hook the kernel does not expose is only worth telling the user about when + * it costs something here: on old kernels the same surface is usually closed + * by SELinux or a capability check, and warning there would be noise. A + * measured leak on a check whose expected hooks are all missing is the case + * that needs the explanation. + */ + fun costsAnyVector(report: DiagnosticReport): Boolean = + report.native.checks.any { check -> + check.outcome is CheckOutcome.Leak && check.missingHooks.isNotEmpty() + } +} internal fun partialHookGap( backend: DisplayNativeBackend, diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/SettingsScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/SettingsScreen.kt index b1166938..31b203aa 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/SettingsScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/SettingsScreen.kt @@ -876,10 +876,10 @@ private suspend fun writeSuperkeySetting( remember: Boolean, superkey: String, ): Int { - val snapshot = TargetsCache.snapshot.value - val base = - snapshot?.let(::buildCanonicalConfigFromTargetsSnapshot) - ?: CanonicalConfig() + // Read the config itself, never a rebuild from the snapshot's projections: + // this toggles one settings field and must not rewrite the app list on its + // way past. A missing config means there is nothing to preserve yet. + val base = TargetsCache.snapshot.value?.canonicalConfig ?: CanonicalConfig() val canonical = base.copy(settings = base.settings.copy(rememberSuperkey = remember)) val secretCommand = if (remember) buildSuperkeyWriteCommand(superkey) else buildSuperkeyClearCommand() return CanonicalConfigRepository diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetsCache.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetsCache.kt index 3f084b87..c0b4cea0 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetsCache.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetsCache.kt @@ -26,11 +26,6 @@ internal data class TargetsSnapshot( val kpmModuleInstalled: Boolean, val zygiskModuleInstalled: Boolean, val portsModuleInstalled: Boolean, - val nativeTargets: Set, - val lsposedTargets: Set, - val hiddenPkgs: Set, - val observerUids: Set, - val portsObservers: Set, val uidToPkg: Map, val canonicalConfig: CanonicalConfig?, val apatchSuperkeySaved: Boolean = false, @@ -58,12 +53,31 @@ internal data class TargetsSnapshot( val nativeHookFamily: NativeHookFamily get() = nativeHookFamilyFor(displayNativeBackendId) - /** Observer UIDs resolved back to current package names via - * `pm list packages -U`. UIDs that no longer map to an installed - * package (e.g. after an uninstall) silently drop out. + /** + * Per-role package sets, projected from [canonicalConfig] on read. + * + * They used to be stored fields, filled in beside the config they were + * derived from — two representations of one truth in a single object, and + * the roles could be written back from the weaker one. `appHiding` was the + * casualty: it was stored as resolved UIDs and mapped back through the + * package inventory, so a target missing from the inventory (a profile the + * scan could not read) silently lost the role the next time anything saved. + * Projected getters cannot drift from the config, and no role survives a + * round trip through UIDs any more. */ - val observerNames: Set - get() = observerUids.mapNotNull { uidToPkg[it] }.toSet() + private val desired: CanonicalConfig get() = canonicalConfig ?: CanonicalConfig() + + val nativeTargets: Set get() = desired.apps.filterValues { it.native.enabled }.keys + val lsposedTargets: Set get() = desired.apps.filterValues { it.java }.keys + val hiddenPkgs: Set get() = desired.apps.filterValues { it.hidden }.keys + val portsObservers: Set get() = desired.apps.filterValues { it.ports }.keys + val observerNames: Set get() = desired.apps.filterValues { it.appHiding }.keys + + /** App-hiding observers as UIDs, for the consumers that speak the wire's + * language. Derived on demand; a package the inventory does not know + * contributes nothing here but keeps its role in the config. */ + val observerUids: Set + get() = observerNames.flatMapTo(mutableSetOf()) { packageUids[it].orEmpty() } } internal object TargetsCache : StateCache( @@ -119,7 +133,6 @@ internal fun parseTargetsSnapshot(rootSnapshot: RootSnapshot): TargetsSnapshot { val sections = rootSnapshot.sections val portsInstalled = sections["ports_prop"]?.isNotBlank() == true val canonical = runCatching { parseCanonicalConfig(sections["canonical_config"].orEmpty()) }.getOrNull() - val desired = canonical ?: CanonicalConfig() // The inventory contains one block per Android user. Each resolved UID // becomes its own reverse-map entry so observer lookups from any profile @@ -129,25 +142,15 @@ internal fun parseTargetsSnapshot(rootSnapshot: RootSnapshot): TargetsSnapshot { pkgToUids.forEach { (pkg, uids) -> uids.forEach { uidToPkg[it] = pkg } } - - fun uidsFor(pkgs: Set): Set = pkgs.flatMap { pkgToUids[it].orEmpty() }.toSet() val activeNativeBackendId = detectNativeBackendStates(sections).activeId - val javaTargets = desired.apps.filterValues { it.java }.keys - val nativeTargets = desired.apps.filterValues { it.native.enabled }.keys - val observerNames = desired.apps.filterValues { it.appHiding }.keys - val hiddenPkgs = desired.apps.filterValues { it.hidden }.keys - val portsObservers = desired.apps.filterValues { it.ports }.keys + // Per-role sets are projections of the config (see TargetsSnapshot), not + // fields — there is nothing to fill in here beyond the config itself. return TargetsSnapshot( kmodModuleInstalled = sections["kmod_module_dir"]?.trim() == "1", kpmModuleInstalled = sections["kpm_module_dir"]?.trim() == "1", zygiskModuleInstalled = sections["zygisk_module_dir"]?.trim() == "1", portsModuleInstalled = portsInstalled, - nativeTargets = nativeTargets, - lsposedTargets = javaTargets, - hiddenPkgs = hiddenPkgs, - observerUids = uidsFor(observerNames), - portsObservers = portsObservers, uidToPkg = uidToPkg, canonicalConfig = canonical, apatchSuperkeySaved = sections["superkey_saved"]?.trim() == "1", diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/checks/NativeProbe.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/checks/NativeProbe.kt index 303aa069..d562f94a 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/checks/NativeProbe.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/checks/NativeProbe.kt @@ -1,5 +1,6 @@ package dev.okhsunrog.vpnhide.checks +import android.util.Log import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -30,8 +31,19 @@ object NativeProbe { external fun runAllChecksJson(): String /** In-process (app-view) run: probes execute as this app (real uid + - * SELinux domain + zygisk/kernel hooks), keyed by stable check id. */ - fun runAll(): Map = parse(runAllChecksJson()) + * SELinux domain + zygisk/kernel hooks), keyed by stable check id. + * + * A probe parses whatever the kernel returns on an arbitrary vendor build, + * so the native side catches its own panics and rethrows them here as a + * Java exception (see the JNI entry in lsposed/native). Swallowing it costs + * one check run — the alternative is the whole app going down on a device + * whose kernel returns something we did not anticipate. The panic message + * and its file:line are in logcat under `VpnHide-Native`. + */ + fun runAll(): Map = + runCatching { parse(runAllChecksJson()) } + .onFailure { Log.e("VpnHide-Native", "native probe run failed", it) } + .getOrDefault(emptyMap()) /** Parse a probe JSON blob (from either transport) into id -> outcome. */ fun parse(json: String): Map = diff --git a/lsposed/app/src/main/res/values-ru/strings.xml b/lsposed/app/src/main/res/values-ru/strings.xml index df5f1dfb..ee8d45a7 100644 --- a/lsposed/app/src/main/res/values-ru/strings.xml +++ b/lsposed/app/src/main/res/values-ru/strings.xml @@ -324,7 +324,7 @@ Обнаружена неправильная установка KPM: KernelPatch загрузил отдельный файл vpnhide.kpm, но модуль vpnhide-kpm.zip не установлен. В одном файле .kpm нет активатора VPN Hide, загрузочных скриптов и доставки настроек, поэтому защита не будет работать правильно. Удалите или выгрузите отдельную запись «vpnhide» в APatch/KPatch-Next (если KPM был встроен в ядро, заново пропатчите boot-образ без него), установите целиком vpnhide-kpm.zip через экран «Модули» и перезагрузите устройство. KPM установлен, runtime KernelPatch отвечает, но управляющий вызов отклонён — SuperKey в VPN Hide не сохранён, и авторизоваться удалось только доверенным su-токеном. Сохраните SuperKey в Настройки → Безопасность (тот же, что задан в APatch/FolkPatch) и нажмите «Обновить». Переустановка zip не поможет. KPM установлен, но ядро не пропатчено runtime\'ом KernelPatch, чтобы его загрузить. Установите KPatch-Next-Module и пропатчьте ядро из его интерфейса (работает и на Magisk, и на KernelSU). Если не получится — используйте APatch или FolkPatch. - Нативный бэкенд загрузился, но на этом ядре встало только %1$d хуков из %2$d — не удалось найти: %3$s. Проверки, которые они закрывают, остаются видимыми; остальное скрыто по-прежнему. Переустановка не поможет: в этой сборке ядра нужные функции названы иначе или отсутствуют. + Нативный бэкенд загрузился, но на этом ядре встало только %1$d хуков из %2$d — не удалось найти: %3$s. Проверки, которые они закрывают, этим бэкендом не защищены; их может закрывать другой слой. Переустановка не поможет: в этой сборке ядра нужные функции названы иначе или отсутствуют. KPM установлен, но не загрузился: %1$s. Чтобы приложить полный лог загрузки к отчёту об ошибке, нажмите Настройки → Отладка → Собрать отладочный лог, либо переустановите vpnhide-kpm.zip из последнего релиза. Установлено больше одного Native-бэкенда. Активным может быть только один (приоритет: модуль ядра, затем KPM, затем Zygisk), остальные простаивают. Удалите неиспользуемые, чтобы не засорять систему. Отладочные логи включены. VPN Hide пишет подробные строки в logcat, которые может прочитать любой с root-доступом. Выключите переключатель в Настройки → Отладка после сбора отчёта об ошибке. diff --git a/lsposed/app/src/main/res/values-zh-rCN/strings.xml b/lsposed/app/src/main/res/values-zh-rCN/strings.xml index 0c969c1f..58600fd5 100644 --- a/lsposed/app/src/main/res/values-zh-rCN/strings.xml +++ b/lsposed/app/src/main/res/values-zh-rCN/strings.xml @@ -358,7 +358,7 @@ 检测到无效的 KPM 安装:KernelPatch 加载了独立的 vpnhide.kpm 文件,但 vpnhide-kpm.zip 模块并未安装。单独的 .kpm 文件没有 VPN Hide 激活器、开机脚本或配置下发,因此保护无法正常工作。请在 APatch/KPatch-Next 中移除或卸载独立的“vpnhide”条目(若它是内嵌的,请在不含它的情况下重新修补 boot 镜像),再从模块界面安装完整的 vpnhide-kpm.zip,然后重启。 KPM 已安装,KernelPatch 运行时也有响应,但模块控制调用被拒绝——VPN Hide 没有保存 SuperKey,只能用受信任的 su 令牌认证。请在“设置 → 安全”中保存 SuperKey(与 APatch/FolkPatch 里设置的相同),然后点按“刷新”。重装 zip 没有用。 KPM 已安装,但内核尚未通过 KernelPatch 运行时打补丁来加载它。请安装 KPatch-Next-Module 并在其界面中为内核打补丁(在 Magisk 和 KernelSU 上均可用)。若不行,请改用 APatch 或 FolkPatch。 - 原生后端已加载,但在此内核上只装上了 %2$d 个内核钩子中的 %1$d 个——未能解析:%3$s。它们负责的检测面仍然可见,其余部分依旧被隐藏。重新安装没有用:在这个内核版本里,这些函数的名字不同或根本不存在。 + 原生后端已加载,但在此内核上只装上了 %2$d 个内核钩子中的 %1$d 个——未能解析:%3$s。它们负责的检测面不由该后端覆盖,可能仍有其他层将其关闭。重新安装没有用:在这个内核版本里,这些函数的名字不同或根本不存在。 KPM 已安装但加载失败:%1$s。请用“设置 → 调试 → 收集调试日志”,把完整的开机输出附到 Bug 报告里,或从最新发行版重新安装 vpnhide-kpm.zip。 装了多个原生后端。同一时间只能有一个生效(优先级:内核模块,其次 KPM,再次 Zygisk),其余会闲置。请卸载用不到的,保持整洁。 调试日志已开启。VPN Hide 正在往 logcat 写详细日志,任何有 Root 的人都能读到。收集完 Bug 报告后,请在“设置 → 调试”中把它关掉。 diff --git a/lsposed/app/src/main/res/values/strings.xml b/lsposed/app/src/main/res/values/strings.xml index 20369576..8deda10e 100644 --- a/lsposed/app/src/main/res/values/strings.xml +++ b/lsposed/app/src/main/res/values/strings.xml @@ -369,7 +369,7 @@ Invalid KPM installation detected: KernelPatch loaded the standalone vpnhide.kpm file, but the vpnhide-kpm.zip module is not installed. The .kpm file alone has no VPN Hide activator, boot scripts, or configuration delivery, so protection will not work correctly. Remove or unload the standalone “vpnhide” entry in APatch/KPatch-Next (if it was embedded, repatch the boot image without it), install the complete vpnhide-kpm.zip from the Modules screen, then reboot. KPM is installed and the KernelPatch runtime answered, but it refused the module control call — VPN Hide has no SuperKey saved, so it could only authenticate with the trusted su token. Save your SuperKey in Settings → Security (the same one you set in APatch/FolkPatch) and tap Refresh. Reinstalling the zip does not help. KPM is installed, but the kernel is not patched with a KernelPatch runtime to load it. Install the KPatch-Next-Module and patch your kernel from its interface (it works on both Magisk and KernelSU). If that does not work, use APatch or FolkPatch instead. - The native backend loaded, but only %1$d of %2$d kernel hooks installed on this kernel — it could not resolve: %3$s. The detection surfaces behind them stay visible; everything else is still hidden. Reinstalling does not help: the functions are named differently (or absent) in this kernel build. + The native backend loaded, but only %1$d of %2$d kernel hooks installed on this kernel — it could not resolve: %3$s. The detection surfaces behind them are not covered by this backend; another layer may still close them. Reinstalling does not help: the functions are named differently (or absent) in this kernel build. KPM installed but failed to load: %1$s. Use Settings → Debugging → Collect debug log to attach the full boot-time output to a bug report, or reinstall vpnhide-kpm.zip from the latest release. More than one native backend is installed. Only one can be active at a time (priority: kernel module, then KPM, then Zygisk); the others sit idle. Uninstall the ones you don\'t use to keep things clean. Debug logging is enabled. VPN Hide is writing verbose lines to logcat that anyone with root can read. Turn it off in Settings → Debugging after you\'ve finished collecting a bug report. diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AppPickerDataTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AppPickerDataTest.kt index 55834b09..b20e59f5 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AppPickerDataTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AppPickerDataTest.kt @@ -668,11 +668,6 @@ class AppPickerDataTest { kpmModuleInstalled = false, zygiskModuleInstalled = false, portsModuleInstalled = true, - nativeTargets = emptySet(), - lsposedTargets = emptySet(), - hiddenPkgs = emptySet(), - observerUids = emptySet(), - portsObservers = emptySet(), uidToPkg = emptyMap(), canonicalConfig = CanonicalConfig(apps = apps.toMap(), settings = settings), ) diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/PartialHookGapTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/PartialHookGapTest.kt index 6f2f5611..c2df1da5 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/PartialHookGapTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/PartialHookGapTest.kt @@ -2,6 +2,7 @@ package dev.okhsunrog.vpnhide import dev.okhsunrog.vpnhide.generated.HookIds import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -62,6 +63,50 @@ class PartialHookGapTest { assertNull(partialHookGap(backend(NativeBackendId.Zygisk), setOf(HookIds.Hook.ZYGISK_IOCTL))) } + @Test + fun `a gap only warrants a warning when it costs a measured vector`() { + val gap = partialHookGap(backend(NativeBackendId.Kpm), KERNEL_HOOKS - HookIds.Hook.SOCK_IOCTL)!! + + // The vector the missing hook covers is leaking → worth telling the user. + assertTrue(gap.costsAnyVector(reportWith(CheckOutcome.Leak, listOf(HookIds.Hook.SOCK_IOCTL)))) + // Same missing hook, but SELinux already closes that surface → silence. + assertFalse(gap.costsAnyVector(reportWith(CheckOutcome.HiddenBySelinux, listOf(HookIds.Hook.SOCK_IOCTL)))) + // A leak on a vector whose hooks all installed is somebody else's problem. + assertFalse(gap.costsAnyVector(reportWith(CheckOutcome.Leak, emptyList()))) + } + + private fun reportWith( + outcome: CheckOutcome, + missing: List, + ): DiagnosticReport { + val check = + DiagnosticCheck( + id = "ioctl_conf", + label = "ioctl SIOCGIFCONF enum", + layer = CheckLayer.NATIVE, + outcome = outcome, + appDetail = "", + groundTruthDetail = null, + expectedHooks = listOf(HookIds.Hook.SOCK_IOCTL), + owned = true, + missingHooks = missing, + ) + val layer = + LayerReport( + layer = CheckLayer.NATIVE, + backend = NativeBackendId.Kpm, + status = LayerStatus.Active(hidden = 0, leaks = 1), + unownedLeaks = 0, + checks = listOf(check), + ) + return DiagnosticReport( + gate = DiagnosticGate.ROUTED, + native = layer, + java = layer.copy(layer = CheckLayer.JAVA, backend = null, checks = emptyList()), + complete = true, + ) + } + @Test fun `the leaking check carries the hook that never installed`() { val reported = KERNEL_HOOKS - HookIds.Hook.SOCK_IOCTL diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/StorageConfigTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/StorageConfigTest.kt index 29fcda4c..bb63f595 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/StorageConfigTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/StorageConfigTest.kt @@ -534,13 +534,18 @@ class StorageConfigTest { kpmModuleInstalled = false, zygiskModuleInstalled = false, portsModuleInstalled = true, - nativeTargets = setOf("com.native"), - lsposedTargets = setOf("com.java"), - hiddenPkgs = setOf("com.hidden"), - observerUids = setOf(10123), - portsObservers = setOf("com.ports"), uidToPkg = mapOf(10123 to "com.observer"), - canonicalConfig = CanonicalConfig(), + canonicalConfig = + CanonicalConfig( + apps = + mapOf( + "com.native" to CanonicalApp(native = NativeRole.All), + "com.java" to CanonicalApp(java = true), + "com.hidden" to CanonicalApp(hidden = true), + "com.observer" to CanonicalApp(appHiding = true), + "com.ports" to CanonicalApp(ports = true), + ), + ), ) val cfg = buildCanonicalConfigFromTargetsSnapshot(snapshot, debug = true) diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/TargetsCacheTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/TargetsCacheTest.kt index a2e41ffc..cec5928d 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/TargetsCacheTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/TargetsCacheTest.kt @@ -53,6 +53,44 @@ class TargetsCacheTest { assertTrue(targets.apatchSuperkeySaved) } + /** + * The role must survive a package the inventory cannot see. + * + * `appHiding` used to be stored as resolved UIDs and mapped back through + * `pm list packages`, so a target in a profile the scan could not read + * vanished from the snapshot — and the next settings write, rebuilt from + * that snapshot, dropped the role on disk. A toggle unrelated to the app + * list silently unconfigured an app. + */ + @Test + fun `an app-hiding target keeps its role when the inventory cannot see it`() { + val rootSnapshot = + RootSnapshot( + sections = + mapOf( + "canonical_config" to + """ + { + "version": 1, + "apps": { + "com.invisible": { "appHiding": true }, + "com.known": { "appHiding": true } + } + } + """.trimIndent(), + // com.invisible is installed in a profile this scan missed. + "pm_packages" to "package:com.known uid:10123\n", + ), + ) + + val targets = parseTargetsSnapshot(rootSnapshot) + + assertEquals(setOf("com.invisible", "com.known"), targets.observerNames) + // Its UID is genuinely unknown, so it contributes none — that is a + // property of the inventory, not a reason to forget the role. + assertEquals(setOf(10123), targets.observerUids) + } + @Test fun `targets snapshot preserves canonical per-hook selections`() { val rootSnapshot = diff --git a/lsposed/native/Cargo.toml b/lsposed/native/Cargo.toml index d254e0b6..e186d9ea 100644 --- a/lsposed/native/Cargo.toml +++ b/lsposed/native/Cargo.toml @@ -28,7 +28,13 @@ opt-level = "z" lto = "fat" codegen-units = 1 strip = true -panic = "abort" +# Unwinding, not abort: this cdylib is loaded into the app process and its JNI +# entry catches the unwind (see install_panic_hook / with_env), so a panic in a +# probe surfaces as a Java exception instead of killing the user's app. Costs +# ~39 KB of unwind tables on arm64 — the only ABI we ship. +panic = "unwind" [profile.dev] -panic = "abort" +# Host unit tests need unwinding too — abort would mask the panic-hook path +# this crate now relies on. +panic = "unwind" diff --git a/lsposed/native/build.rs b/lsposed/native/build.rs index ed31dc8e..b63b5098 100644 --- a/lsposed/native/build.rs +++ b/lsposed/native/build.rs @@ -11,4 +11,9 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rustc-link-arg=-Wl,-z,max-page-size=16384"); + // liblog, for the panic hook's __android_log_write. Android-only: the host + // test build has no such library. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") { + println!("cargo:rustc-link-lib=log"); + } } diff --git a/lsposed/native/src/lib.rs b/lsposed/native/src/lib.rs index 6633487d..0ab0f269 100644 --- a/lsposed/native/src/lib.rs +++ b/lsposed/native/src/lib.rs @@ -189,7 +189,7 @@ fn check_ioctl_siocgifflags() -> CheckOutput { let name = b"tun0\0"; ifr.ifr_name[..name.len()].copy_from_slice(&name.map(|b| b as libc::c_char)); - let ret = libc::ioctl(fd, libc::SIOCGIFFLAGS as _, &ifr); + let ret = libc::ioctl(fd, libc::SIOCGIFFLAGS as _, &mut ifr); let err = last_os_errno(); if ret < 0 { @@ -223,7 +223,7 @@ fn check_ioctl_siocgifmtu() -> CheckOutput { let name = b"tun0\0"; ifr.ifr_name[..name.len()].copy_from_slice(&name.map(|b| b as libc::c_char)); - let ret = libc::ioctl(fd, libc::SIOCGIFMTU as _, &ifr); + let ret = libc::ioctl(fd, libc::SIOCGIFMTU as _, &mut ifr); let err = last_os_errno(); if ret < 0 { @@ -373,8 +373,9 @@ fn check_setsockopt_bindtodevice() -> CheckOutput { /// /// Interface names are arbitrary bytes, so a `/proc/net` line can legitimately /// carry multi-byte UTF-8. Slicing straight at `max_bytes` panics when that byte -/// lands mid-character, and this crate builds with `panic = "abort"` — the app -/// would die running its own diagnostics. (Truncation itself is not an edge +/// lands mid-character. The JNI entry catches the unwind now, but a probe that +/// panics still loses the whole check run, and the truncation itself is not an +/// edge case. (Truncation itself is not an edge /// case: `/proc/net/route` lines routinely run past 80 bytes.) fn truncate_on_char_boundary(line: &str, max_bytes: usize) -> &str { let mut end = line.len().min(max_bytes); @@ -1233,14 +1234,74 @@ fn uid_routed_through_vpn(myuid: u32) -> (bool, String) { (routed, detail) } +/// Log tag for anything this crate reports. Listed in the app's `LogTags` so the +/// debug bundle's logcat filter picks it up. +#[cfg(target_os = "android")] +const LOG_TAG: &str = "VpnHide-Native"; + +#[cfg(target_os = "android")] +unsafe extern "C" { + fn __android_log_write( + prio: libc::c_int, + tag: *const libc::c_char, + text: *const libc::c_char, + ) -> libc::c_int; +} + +/// Send one line to logcat. Best effort: a message with an interior NUL is +/// dropped rather than truncated at a surprising place. +#[cfg(target_os = "android")] +fn log_error(message: &str) { + use std::ffi::CString; + + const ANDROID_LOG_ERROR: libc::c_int = 6; + let (Ok(tag), Ok(text)) = (CString::new(LOG_TAG), CString::new(message)) else { + return; + }; + // SAFETY: both pointers are NUL-terminated and outlive the call. + unsafe { + __android_log_write(ANDROID_LOG_ERROR, tag.as_ptr(), text.as_ptr()); + } +} + +/// Route panics to logcat. +/// +/// The default hook writes to stderr, which for an Android app process goes +/// nowhere — so a panic in here used to be invisible: the process just died (or, +/// since the switch to unwinding, surfaced as a Java exception with no location). +/// The hook runs under either panic strategy and carries what actually matters +/// for a bug report: the message and the `file:line` that raised it. Backtraces +/// are deliberately not attempted; this crate is built with fat LTO and stripped, +/// so they would be unsymbolised addresses. +#[cfg(target_os = "android")] +fn install_panic_hook() { + use std::sync::Once; + + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + log_error(&format!("panic in native probe: {info}")); + previous(info); + })); + }); +} + /// In-process (app-view) entry. Class/package must match the Kotlin /// `object dev.okhsunrog.vpnhide.checks.NativeProbe`. +/// +/// A panic here must not take the app down with it: the probes parse whatever +/// the kernel hands back on an arbitrary vendor build, and this runs on every +/// cold start. `with_env` catches the unwind (which is why the crate builds with +/// `panic = "unwind"`) and `ThrowRuntimeExAndDefault` turns it into a Java +/// exception the caller can report as a failed check run. #[cfg(target_os = "android")] #[unsafe(no_mangle)] pub extern "system" fn Java_dev_okhsunrog_vpnhide_checks_NativeProbe_runAllChecksJson<'local>( mut env: jni::EnvUnowned<'local>, _class: jni::objects::JClass<'local>, ) -> jni::objects::JString<'local> { + install_panic_hook(); env.with_env( |env| -> jni::errors::Result> { env.new_string(run_all_json()) diff --git a/zygisk/src/hooks.rs b/zygisk/src/hooks.rs index 6a8bfec3..2fdf1cf5 100644 --- a/zygisk/src/hooks.rs +++ b/zygisk/src/hooks.rs @@ -24,7 +24,7 @@ use core::cell::{Cell, RefCell}; use core::ffi::{CStr, c_int, c_void}; -use core::sync::atomic::{AtomicPtr, AtomicU8, AtomicU32, Ordering}; +use core::sync::atomic::{AtomicI32, AtomicPtr, AtomicU8, AtomicU32, Ordering}; use core::{mem, ptr, slice}; use libc::{SIOCGIFCONF, SIOCGIFNAME, ifreq}; @@ -232,6 +232,9 @@ const KERNEL_BIND_POLICY_UNKNOWN: u8 = 0; const KERNEL_BIND_POLICY_NATIVE_ONLY: u8 = 1; const KERNEL_BIND_POLICY_HOOK: u8 = 2; static KERNEL_BIND_POLICY: AtomicU8 = AtomicU8::new(KERNEL_BIND_POLICY_UNKNOWN); +/// The measured errno to deny a hidden bind with; meaningful once +/// [`KERNEL_BIND_POLICY`] says HOOK. +static KERNEL_BIND_ERRNO: AtomicI32 = AtomicI32::new(libc::ENODEV); fn parse_decimal_component(input: &[u8], cursor: &mut usize) -> Option { let start = *cursor; @@ -261,19 +264,72 @@ fn release_has_unprivileged_first_bind(release: &[u8]) -> bool { major > 5 || (major == 5 && minor >= 7) } -/// Linux before 5.7 rejected an unprivileged SO_BINDTODEVICE before reading the -/// interface name. Filtering there would create a name-dependent ENODEV/EPERM -/// difference and expose rather than hide VPN prefixes. Keep the hook inert on -/// those kernels; SO_BINDTOIFINDEX did not exist there either. -fn kernel_needs_socket_bind_hiding() -> bool { +/// What a bind to a hidden interface must return, given what this kernel says +/// about a name that does not exist. +/// +/// Hiding an interface means making it answer exactly like an absent one. Which +/// error that is depends on the order of the kernel's own checks, and that order +/// differs between trees: upstream 5.4 tests `CAP_NET_RAW` before it even looks +/// at the name, so *every* bind — existing or not — fails with EPERM there; +/// 5.7+ resolves the name first, so an absent one fails with ENODEV. Mirroring +/// the measured answer is what keeps the reply from being an oracle: if a +/// non-existent name gives EPERM, answering ENODEV for `tun0` alone would +/// announce that `tun0` exists. +/// +/// `probe` is the errno an unprivileged bind to a made-up name produced here, or +/// `None` if we could not measure it. `release_allows_first_bind` is the old +/// version heuristic, kept as the fallback for that case so behaviour is never +/// worse than before the probe existed. +fn hidden_bind_errno(probe: Option, release_allows_first_bind: bool) -> Option { + match probe { + // A made-up name binding successfully means the probe told us nothing + // usable (or the name existed after all) — fall back. + Some(0) | None => release_allows_first_bind.then_some(libc::ENODEV), + Some(errno) => Some(errno), + } +} + +/// Ask the kernel what an unprivileged bind to a name that cannot exist returns. +/// +/// One socket and one setsockopt, on our own fd, through the real libc entry — +/// the hook is not consulted, so there is no recursion. Cached for the life of +/// the process: the answer is a property of the kernel, not of the call. +fn probe_absent_iface_errno() -> Option { + // Deliberately not VPN-shaped, so a future matcher change cannot make the + // probe name itself interesting, and unlikely to the point of impossibility + // as a real interface. + const ABSENT: &[u8] = b"zzvpnhideprobe\0"; + + let real = real_setsockopt()?; + let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) }; + if fd < 0 { + return None; + } + let rc = unsafe { + real( + fd, + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + ABSENT.as_ptr().cast(), + ABSENT.len() as libc::socklen_t, + ) + }; + let errno = if rc == 0 { 0 } else { get_errno() }; + unsafe { libc::close(fd) }; + Some(errno) +} + +/// The errno this process denies hidden binds with, or `None` to stay out of the +/// way entirely. Measured once, then cached in [`KERNEL_BIND_POLICY`]. +fn socket_bind_denial_errno() -> Option { match KERNEL_BIND_POLICY.load(Ordering::Relaxed) { - KERNEL_BIND_POLICY_NATIVE_ONLY => return false, - KERNEL_BIND_POLICY_HOOK => return true, + KERNEL_BIND_POLICY_NATIVE_ONLY => return None, + KERNEL_BIND_POLICY_HOOK => return Some(KERNEL_BIND_ERRNO.load(Ordering::Relaxed)), _ => {} } - let mut uts = mem::MaybeUninit::::zeroed(); - let hook = unsafe { + let release_allows_first_bind = unsafe { + let mut uts = mem::MaybeUninit::::zeroed(); if libc::uname(uts.as_mut_ptr()) != 0 { false } else { @@ -287,15 +343,19 @@ fn kernel_needs_socket_bind_hiding() -> bool { release_has_unprivileged_first_bind(&release[..end]) } }; + let decision = hidden_bind_errno(probe_absent_iface_errno(), release_allows_first_bind); + if let Some(errno) = decision { + KERNEL_BIND_ERRNO.store(errno, Ordering::Relaxed); + } KERNEL_BIND_POLICY.store( - if hook { + if decision.is_some() { KERNEL_BIND_POLICY_HOOK } else { KERNEL_BIND_POLICY_NATIVE_ONLY }, Ordering::Relaxed, ); - hook + decision } /// Copy caller memory without dereferencing its pointer in-process. @@ -414,11 +474,15 @@ pub unsafe extern "C" fn hooked_setsockopt( return unsafe { real(fd, level, optname, optval, optlen) }; } - if (optname == libc::SO_BINDTODEVICE || optname == SO_BINDTOIFINDEX) - && (!kernel_needs_socket_bind_hiding() || !is_socket_fd(fd)) - { - return unsafe { real(fd, level, optname, optval, optlen) }; - } + let denial_errno = if optname == libc::SO_BINDTODEVICE || optname == SO_BINDTOIFINDEX { + match socket_bind_denial_errno() { + Some(errno) if is_socket_fd(fd) => errno, + // Nothing to mirror (or not a socket): stay out of the way. + _ => return unsafe { real(fd, level, optname, optval, optlen) }, + } + } else { + libc::ENODEV + }; if optname == libc::SO_BINDTODEVICE { // The kernel takes a signed length internally. Preserve its native @@ -435,7 +499,9 @@ pub unsafe extern "C" fn hooked_setsockopt( return unsafe { real(fd, level, optname, optval, optlen) }; } if is_vpn_iface_bytes(&name) { - set_errno(libc::ENODEV); + // The measured "no such interface" answer, so a hidden name is + // indistinguishable from one that never existed on this kernel. + set_errno(denial_errno); return -1; } @@ -466,7 +532,7 @@ pub unsafe extern "C" fn hooked_setsockopt( } let ifindex = c_int::from_ne_bytes(raw); if deny_ifindex_bind(ifindex, ifindex_is_vpn(ifindex)) { - set_errno(libc::ENODEV); + set_errno(denial_errno); return -1; } @@ -552,12 +618,17 @@ pub unsafe extern "C" fn hooked_ioctl( // All other SIOCGIF* ioctls (FLAGS, MTU, INDEX, HWADDR, ADDR, etc.) // take an ifreq with the interface name as input. Pre-screen it. - if !arg.is_null() && is_siocgif(request) { - let req = unsafe { &*(arg as *const ifreq) }; - let name_bytes = unsafe { - slice::from_raw_parts(req.ifr_name.as_ptr().cast::(), req.ifr_name.len()) - }; - if is_vpn_iface_bytes(name_bytes) { + // + // The name is read through a fault-contained self-read, never by + // dereferencing `arg`: this branch inspects the caller's buffer BEFORE the + // kernel has validated it, so a target app passing a bad or short pointer + // (the case the real ioctl answers with EFAULT) would otherwise take a + // SIGSEGV inside its own process. Same reasoning as the setsockopt hook. + // A non-socket fd cannot carry this family at all, so it is passed straight + // through rather than paying for a read. + if !arg.is_null() && is_siocgif(request) && is_socket_fd(fd) { + let mut name_bytes = [0u8; libc::IFNAMSIZ]; + if copy_from_self(arg, &mut name_bytes) && is_vpn_iface_bytes(&name_bytes) { set_errno(libc::ENODEV); return -1; } @@ -1581,9 +1652,9 @@ mod setsockopt_tests { use core::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; use super::{ - KERNEL_BIND_POLICY, KERNEL_BIND_POLICY_HOOK, SO_BINDTOIFINDEX, copy_from_self, - deny_ifindex_bind, hooked_setsockopt, release_has_unprivileged_first_bind, set_errno, - set_real_setsockopt_ptr, + KERNEL_BIND_ERRNO, KERNEL_BIND_POLICY, KERNEL_BIND_POLICY_HOOK, SO_BINDTOIFINDEX, + copy_from_self, deny_ifindex_bind, hidden_bind_errno, hooked_setsockopt, + release_has_unprivileged_first_bind, set_errno, set_real_setsockopt_ptr, }; static REAL_CALLS: AtomicUsize = AtomicUsize::new(0); @@ -1618,6 +1689,41 @@ mod setsockopt_tests { )); } + /// Hiding an interface means answering exactly like an absent one, and which + /// errno that is depends on the kernel's own check order — EPERM on trees + /// that test CAP_NET_RAW before parsing the name, ENODEV on trees that + /// resolve first. Mirroring the measured answer is what stops the reply + /// being an oracle. + #[test] + fn hidden_bind_mirrors_the_kernels_answer_for_an_absent_name() { + // 5.7+ style: an absent name is ENODEV, so a hidden one is too. + assert_eq!( + hidden_bind_errno(Some(libc::ENODEV), true), + Some(libc::ENODEV) + ); + // Gated tree: every bind is EPERM, so denying with ENODEV would single + // the VPN out. Mirror EPERM instead. + assert_eq!( + hidden_bind_errno(Some(libc::EPERM), false), + Some(libc::EPERM) + ); + // Any other refusal is mirrored verbatim rather than reinterpreted. + assert_eq!( + hidden_bind_errno(Some(libc::EINVAL), true), + Some(libc::EINVAL) + ); + } + + #[test] + fn an_unusable_probe_falls_back_to_the_release_heuristic() { + // Probe failed: behave exactly as before it existed. + assert_eq!(hidden_bind_errno(None, true), Some(libc::ENODEV)); + assert_eq!(hidden_bind_errno(None, false), None); + // An absent name that binds successfully tells us nothing usable. + assert_eq!(hidden_bind_errno(Some(0), true), Some(libc::ENODEV)); + assert_eq!(hidden_bind_errno(Some(0), false), None); + } + #[test] fn ifindex_decision_denies_vpn_and_resolution_races_only() { assert!(deny_ifindex_bind(42, Some(true))); @@ -1663,6 +1769,28 @@ mod setsockopt_tests { ); assert_eq!(REAL_CALLS.load(Ordering::Relaxed), 0); + // On a tree where every bind is refused for lack of CAP_NET_RAW, the + // denial has to mirror that refusal — answering ENODEV only for the VPN + // name would announce it. Same call, different measured kernel. + KERNEL_BIND_ERRNO.store(libc::EPERM, Ordering::Relaxed); + set_errno(0); + let rc = unsafe { + hooked_setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + vpn.as_ptr().cast(), + vpn.len() as libc::socklen_t, + ) + }; + assert_eq!(rc, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ); + assert_eq!(REAL_CALLS.load(Ordering::Relaxed), 0); + KERNEL_BIND_ERRNO.store(libc::ENODEV, Ordering::Relaxed); + let physical = *b"eth0"; let rc = unsafe { hooked_setsockopt(