From b21b126e3d2ffce281ba9ad41bbc3c1d9b6bbe82 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Mon, 24 Aug 2026 18:10:59 +0300 Subject: [PATCH 1/6] fix(kpm): hook the bind helper by symbol instead of by kernel version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sliva_ru reported `setsockopt SO_BINDTODEVICE tun0` leaking on a self-built LineageOS sm8350 (5.4.302-qgki) while the KPM was fully healthy — every hook installed, error 0x0. The vector was never covered there: the resolved-ifindex hook was wired only for kernels in [5.7, 5.9), and below that we deliberately relied on the kernel refusing an unprivileged bind without CAP_NET_RAW. That assumption came from upstream sources and the QEMU reference images, and it does not survive contact with vendor trees. His kernel has the gate compiled in — `sock_bindtoindex_locked` calls `ns_capable`, verified by disassembling the vmlinux rebuilt from his boot image — and an untrusted app still bound a socket to tun0. Whatever satisfies the check on that ROM, a kernel-side policy we do not control cannot back a coverage claim. - probe both helper names by symbol on any kernel below 5.9: upstream renamed sock_setbindtodevice_locked to sock_bindtoindex_locked in 5.8, and 5.4 vendor trees backport the newer one (his does) - when neither resolves, leave the hook bit clear rather than reporting a vector we do not cover - the partial-hooks warning now fires only when a missing hook costs a measured vector, so kernels that never had the symbol — and close the surface by capability or SELinux anyway — stay quiet - correct the invariant in detection-vectors.md and at the guard in socket_bind_before_common, with the counterexample, so it does not get reintroduced. The zygisk hook still carries the same version gate; its oracle argument holds only while every bind is refused, noted in the doc as wanting a runtime probe. --- ...e-bind-interface-vector-is-covered-dc2b.md | 9 +++ docs/detection-vectors.md | 36 +++++++---- kmod/kpm/vpnhide_kpm.c | 63 +++++++++++++------ .../dev/okhsunrog/vpnhide/DashboardData.kt | 35 +++++++---- .../okhsunrog/vpnhide/NativeBackendData.kt | 16 ++++- .../app/src/main/res/values-ru/strings.xml | 2 +- .../src/main/res/values-zh-rCN/strings.xml | 2 +- lsposed/app/src/main/res/values/strings.xml | 2 +- .../okhsunrog/vpnhide/PartialHookGapTest.kt | 45 +++++++++++++ 9 files changed, 162 insertions(+), 48 deletions(-) create mode 100644 changelog.d/fixed-the-bind-interface-vector-is-covered-dc2b.md 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/docs/detection-vectors.md b/docs/detection-vectors.md index 6b8ee5ed..fb97819f 100644 --- a/docs/detection-vectors.md +++ b/docs/detection-vectors.md @@ -240,14 +240,23 @@ 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. + +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 +264,14 @@ 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. On pre-5.7 kernels it stays inert because the kernel is expected to +reject an unprivileged bind before inspecting the name; returning a +name-dependent error there would create a new oracle. Note the asymmetry with +the kernel backends after the LineageOS 5.4 finding above: where a bind +actually succeeds, staying inert leaks. The oracle argument only holds while +every bind is refused, so this gate wants to become a runtime probe (does an +unprivileged bind to a physical interface succeed here?) rather than a version +comparison. 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..66c9e0b3 100644 --- a/kmod/kpm/vpnhide_kpm.c +++ b/kmod/kpm/vpnhide_kpm.c @@ -723,18 +723,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) { @@ -840,10 +849,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; @@ -2008,14 +2020,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/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/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/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/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 From 6646bf1d0626f26bac21a74c07fee1dddd517b0e Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Mon, 24 Aug 2026 18:13:42 +0300 Subject: [PATCH 2/6] fix(native): a panicking probe should not kill the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two devices report the app dying at startup whenever a VPN interface is up, with nothing in the app's own logs. The probe crate built with `panic = "abort"`, and the full check suite runs on every cold start, so any panic in a probe — parsing whatever an arbitrary vendor kernel hands back — took the process down with SIGABRT and no Java trace. The JNI entry already wrapped the run in `catch_unwind` (via jni's `with_env` + ThrowRuntimeExAndDefault); abort made that dead code. - release and dev profiles unwind. Costs ~39 KB of unwind tables on arm64, the only ABI we ship - a panic hook logs the message and its file:line to logcat under VpnHide-Native, which the bundle's filter now captures. The default hook writes to stderr, which for an app process goes nowhere — this is why such a crash leaves no trace. Backtraces are deliberately not attempted: fat LTO + strip would make them unsymbolised addresses - the Kotlin caller swallows a thrown probe run, so the worst case is one empty check run instead of a dead app - pass the SIOCGIFFLAGS/SIOCGIFMTU ifreq by unique reference. The kernel writes the result through that pointer, so deriving it from a shared borrow is UB — and it is the tun0-visible branch that reads the value back, exactly the state these reports are about --- ...d-a-failing-native-probe-no-longer-5734.md | 9 +++ .../kotlin/dev/okhsunrog/vpnhide/LogTags.kt | 5 ++ .../okhsunrog/vpnhide/checks/NativeProbe.kt | 16 ++++- lsposed/native/Cargo.toml | 10 ++- lsposed/native/build.rs | 5 ++ lsposed/native/src/lib.rs | 69 +++++++++++++++++-- 6 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixed-a-failing-native-probe-no-longer-5734.md 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/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/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/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()) From 528bb16dae18336b2b635df9b9b83122e6826f62 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Mon, 24 Aug 2026 18:14:03 +0300 Subject: [PATCH 3/6] fix(zygisk): read the ioctl interface name through the fault-contained path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SIOCGIF* pre-screen dereferenced the caller's `arg` before the real ioctl had a chance to validate it. A target app passing a bad or short pointer — the case the kernel answers with EFAULT — instead took a SIGSEGV inside its own process, caused by our hook. The setsockopt hook was written to avoid exactly this (copy_from_self, with the reasoning in its doc comment); the ioctl path was the inconsistent one. Reads the 16-byte name through copy_from_self and passes a non-socket fd straight through, since this ioctl family cannot apply to one. Fault containment itself is already covered by self_copy_contains_bad_caller_pointers. Found by a review pass over the project's unsafe code. --- ...d-the-zygisk-backend-no-longer-risks-f812.md | 9 +++++++++ zygisk/src/hooks.rs | 17 +++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixed-the-zygisk-backend-no-longer-risks-f812.md 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/zygisk/src/hooks.rs b/zygisk/src/hooks.rs index 6a8bfec3..fb3d79aa 100644 --- a/zygisk/src/hooks.rs +++ b/zygisk/src/hooks.rs @@ -552,12 +552,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; } From 965516218b8e8f6620e1fd1a537567ce75838d97 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Mon, 24 Aug 2026 18:58:55 +0300 Subject: [PATCH 4/6] fix(storage): stop settings writes from rebuilding the app list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `writeSuperkeySetting` and `writeFilesystemHidingSetting` each change one field in `settings`, but took their base from `buildCanonicalConfigFromTargetsSnapshot` — rebuilding every app's roles from the snapshot's per-role sets and writing that back. One of those sets round-tripped through UIDs: `appHiding` was stored as resolved UIDs and mapped back through `pm list packages`, so a target the inventory could not see (a profile the scan failed to read — the case #293 added diagnostics for) disappeared from the projection and lost its role on disk. A toggle unrelated to the app list silently unconfigured an app. - both writers take `snapshot.canonicalConfig` and copy the one field - TargetsSnapshot no longer stores the five per-role sets beside the config they came from. They are projections of it now, so the object cannot carry two versions of one truth, and `observerNames` no longer passes through UIDs at all. `observerUids` stays for the consumers that need the wire's language, derived on demand - regression test: an app-hiding target absent from the inventory keeps its role Not reproduced on a device — the chain is read off the code, and the missing-from-inventory precondition is one users have hit. --- ...changing-a-setting-no-longer-risks-6699.md | 9 ++++ .../vpnhide/FilesystemHidingSettings.kt | 4 +- .../dev/okhsunrog/vpnhide/SettingsScreen.kt | 8 +-- .../dev/okhsunrog/vpnhide/TargetsCache.kt | 49 ++++++++++--------- .../okhsunrog/vpnhide/AppPickerDataTest.kt | 5 -- .../okhsunrog/vpnhide/StorageConfigTest.kt | 17 ++++--- .../dev/okhsunrog/vpnhide/TargetsCacheTest.kt | 38 ++++++++++++++ 7 files changed, 91 insertions(+), 39 deletions(-) create mode 100644 changelog.d/fixed-changing-a-setting-no-longer-risks-6699.md 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/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/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/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/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 = From fdde1d073b43b91d4f3389737b593d01fdde573b Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 25 Aug 2026 12:57:32 +0300 Subject: [PATCH 5/6] fix(kpm): deny a hidden bind only where the caller could have bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index-helper hook runs before that helper's own CAP_NET_RAW check, so it answered ENODEV even for callers the kernel was about to refuse with EPERM. On a tree where the check bites, a VPN name then reads differently from every other name — the exact oracle the Zygisk hook refuses to create — and widening the hook to all kernels below 5.9 in the previous commit widened that to the 4.x families where the check really does bite. Ask capable(CAP_NET_RAW) first and stay out of the way when it fails: the kernel refuses those callers itself, identically for every interface. Deny only the ones that would otherwise have bound, which is the case the LineageOS 5.4 report is about. capable() needs no struct offsets, so it cannot go stale on a vendor kernel the way an offset table can; a kernel where it will not resolve keeps the previous unconditional denial. --- docs/detection-vectors.md | 7 +++++++ kmod/kpm/vpnhide_kpm.c | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docs/detection-vectors.md b/docs/detection-vectors.md index fb97819f..86151cd0 100644 --- a/docs/detection-vectors.md +++ b/docs/detection-vectors.md @@ -250,6 +250,13 @@ 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 diff --git a/kmod/kpm/vpnhide_kpm.c b/kmod/kpm/vpnhide_kpm.c index 66c9e0b3..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) @@ -826,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); } @@ -1785,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"); From ba6311e82c42d41fb0708c3d450e6c007c728a1f Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Tue, 25 Aug 2026 11:46:17 +0300 Subject: [PATCH 6/6] fix(zygisk): mirror the kernel's answer for an absent interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bind hook decided whether to act by comparing the release string: below 5.7 it stayed inert, on the assumption that the kernel refuses an unprivileged bind before it even parses the name, so a name-specific ENODEV from us would announce the interface instead of hiding it. The oracle argument is sound; the version test is not the way to ask it. A LineageOS 5.4 build (the KPM report in the previous commit) lets an app bind to tun0, and there the same gate means we simply do not hide. Replaced with the question that actually decides it: what does this kernel return for a bind to a name that cannot exist? Denying a hidden interface with exactly that errno is oracle-free by construction — EPERM where every bind is refused (indistinguishable, as today), ENODEV where names resolve first (hidden, as on 5.7+). - one socket + one setsockopt through the real libc entry, cached for the process; an unusable measurement falls back to the old heuristic, so behaviour is never worse than before - hidden_bind_errno is pure and unit-tested over the decision table, and the hook test now covers the EPERM-mirroring path end to end - bind-probe gains a bind_absent_name case, so the QEMU lanes record what each supported kernel family answers instead of us assuming it --- ...zygisk-backend-now-hides-interface-fb22.md | 9 + docs/detection-vectors.md | 19 +- kmod/test/bind-probe.c | 6 + kmod/test/init.sh | 22 ++- kmod/test/run-kpm.sh | 15 ++ zygisk/src/hooks.rs | 167 +++++++++++++++--- 6 files changed, 206 insertions(+), 32 deletions(-) create mode 100644 changelog.d/fixed-the-zygisk-backend-now-hides-interface-fb22.md 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 86151cd0..1e1a387d 100644 --- a/docs/detection-vectors.md +++ b/docs/detection-vectors.md @@ -271,14 +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 is expected to -reject an unprivileged bind before inspecting the name; returning a -name-dependent error there would create a new oracle. Note the asymmetry with -the kernel backends after the LineageOS 5.4 finding above: where a bind -actually succeeds, staying inert leaks. The oracle argument only holds while -every bind is refused, so this gate wants to become a runtime probe (does an -unprivileged bind to a physical interface succeed here?) rather than a version -comparison. +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/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/zygisk/src/hooks.rs b/zygisk/src/hooks.rs index fb3d79aa..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; } @@ -1586,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); @@ -1623,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))); @@ -1668,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(