Skip to content

Cover the bind vector below kernel 5.9, and stop a probe panic from killing the app - #310

Merged
okhsunrog merged 5 commits into
mainfrom
fix/bind-hook-and-native-panics
Aug 25, 2026
Merged

Cover the bind vector below kernel 5.9, and stop a probe panic from killing the app#310
okhsunrog merged 5 commits into
mainfrom
fix/bind-hook-and-native-panics

Conversation

@okhsunrog

Copy link
Copy Markdown
Owner

Four fixes off two bug reports and a review pass over the project's unsafe Rust.

@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 relied on the kernel refusing an unprivileged bind without CAP_NET_RAW. That assumption came from upstream sources and the QEMU reference images. 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. A kernel-side policy we do not control cannot back a coverage claim.

He also has a second device where the app dies at startup whenever a VPN is up, with nothing in its logs. No crash log yet, but the probe crate built with panic = "abort" and runs its whole check suite on every cold start, so any panic parsing an unexpected kernel reply took the process down silently.

  • KPM probes both bind-helper names by symbol on any kernel below 5.9 (upstream renamed sock_setbindtodevice_locked to sock_bindtoindex_locked in 5.8; vendor 5.4 trees backport the newer one). A tree exporting neither leaves the hook bit clear instead of claiming a vector it does not cover
  • the partial-hooks warning fires only when a missing hook costs a measured vector, so kernels that never had the symbol stay quiet
  • the probe crate unwinds instead of aborting (~39 KB on arm64, the only ABI we ship), which activates the catch_unwind its JNI entry already had; a panic hook logs the message and file:line to logcat under VpnHide-Native, and the Kotlin caller swallows a thrown run
  • SIOCGIFFLAGS/SIOCGIFMTU pass their ifreq by unique reference — the kernel writes the result through that pointer, and it is the tun0-visible branch that reads it back
  • zygisk's SIOCGIF* pre-screen reads the interface name through the fault-contained path instead of dereferencing the caller's arg before the kernel validates it; a target app passing a bad pointer used to take a SIGSEGV from our hook where the kernel would have returned EFAULT
  • settings writes (SuperKey, experimental protection) take the stored config instead of rebuilding every app's roles from the snapshot's projections. appHiding round-tripped through UIDs, so a target the package inventory could not see lost its role on save — TargetsSnapshot now projects the per-role sets from the config rather than storing them beside it

The bind fix is not yet confirmed on a device; @sliva_ru has the details. The docs correction and the guard comment carry the counterexample so the invariant does not come back. Zygisk still carries the same version gate — its oracle argument holds only while every bind is refused, noted in detection-vectors.md as wanting a runtime probe.

@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.
@okhsunrog
okhsunrog merged commit 98fe9c7 into main Aug 25, 2026
35 checks passed
@okhsunrog
okhsunrog deleted the fix/bind-hook-and-native-panics branch August 25, 2026 10:21
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