Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/fixed-a-failing-native-probe-no-longer-5734.md
Original file line number Diff line number Diff line change
@@ -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), а не к падению приложения при запуске.
9 changes: 9 additions & 0 deletions changelog.d/fixed-changing-a-setting-no-longer-risks-6699.md
Original file line number Diff line number Diff line change
@@ -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 проходила через список установленных приложений, — и приложение из профиля, который не удалось прочитать, теряло роль при сохранении.
Original file line number Diff line number Diff line change
@@ -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-интерфейсу. Теперь нужная функция ищется по имени в символах ядра, а если её нет вовсе, бэкенд больше не отчитывается о защите этого вектора.
9 changes: 9 additions & 0 deletions changelog.d/fixed-the-zygisk-backend-no-longer-risks-f812.md
Original file line number Diff line number Diff line change
@@ -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 внутри приложения.
43 changes: 32 additions & 11 deletions docs/detection-vectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,24 +240,45 @@ 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
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
Expand Down
79 changes: 59 additions & 20 deletions kmod/kpm/vpnhide_kpm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 *);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 22 additions & 13 deletions lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DashboardData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1784,6 +1774,7 @@ internal suspend fun loadDashboardState(
complete = true,
installedOptionalHooks = installedOptionalHooks,
)
measuredReport = report
ProtectionCheck.Checked(report.native.status, report.java.status)
}

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
5 changes: 5 additions & 0 deletions lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/LogTags.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -43,6 +47,7 @@ internal object LogTags {
STATISTICS,
APP_LIST,
DEBUG_CONFIG,
NATIVE,
)

/** Native-backend logcat tags (kmod / ports / zygisk / shadowhook). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,21 @@ internal data class PartialHookGap(
val installed: Int,
val expected: Int,
val missing: List<HookIds.Hook>,
)
) {
/**
* 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading