Skip to content

Zygisk: mirror the kernel's answer for an absent interface - #311

Merged
okhsunrog merged 6 commits into
mainfrom
fix/zygisk-bind-probe
Aug 25, 2026
Merged

Zygisk: mirror the kernel's answer for an absent interface#311
okhsunrog merged 6 commits into
mainfrom
fix/zygisk-bind-probe

Conversation

@okhsunrog

Copy link
Copy Markdown
Owner

Stacked on #310 — review that first; this adds one commit on top.

The zygisk 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. The LineageOS 5.4 build behind #310 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 and one setsockopt through the real libc entry, cached for the process; an unusable measurement falls back to the old release heuristic, so behaviour is never worse than before the probe existed
  • hidden_bind_errno is pure and unit-tested over the decision table, and the existing hook test now also 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 actually answers (4.9 / 4.14 / 4.19 / 5.4 through kpm-qemu-legacy, GKI through kmod-qemu) instead of the code assuming it

No device testing was needed for this: the kernel-side question is measured by the existing QEMU rig, and the hook's decision is a pure function with host tests. The thin probe is two syscalls whose failure mode is the old behaviour.

@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.
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 path

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.
`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.
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.
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
@okhsunrog
okhsunrog force-pushed the fix/zygisk-bind-probe branch from 13e6139 to ba6311e Compare August 25, 2026 09:57
@okhsunrog
okhsunrog merged commit d8f6542 into main Aug 25, 2026
35 checks passed
@okhsunrog
okhsunrog deleted the fix/zygisk-bind-probe branch August 25, 2026 10:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant