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 внутри приложения.
Original file line number Diff line number Diff line change
@@ -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-бэкенд теперь скрывает привязку к интерфейсу на ядрах, где раньше молчал. Решение принималось по версии ядра, исходя из того, что старые ядра сами отвергают привязку без прав; там, где это не так, интерфейс оставался доступным. Теперь бэкенд измеряет, что работающее ядро отвечает на несуществующий интерфейс, и отдаёт скрытому ровно такой же ответ.
46 changes: 35 additions & 11 deletions docs/detection-vectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,24 +240,48 @@ 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. 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
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
6 changes: 6 additions & 0 deletions kmod/test/bind-probe.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
22 changes: 20 additions & 2 deletions kmod/test/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions kmod/test/run-kpm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading