release.py patched kmod/zygisk/ports module.prop but not the KPM one
(kmod/kpm/module/module.prop), which was added after the script was
written — so the KPM module shipped with a stale versionCode=701. Add it
to the patched files and set it to v1.0.0/10000 for this release.
customize.sh warned about the single-active conflict whenever the vpnhide_kmod
module directory merely existed, and told the user to *uninstall* it. But the
boot loader (post-fs-data.sh / service.sh) only refuses to load the KPM when
vpnhide_kmod is present AND not disabled — a disabled .ko lets the KPM load.
Make the install check mirror that runtime guard (present AND no `disable`
flag), and reword the message to say disabling is sufficient. Verified on a
Pixel 8 Pro: with vpnhide_kmod disabled (not uninstalled), the KPM loads and
configures, and the .ko is not in lsmod.
* feat(kpm): extend the public host-route concealment to the legacy kernels
I had disabled the host-route check on the non-GKI kernels (4.14/4.19/5.4) on the
mistaken belief they weren't QEMU-tested — but kpm-qemu-legacy covers exactly
those, and they ARE the kernels the KPM exists to serve. Enable it properly:
- IPv4 now works on every supported kernel. On <5.6 (5.4/4.19/4.14) fib_dump_info
passes the __be32 dst and dst_len as args 6/7 instead of a fib_rt_info*, so
kpm_is_public_host_route4 reads them from the right place per ABI. (The __be32
value in the arg register stores to network-order bytes, so the same
vpnhide_is_public_ipv4 applies.)
- IPv6 now works on 5.4 and 4.19 too: their fib6_info head matches 5.10, so
fib6_dst is at +64 (derived from the AOSP/vanilla sources, QEMU-validated on
the legacy images). 4.14 stays disabled — it uses rt6_info, whose
cacheline-aligned rt6i_dst has no hand-derivable offset.
- Harness: test hostroute4 on every kernel and hostroute6 on every kernel except
4.14 (init-kpm.sh gate by uname -r).
Validated A/B in QEMU locally on 5.4, 4.19, 4.14 (legacy images, cortex-a57) and
re-checked 5.10 GKI for no regression on the refactored 5.6+ path.
* feat(kpm): also hide the public IPv6 host-route on 4.14 (rt6_info path)
Completes host-route coverage across every supported kernel. 4.14 predates
fib6_info — rt6_fill_node takes a struct rt6_info*, and the route's destination
is rt6_info.rt6i_dst (a rt6key), not fib6_info.fib6_dst. Add a per-kver rt6_dst
offset and read the rt6key there on the rt6_via_dst path.
The offset is derivable, not guessed: struct dst_entry is 160 bytes (its __pads
keep the size constant regardless of CONFIG_XFRM/CLASSID), the rt6_info prefix
runs to +220, and rt6i_dst is ____cacheline_aligned_in_smp, so it rounds up to
+256 (same for a 64- or 128-byte cacheline). QEMU-validated A/B on the legacy
4.14 image (hostroute6 now hidden for the target, 18/18, no panic), and the
harness host-route gate is dropped — every KPM kver now covers v4 + v6.
* docs(roadmap): record the 32-bit compat SIOCGIFCONF gap as low-priority
* docs(changelog): note the legacy-kernel KPM host-route coverage
* fix(ports): block the whole loopback range; warn on native-target overflow
Two activator findings from the codebase audit.
The localhost port blocker only rejected 127.0.0.1, so a proxy/VPN daemon bound
to the wildcard 0.0.0.0 (the common allow-lan / TUN setup for Clash, sing-box,
V2Ray) stayed reachable on any other 127.x.x.x alias — an observer could
connect(127.0.0.2:port) and get a handshake a clean device would refuse, a
positive fingerprint. Reject the whole 127.0.0.0/8 block instead (::1 already is
the entire IPv6 loopback).
The native-target cap (64) was restated as a bare literal in three places (the
activator and both C backends) and projection truncated the overflow with a
silent .take(), dropping the highest-UID apps from protection with no diagnostic.
Export the cap once from the shared protocol crate, alias it in the activator,
cross-reference it from the C #defines, and warn on stderr when the target set
exceeds it.
* style: rustfmt activator loopback comment
* docs(changelog): note the native-target overflow warning
* fix(kmod): bound KPM ctl0 reply and reject overflowing version
- KPM ctl0 stats/status: vpnhide_format_* report the full intended length,
which can exceed the 4096-byte stack buffer; clamp to sizeof(buf) before
clamp_to_line/copy_to_user so a generous outlen can't drive an out-of-bounds
read of the kernel stack (info leak / panic). The .ko already bounds this way.
- vpnhide_tok_decimal: reject values that overflow unsigned long instead of
wrapping mod 2^64 and slipping past the version fuse — same contract as the
hex token parser.
Host shared-logic and protocol-vector tests pass.
* fix(kpm): hide the public IPv4 host-route; share the predicates with the .ko
The KPM route hooks only hid a route when its output device was itself a VPN
interface, missing the public /32 host-route a VPN client pins to the physical
uplink to reach the server — the .ko hides it, the KPM did not, breaking the
backend-parity contract and leaking the VPN server's address through a
routing-table dump even with the tun interface hidden.
Rather than duplicate the .ko's predicates into the KPM, lift the pure
address/iface logic into shared/vpnhide_logic.h — vpnhide_is_public_ipv4
(network-order bytes), vpnhide_is_public_ipv6, and vpnhide_iface_is_physical
(self-contained case-insensitive prefix match) — so BOTH backends share one
implementation, the way the rest of the filtering logic already is. The .ko and
the KPM now call the shared predicates; each keeps only its kernel-struct read.
Host unit tests cover the predicates (test_vpnhide_logic.c), and the QEMU
harness gains a public host-route vector on BOTH backends (init.sh + init-kpm.sh
/ run-kpm.sh).
IPv4 needs no per-kver offset: fib_rt_info.dst/dst_len sit at a constant +12/+16
on every 5.6+ kernel (verified against the GKI sources), and the fri is
stack-resident so the reads are in-bounds. Validated A/B in QEMU — the .ko on
android12-5.10 (freshly DDK-built) and the KPM on android12-5.10 + android16-6.12
(hostroute4 hidden for the target, 17/17, no panic).
The IPv6 /128 analogue in the KPM is left a documented TODO: it needs the
per-kver fib6_info.fib6_dst offset, which varies. The .ko already hides it and
now also uses the shared vpnhide_is_public_ipv6.
* feat(kpm): also hide the public IPv6 host-route via a physical uplink
Completes the host-route parity with the .ko. The KPM now hides a public /128
host-route pinned to a physical uplink (the IPv6 analogue of the /32 just added),
the way the .ko's is_public_host_route6_via_physical does, using the shared
vpnhide_is_public_ipv6 / vpnhide_iface_is_physical predicates.
Unlike IPv4, this needs a per-kver offset: fib6_info.fib6_dst (rt6key { addr@0;
int plen@16 }) sits at +64 on 5.10/5.15/6.1/6.6 and +80 on 6.12 (the new
gc_link), derived from the GKI sources and the existing fib6_info offsets in the
table. A new fib6_info_fib6_dst field carries it; it is 0 (disabled) on the
pre-fib6_info 4.14 rt6_info path and on the non-GKI 5.4/4.19 kernels the QEMU
matrix can't validate — a wrong offset would fault, and the table's doctrine is
proven-not-guessed.
Validated A/B in QEMU on every distinct GKI offset struct — KPM on
android12-5.10, android13-5.15, android14-6.1, android16-6.12 and the .ko on
android12-5.10 (new hostroute6 vector hidden for the target, 18/18, no panic).
android15-6.6 shares the 6.1 struct and is covered by CI's per-KMI matrix.
* fix(kpm): publish config via a lock-free double-buffer, not in place
vpnhide_kpm_ctl0 parsed a CONFIG payload straight into the live targets[] array
and only then updated nr_targets, while every hooked syscall reads that same
array lock-free via hook_active() on other CPUs. A hook firing mid-update could
read a torn target (new uid with stale hookmask, or a half-overwritten entry
against the old, larger count) — momentarily filtering the wrong app: a target's
VPN briefly visible, or a non-target briefly hidden, on every config replace. The
.ko avoids this by parsing into a staging buffer and publishing under a spinlock;
the KPM had dropped both.
KP has no kernel spinlock/RCU, so mirror it lock-free: hold the config in a
two-element double-buffer, parse each update into a stack-local staging set, fill
the inactive buffer, and publish it with a single release-store of the active
index. hook_active() acquire-loads that index once and reads only that snapshot,
so the mask gate and the per-uid scan are always from one consistent config —
never a mix. Writers are the serialized control path (ctl0 / init load-args), so
they never pick the same inactive buffer.
Validated in QEMU on android12-5.10 (kver-independent — no offsets involved):
all hide/keep vectors still pass (18/18, no panic), exercising the rewritten
hook_active hot path.
* refactor(kmod): share the seq compactor and add a lock-free hook_active gate
Two .ko changes for parity with the KPM, no behavior change.
#29: fib_route_ret / ipv6_route_ret hand-rolled the /proc/net/route line
compaction that shared/vpnhide_logic.h already provides as
vpnhide_compact_seq_lines — the exact function the KPM calls for the same hooks.
The header even documents the inline copies as "intentionally byte-identical" to
the shared one. Replace both with the shared call (FIELD_FIRST / FIELD_LAST), so
the compaction lives in one place instead of three.
#30: hook_active() called target_mask() unconditionally, taking the global
targets_lock spinlock on every hooked syscall (e.g. every socket ioctl) even
with no targets configured. Add an active_hook_mask — the OR of all targets'
hookmasks, recomputed under targets_lock on each config apply — and short-circuit
hook_active() on a lock-free read before locking, matching the KPM's fast path.
Validated A/B in QEMU on android12-5.10 (freshly DDK-built): proc_route_v4/v6
hidden for the target, non-VPN routes preserved exactly (compactor output
byte-identical), 18/18, no panic.
* fix(kpm): avoid double-allocating a stats slot for one uid under a CAS race
stats_slot_for_uid() could hand the same uid two slots, splitting its per-hook
counters across two stats lines. Two races: a concurrent claimer mid-init (slot
state 2, uid not yet readable) was skipped by the second scan, which then
allocated a fresh slot for the same uid; and on a lost CAS the code moved on to
the next slot instead of re-checking the one the winner took.
Wait out the transient init state (state 2) so the uid becomes readable before
deciding, and re-examine a slot after losing its CAS instead of skipping ahead.
A residual window remains only if two first-hits for one uid claim two different
free slots at the same instant — that just splits a counter, never a crash; a
perfect lock-free find-or-insert needs a lock KP deliberately does without.
Validated in QEMU on android12-5.10: stats vectors unchanged, 18/18, no panic.
* docs: record the two narrow SIOCGIFCONF gaps (size-query, 32-bit compat)
* fix(kpm): seqlock config instead of double-buffer; gate host-route on GKI 5.6+
CI surfaced three issues in the kmod-behavior batch:
- The lock-free double-buffer (two copies of targets[]) grew the .kpm enough that
the patched android16-6.12 image stopped booting (KP embeds the KPM into a
reserved kernel region with limited headroom; 6.12 had the least, 5.10 was
fine). Replace it with a seqlock: a writer bumps cfg_seq odd, mutates targets[]
in place, bumps it even; the hook reader retries while odd / on a concurrent
change. Same torn-read protection (#26/#28/#31), one extra word instead of a
second targets[]. In-place parse is safe — vpnhide_parse_config only writes
after a valid header, so a reject-whole leaves the old config intact.
Re-validated A/B in QEMU: KPM boots and passes on 5.10 and 6.12 (18/18).
- The new hostroute4/hostroute6 vectors failed on the non-GKI legacy kernels
(4.14/4.19/5.4), where the host-route is intentionally disabled (IPv4 needs the
5.6+ fib_rt_info path; IPv6 needs a per-kver fib6_dst offset only populated for
GKI). Gate them in init-kpm.sh on the running kernel's version (>= 5.6) and
emit a SKIP marker that run-kpm.sh honors otherwise.
- clang-format the two new hot-path blocks with the pinned 18.x formatter.
- KPM ctl0 stats/status: vpnhide_format_* report the full intended length,
which can exceed the 4096-byte stack buffer; clamp to sizeof(buf) before
clamp_to_line/copy_to_user so a generous outlen can't drive an out-of-bounds
read of the kernel stack (info leak / panic). The .ko already bounds this way.
- vpnhide_tok_decimal: reject values that overflow unsigned long instead of
wrapping mod 2^64 and slipping past the version fuse — same contract as the
hex token parser.
Host shared-logic and protocol-vector tests pass.
Verified on a Pixel 4a (APatch, kernel 4.14) and a Pixel 8 Pro (KSU-Next +
KPatch-Next-Module, GKI 6.1):
- KPM runtime probe: drop the dead /data/adb/ksu/bin/kpatch guess (KSU-Next
ships kpatch via the KPatch-Next-Module, not ksu/bin) and the unused PATH
check. The real signals are /data/adb/ap (APatch — loads KPMs via supercall,
no CLI on disk) and /data/adb/modules/KPatch-Next/bin/kpatch (the module, same
on Magisk/KSU/KSU-Next). Mirror the same trim in the activator's find_kpatch
and the KPM post-fs-data, documenting that APatch goes through the supercall.
- root_manager: APatch and KSU both ship a /data/adb/magisk compat layer (the
APatch 4a has /data/adb/magisk + magisk.db), so the old order reported APatch
as 'magisk'. Check magisk first and let the exclusive ksu/ap dirs override it.
- reword the KPM needs-runtime string: drop the misplaced 'APatch already
includes it' note (an APatch user never sees this string — the runtime flag is
already true via /data/adb/ap).
When the KPM stands down for a co-installed .ko backend (§1.5), the boot
flow previously let the activator's silent skip be recorded by service.sh
as 'activator 1 configured' — load_status falsely claimed the KPM was
loaded. The .ko+KPM conflict banner also keyed on both backends being
active, a state that can never be observed (two live kernel hookers freeze
the device), so users were never warned about the redundant co-install.
- activator: activate_kpm_boot returns KpmBootOutcome; a kmod conflict is
DeferredConflict (not silent Ok), mapped to exit code 3 in the kpm bin.
- kpm service.sh: conflict dir-check before the APatch superkey branch
(so a co-installed .ko isn't masked as awaiting_superkey), and exit 3
is recorded as a truthful 'conflict' status instead of 'configured'.
- app: surface the deferred-conflict (load_status runtime=conflict for the
current boot) as a warning; new dashboard_issue_native_conflict_deferred
string (EN+RU) telling the user to remove one kernel backend.
- post-fs-data.sh: replace the comment claiming a non-existent kernel-half
guard with the actual layered, fail-safe userspace design.
The KPM boot script was out of step with the rest of the migration:
- it sourced debug from its own /data/adb/vpnhide_kpm/debug_logging, which the
app never writes — so KPM debug was always off. Read the one canonical flag
(/data/system/vpnhide_debug_logging) instead, folded into the ctl0 config's
`debug` line like the .ko/zygisk scripts.
- it wrote the lsposed channel (vpnhide_uids.txt) as plain UIDs, which the new
system_server reader can't parse. Emit the `vpnhide 1 config` wire via the
same build_config helper, so a KPM-only install still feeds the Java layer.
Refs #21.
Finish the migration: docs/state.md, docs/protocol.md, detection-vectors.md and
the kmod/lsposed READMEs now describe the folded /proc/vpnhide_ctl node and the
`vpnhide 1 config` wire on every channel; the kmod ABI section documents
config-in / status+stats-out. The KPM exit drops the dead remove_proc_entry
calls (it has no /proc node — its channel is ctl0). The shared header notes that
the decimal vpnhide_parse_target_uids is the legacy load-args/host-test path,
not the protocol channel. Changelog fragment added.
Refs #23.
Package the cross-version .kpm as one flashable module (kmod/kpm/module +
kmod/kpm/build.py -> vpnhide-kpm.zip), analogous to the .ko and Zygisk
modules, per protocol §7.4:
- post-fs-data.sh loads the KPM via the KernelPatch runtime early in boot.
Keyless KPatch-Next (Magisk/KSU) loads automatically; APatch (superkey)
records awaiting_superkey for the app. Single-active guard: refuses to load
when the .ko backend is installed (§1.5).
- service.sh resolves package->UID once PackageManager is up, pushes a
'vpnhide 1 config' snapshot via 'kpatch kpm ctl0', and feeds the lsposed
UID file (the always-active Java layer).
- customize.sh seeds the persistent /data/adb/vpnhide_kpm config dir.
CI gains a 'kpm' publish job (build + upload vpnhide-kpm.zip) wired into the
release job's needs; the new module scripts join the shellcheck list. The
kpm-qemu jobs remain the runtime test gate.
The boot-script CLI invocations follow §7.3 and are fail-safe (they record
load_status and exit rather than risk a bootloop); the exact on-device
kpatch CLI paths get confirmed in device testing.
Dispatch the KernelPatch ctl0 supercall on the §4 header kind:
- config -> vpnhide_parse_config: per-uid hookmask + debug (replaces the
uid-only target set; each hook now gated by its bit, enabling per-hook
control). The decimal load-args path stays for headless QEMU bring-up and
enables the full kernel mask.
- status -> vpnhide_format_status: backend id, kver, installed-hook mask,
dominant error (ok / partial_hooks), via out_msg + clamp_to_line.
- stats -> format wired; per-(uid,hook) counters are a follow-up (TODO).
Add backend ids (kmod/kpm/zygisk/lsposed, protocol §4.3) to the registry so
the status emitter and the app share one source — codegen now renders them
to C/Rust/Kotlin alongside the hook ids and error codes.
Second PAN bug surfaced by the SW_TTBR0_PAN harness: the 5.4 target boot
panicked the moment a target UID hit the SIOCGIFCONF / dev_ioctl path
(copy_from_user). We resolved the *raw* __arch_copy_*_user asm, which only
copies — the access_ok + uaccess enable/disable (the TTBR0 switch under
software PAN, or the PAN toggle under hardware PAN) lives in the C wrapper
on >=5.x. Calling the raw routine directly with software PAN active reads an
unmapped user page -> fault -> panic. On 4.14 the raw asm still did the
switch itself, which is why only 5.x tripped; on real hardware-PAN devices
the raw routine is fine, but old ARMv8.0 cores (no FEAT_PAN, software PAN)
would hit this.
Resolve the generic _copy_from_user / _copy_to_user first (correct under both
PAN modes), fall back to __arch_copy_*_user only if absent. Re-validated:
5.4 goes 3/9+panic -> 9/9 clean; 4.14 / 4.19 (software PAN) and 5.10
(hardware PAN) still 9/9, no regression.
Found by running the KPM on a real Pixel 4a (kernel 4.14.302): the device
reboot-looped seconds after loading the module. pstore showed a kernel
panic in our dev_ioctl hook (iface_is_vpn dereferencing a bad pointer),
via sock_ioctl -> do_vfs_ioctl -> SyS_ioctl, faulting on `ldrb w9,[x0]`.
Root cause: dev_ioctl's 3rd arg is a *userspace* `struct ifreq __user *` on
kernels before ~5.5 (4.14/4.19/5.4 copy it in themselves), but a *kernel*
`struct ifreq *` on >=5.5. dev_ioctl_after dereferenced it directly, which
is fine on >=5.5 and was fine in QEMU (TCG doesn't enforce PAN), but on real
arm64 hardware with PAN (Privileged Access Never) a direct kernel read of a
user address faults -> panic -> bootloop. This is a gap in the QEMU harness:
it can't catch user/kernel pointer-domain bugs because PAN isn't emulated.
Fix: branch on the pointer domain at runtime (ptr_is_kernel: arm64 TTBR1
addresses have the top 16 bits set). Kernel ifr -> read directly; user ifr
-> copy the name in via copy_from_user. Works across all versions with no
offset table. Re-validated in QEMU: 4.14 (user arg) and 5.10 (kernel arg)
both still 9/9, dev_ioctl + siocgifconf PASS. Confirmed on-device: after the
fix the Pixel 4a is stable with the module loaded and hides vpn0 from the
target UID across ip addr / ip link / ifconfig (SIOCGIFCONF).
Expand the Deploy section: a KPM always needs KernelPatch embedded in the
kernel (no "KPM on a stock kernel"), but the boot.img patch can be driven by
APatch, KPatch-Next on KernelSU-Next, OR the standalone KPatch-Next-Module
for plain Magisk / stock KernelSU. The latter bundles kpimg+kptools+
magiskboot, patches boot.img from its WebUI, and auto-loads
/data/adb/kp-next/kpm/*.kpm each boot — so dropping vpnhide.kpm there is the
easy persistence path. Note our UID targeting goes through the module's own
KPM_CTL0/load-args, separate from KPatch-Next's package_config/exclude_set.
Validating 6.12 surfaced two struct changes vs 6.1, both of which would
silently break the module on every 6.12 device:
1. struct fib6_info inserted `struct hlist_node gc_link` (16 B) after
`expires`, shifting nh@176 and fib6_nh[]@192 (6.1: 160/176). Without this
the IPv6 route-dump hook reads the wrong dev. (fib_info also gained a
`pfsrc_removed` bool, but it lands in existing padding so nh stays @104;
inet6_ifaddr is unchanged, idev@168.)
2. The struct net_device cacheline-group reorg (TX/TXRX/RX read-mostly
groups) moved `name` off offset 0 for the first time ever. Derived
netdev_name@296 by compiling offsetof(struct net_device, name) against
the 6.12 gki headers; harness-confirmed. This breaks every name-based
hook (link/addr/route dumps all call netdev_name(dev)), which is why
only the proc/ioctl/policy vectors passed before the fix.
New vpnhide_off_6_12 entry (selector: >=6.12 -> 6_12, 6.0-6.11 -> 6_1).
Validated A/B on a DDK-built android16-6.12 GKI Image: 9/9, no panic.
Eight kernels now pass the full native-vector A/B — the complete Android
kernel range: 4.14, 4.19, 5.4, 5.10, 5.15, 6.1, 6.6, 6.12.
Every offset the module reads is byte-identical between 6.1 and 6.6
(fib_dump via fib_rt_info*@arg4, idev@168, fib_info 96/104/128, fib6_info
nh@160/fib6_nh@176, fib_rule), so 6.6 maps to the existing 6.1 table. A
separate A/B run on a DDK-built android15-6.6 GKI Image confirms it: 9/9
vectors, no panic. Documents the shared coverage; no offset change.
Seven kernels now pass the full A/B: 4.14, 4.19, 5.4, 5.10, 5.15, 6.1, 6.6.
Validating 5.15 (the selector mapped it to the 5.10 table) surfaced a real
bug: 5.15's struct fib6_info gained offload/offload_failed fields (same as
6.1), so nh@160 and fib6_nh[]@176 — the 5.10 table's 152/168 would have made
the IPv6 route-dump hook read the wrong dev on every 5.15 device.
Splits 5.15 into vpnhide_off_5_15 (selector: >=5.15 -> 5_15, 5.6-5.14 ->
5_x). Identical to 5.10 except the fib6_info offsets. Validated A/B on a
DDK-built android13-5.15 GKI Image: 9/9 vectors, no panic (netlink_route6
confirms fib6_nh@176; getifaddrs confirms idev@168).
Six kernels now pass the full native-vector A/B: 4.14, 4.19, 5.4, 5.10,
5.15, 6.1.
Fills in the 6.1 route-dump/policy offsets (were TODO) and validates the
whole table A/B on a DDK-built (clang) android14-6.1 GKI Image: 9/9 vectors,
no panic. With this, all five supported kernels — 4.14, 4.19, 5.4, 5.10,
6.1 — pass the full native-vector A/B.
Derived from android14-6.1 source:
- inet6_ifaddr.idev@168, NOT 216. The struct prefix up to dad_work is
byte-identical to 5.10 (which validates at 168), and the getifaddrs()
probe confirms 168 closes the address path (target 4 -> 0). The 216 first
taken from soranerai is dropped.
- fib_info 96/104/128 and fib_dump_info via fib_rt_info*@arg4 — same as 5.10.
- fib6_info nh@160, fib6_nh[]@176 (ANDROID_KABI_RESERVE before fib6_nh).
- fib_rule 36/88/104/120/124, skb.len@112, proc_ops (>=5.6).
4.14 goes from 7 to all 10 hooks, validated A/B on a from-source 4.14.336
QEMU kernel (9/9 vectors, no panic). It's the oldest and most structurally
divergent target, so this also adds the last cross-version capability:
- IPv4 route dump: legacy fib_dump_info prototype (fib_info* at arg 9, no
nexthop objects) — reuses the table-driven fib_dump path. fib_info
fib_nhs@80 / fib_nh@104 (fib_weight, if CONFIG_IP_ROUTE_MULTIPATH, fills
the @84 pad so fib_nh stays @104).
- IPv6 route dump: rt6_fill_node takes a struct rt6_info* here (pre-
fib6_info), whose embedded dst_entry holds net_device *dev as its first
member. New rt6_via_dst/rt6_dst_dev offsets make dev_from_fib6_info read
the dev directly (dev@0) instead of walking a fib6_nh. fib6_info kernels
set rt6_via_dst=0 and are unaffected (5.10 re-validated 9/9).
- Policy rule: re-enabled — fib_nl_fill_rule is fib_nl_fill_rule.isra.N
under gcc, which the fuzzy resolver now finds.
With this, 4.14 / 4.19 / 5.4 / 5.10 all pass the full native-vector A/B.
Adds 4.19 to the offset table, validated A/B on a from-source 4.19.325 QEMU
kernel (9/9 vectors, no panic). 4.19 is a pre-nexthop kernel that already
carries the struct fib6_info IPv6 route model:
- fib_info/fib6_info have no `struct nexthop *nh` field -> fib_info_nh and
fib6_info_nh are 0, and the dev_from_fib*_info nexthop guards skip the
check (added with the 5.4 work).
- fib_dump_info uses the legacy <5.6 prototype (fib_info* directly at arg 9),
same shape as 5.4 -> reuses the table-driven fib_dump path.
- fib6_nh predates fib_nh_common: nh_dev is the 2nd field (+16 after the
in6_addr gateway), and CONFIG_IPV6_ROUTER_PREF (Android-common, validated
=y) puts fib6_nh@160 -> dev@176. Noted as config-sensitive.
- inet6_ifaddr.idev@168, fib_info fib_nhs@80/fib_nh@104, fib_rule + ifa_dev
match the other versions; skb.len@112 (modern head + conntrack).
Selector maps [4.19, 5.0) -> 4.19, [4.0, 4.19) -> the 4.14 table.
Adds 5.4 to the KPM offset table with full native-vector parity (validated
A/B on a from-source 5.4.302 QEMU kernel: 9/9 vectors pass, no panic) and
the shared infrastructure that made it work across versions.
Per-version offsets (vpnhide_off_5_4, derived from AOSP common source):
- inet6_ifaddr.idev@168, in_ifaddr.ifa_dev@24, fib_info (96/104/128) and
fib_rule (36/88/104/120/124) all match 5.10.
- fib6_info has no ANDROID_KABI_RESERVE here -> fib6_nh[]@160 (5.10 is 168).
- skb.len@112 (modern head + _nfct; real 5.4 devices carry conntrack).
- procfs is file_operations (<5.6).
Shared changes (also benefit 4.14/4.19/6.1):
- Table-driven fib_dump_info: 5.4 uses the legacy <5.6 prototype (fib_info
passed directly at arg 9, 11 args), not 5.10's fib_rt_info* at arg 4. The
hook now reads the fi arg by table index (fib_dump_fi_arg) and derefs it
via fri only when fib_dump_fi_via_fri is set, unified on a 12-arg frame
(argno=11, like rtnl). Re-validated: 5.10 still 9/9.
- Nexthop-object guards: dev_from_fib_info/dev_from_fib6_info only probe the
nh field when its offset is known (kernels <5.3 have no nexthop objects).
- Fuzzy symbol resolution: gcc renames static fns to name.isra.N /
name.constprop.N, which kallsyms_lookup_name misses. Hook resolution now
falls back to the name.-prefixed clone via kallsyms_on_each_symbol, so
fib_nl_fill_rule.isra.21 (gcc) is hooked; clang device kernels keep the
plain name and match exactly first. This flipped 5.4 policy_rule to PASS.
Harness: bake rodata=off into the boot args (KernelPatch writes its restore
region + hook sites into kernel text; some from-source layouts place that in
a read-only page -> early abort). Note that <5.10 kernels need -cpu cortex-a57.
Brings the KPM up on kernel 4.14 (the real #33 target — pre-GKI, no DDK
build). Validated A/B in QEMU against a from-source 4.14.336 Image that
KernelPatch patches and boots; SUMMARY: 7 hooks PASS, no panic.
Make hook installation offset-gated: each hook installs only when the
offset(s) it dereferences are known for the running kver (0 => skip), so
a partially-filled offset table can never run a hook with a wrong/zero
offset and panic. This is what lets one binary serve a version that only
has some of its offsets derived yet — exactly 4.14's state.
4.14 offsets (vpnhide_off_4_x), derived from 4.14.336 source:
- inet6_ifaddr.idev@168 — no rt_priority field (vs 5.10) but 4.14's
timer_list still carries `data`, so delayed_work is +8: nets out to
the same 168. (A wrong 160 here panicked at boot until corrected —
inet6_fill fires during boot networking, before init.)
- in_ifaddr.ifa_dev@24, fib_rule offsets — same layout as 5.10.
Working on 4.14: fib_route_seq_show, ipv6_route_seq_show,
rtnl_fill_ifinfo, inet_fill_ifaddr, inet6_fill_ifaddr (the static
getifaddrs() probe goes target->0), dev_ioctl, sock_ioctl.
Left gated off on 4.14 (offset 0), with notes:
- fib_dump_info / rt6_fill_node — structurally different on 4.14 (fi at a
different arg, no nexthop objects, IPv6 = rt6_info not fib6_info); needs
table-driven arg/extraction before it can be enabled.
- fib_nl_fill_rule — installs but never fires; the symbol is static and
appears inlined on 4.14. Needs an alternate hook point.
Harness: add VPNHIDE_QEMU_CPU (default max) so pre-GKI kernels can run
with cortex-a57 — `max` faults before the console on 4.14.
Closes the RTM_GETADDR address path. A static getifaddrs() probe
(test/gai-probe.c, built per run by run-kpm.sh) proved that with only
rtnl_fill_ifinfo hooked, bionic getifaddrs() still saw vpn0 via its
RTM_GETADDR address entries (target gai count = 3) even though `ip addr`
showed nothing — exactly the gap a getifaddrs-based detector (RKNHardering)
exploits. Hooking both addr-fill functions drops it to 0.
- inet_fill_ifaddr: dev = ifa->ifa_dev->dev (in_ifaddr.ifa_dev@24,
in_device.dev@0 — config-stable).
- inet6_fill_ifaddr: dev = ifa->idev->dev (inet6_ifaddr.idev@168 derived
through the lock/dad_work fields, inet6_dev.dev@0) — validated by the gai
probe (target 3 → 0), confirming the derivation.
- run-kpm.sh builds the static probe (NDK clang) and adds gai_getifaddrs to
the A/B set; init-kpm.sh runs it.
All 10 native hooks now QEMU-validated A/B on android12-5.10, no panic —
full parity with the .ko. Only the 5.10 offset table is filled; other
versions next.
fib_nl_fill_rule(skb=arg0, rule=arg1, …): hide a policy rule that routes
via a VPN iface (iif/oifname) or selects a non-standard table (>100, not
main/local/default) for a target UID range — mirrors the .ko's per-UID
rule hiding. 5.10 struct fib_rule offsets derived from source: table@36,
iifname@88, oifname@104, uid_range@120/124.
Harness vector policy_rule (`ip rule show | grep 199`) PASSes A/B
(notarget=1 target=0, no panic). 8/10 hooks validated on android12-5.10:
proc_route_v4/v6, getifaddrs, siocgifconf, dev_ioctl, netlink_route4/v6,
policy_rule. Header STATUS refreshed (was stale).
Remaining 2 (inet_fill_ifaddr, inet6_fill_ifaddr / RTM_GETADDR) are masked
by the rtnl link hook in `ip addr`, so they need a getifaddrs/raw-netlink
probe to validate independently.
IPv6 analogue of fib_dump_info. rt6_fill_node(net, skb=arg1, rt=arg2, …):
resolve fib6_info -> fib6_nh[0].nh_common.nhc_dev (legacy single-nexthop)
and skb_trim + ret 0 for a VPN iface.
5.10 fib6_info offsets derived from android12-5.10 source — note the
Android-specific layout: rt6key=20B, the bitfield byte, rcu_head padding,
and ANDROID_KABI_RESERVE(8) before the flexible fib6_nh[] => nh@152,
fib6_nh[]@168. Validated first try (netlink_route6 PASS A/B, no panic),
confirming the source-derivation method holds even for the fiddly IPv6
struct. 7/10 hooks validated on android12-5.10.
First hook that dereferences version-specific kernel structs, proving the
offset methodology: derive struct offsets from the matching kernel source,
encode them in kver_offsets.h, validate in QEMU (a wrong offset is an A/B
fail or a contained panic, never a brick).
fib_dump_info(skb=arg0, …, fri=arg4): resolve the route's output device
(fri->fi->fib_nh[0].nh_common.nhc_dev for a legacy single-nexthop route)
and, if it's a VPN iface, skb_trim back + return 0 — the RTM_GETROUTE dump
path that detectors render as `if<N>` (issue #86).
5.10 offsets derived from android12-5.10 source (LP64, no ifdefs before
fib_nh[]): fib_info.fib_nhs@96, .nh@104, .fib_nh[]@128, nhc_dev@0.
Nexthop-object routes (fi->nh != 0) are not unpacked yet (legacy fib_nh
covers `ip route add … dev X`).
Harness vector netlink_route4 (`ip route show table all`) PASSes A/B on
android12-5.10 (notarget=11 target=0, no panic). 6/10 hooks validated.