The classic two-step SIOCGIFCONF enumeration first calls with ifc_req == NULL
to learn the buffer size (the kernel returns ifc_len = N * sizeof(struct ifreq)
without copying anything), then calls again with a sized buffer. sock_ioctl_ret
filtered the fill but bailed on the NULL probe, so the size query returned the
unfiltered count — a target comparing the two back-to-back saw a one-interface
discrepancy that revealed a hidden VPN.
sock_ioctl_ret now also handles ifc_req == NULL: it rcu-enumerates the socket's
netns netdevs (net captured at entry from the socket, matching dev_ifconf's
sock_net) and subtracts sizeof(struct ifreq) per IPv4-addressed VPN iface,
matched by ifa_label — the exact set and naming inet_gifconf would have emitted
— so the size query agrees with the filtered fill.
Validated by a new harness vector (ifconf_size_probe, via kmod/test/ifconf-probe.c)
asserting a target's size query equals its filtered fill and drops below the
non-target size query: 19/19 PASS, no panic, on GKI 6.1 and 6.6 in QEMU; CI
covers the remaining KMIs once the ddk-qemu image rebuilds with the new probe.
The 32-bit compat SIOCGIFCONF path (compat_sock_ioctl) remains unhooked and is
tracked in docs as low priority. The KPM backend's filter_ifconf still bails on
the NULL probe (raw netdev walking by per-kver offset is not worth it for the
legacy probe) and is noted as WIP parity.
* 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(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.
Replace the two legacy nodes /proc/vpnhide_targets (decimal UID list) and
/proc/vpnhide_debug with a single /proc/vpnhide_ctl that speaks the shared
control/stats protocol (docs/protocol.md):
- write = a `vpnhide 1 config` snapshot, parsed by the freestanding parser
shared verbatim with the KPM (shared/vpnhide_logic.h): per-UID hook mask +
folded debug flag, version-gated, rejected-whole on a bad header.
- read = a self-documenting banner + `status` (backend/kver/installed-hook
mask/error) + an empty `stats` snapshot (counters still TODO, as in the KPM).
Per-hook gating: each kretprobe entry handler now checks hook_active(<id>)
against the calling UID's mask instead of a flat is_target_uid(), so the app
can enable hooks individually. The QEMU harness writes the config wire to the
new node.
Refs #20.
The gai_getifaddrs vector skips (still exit 0) when no bionic probe is
available — correct for a dev box without an NDK, but in CI the probe is
baked into the image, so a skip there means it's missing/broken and would
let the job go green with the addr-fill hook silently unchecked. Add a
VPNHIDE_GAI_REQUIRED gate (set by both kpm-qemu jobs): when set, an
unavailable probe is a hard failure instead of a silent coverage drop.
The KPM QEMU jobs were failing for three reasons unrelated to the .kpm:
- run-kpm.sh died silently before any output: the NDK-glob under
`set -euo pipefail` exits non-zero when no NDK is present (CI), killing
the script. Guard it with `|| true`.
- The prebuilt kptools-linux needs glibc >= 2.38; the legacy image was
Debian bookworm (2.36), so the dynamic loader rejected it and the patch
step failed silently, booting an unpatched kernel ("KPM did not load").
Move the legacy image to trixie (2.41) and add a loud kptools preflight.
- The gai_getifaddrs vector validates how *bionic* getifaddrs parses the
filtered RTM_GETADDR dump. It was falling back to a glibc cross-gcc,
which is the wrong libc (glibc walks the stream more strictly and spins
on 5.4 — a glibc quirk, not a product bug; bionic passes there, verified
9/9 in QEMU). Build the probe strictly with bionic: bake it from
gai-probe.c with NDK r28c into both test images and pick it up via
VPNHIDE_GAI_BIN, so the addr-fill hook is validated in CI on every kernel
instead of skipped. (r27c's lld under-aligns the static PT_TLS and bionic
aborts at startup; r28c's lld fixes it.)
Net: the addr-fill hook keeps full CI coverage; only when neither a baked
probe nor a host NDK exists is the vector skipped (the 8 core vectors run).
Adds a `kpm-qemu` job that runs the KPM harness (run-kpm.sh) on the same
baked GKI kernels as `kmod-qemu`, across all 7 KMIs — KernelPatch-patched +
.kpm-embedded boot instead of insmod. The KPM was untested in CI until now;
this closes that gap for the GKI range (4.14/4.19/5.4 follow in a separate
legacy job + image).
Hermetic, reusing the ddk-qemu image:
- bake the KernelPatch tool + runtime (kptools-linux + kpimg-linux, pinned
KP_VER) into /opt/qemu/kp and expose VPNHIDE_KP_BIN, so run-kpm.sh skips its
per-run release download;
- add gcc-aarch64-linux-gnu so the harness's static getifaddrs probe builds;
- run-kpm.sh honors VPNHIDE_KP_BIN (falls back to the per-run cache locally).
The job builds the relocatable .kpm against the KernelPatch submodule
(submodules: recursive) with the DDK clang — no kernel tree needed — then
run-kpm.sh boots the baked Image with KernelPatch and runs the A/B vectors.
Touching the Dockerfile/qemu.config triggers the image rebuild that bakes
these in (qemu-image.yml).
Codifies the (previously ad-hoc) build of the pre-GKI / legacy kernels the
KPM harness needs but the .ko backend can't serve: 4.14 + 4.19 (vanilla,
pinned 4.14.336 / 4.19.325) and 5.4 (AOSP common android11-5.4, pinned SHA).
One pinned Bootlin aarch64 gcc 7.3 builds all three — the compiler version
doesn't affect struct layout (source+config does), it's just old enough to
compile these kernels (a modern gcc/clang trips on -Werror). Config is
`defconfig` + qemu.config (virtio/console/initrd + SW PAN) + the IPv6 /
policy-routing bits the route/rule vectors need, with 4.19 getting
IPV6_ROUTER_PREF=y so fib6_nh lands at the offset kver_offsets.h pins (@176).
Output is just an Image (the .kpm is relocatable — no kernel tree needed).
This is the producer for the CI `kpm-qemu-legacy` images; locally:
kmod/test/build-source-kernel.sh 4.14
VPNHIDE_QEMU_IMAGE=.cache/legacy/4.14/Image VPNHIDE_QEMU_CPU=cortex-a57 \
kmod/test/run-kpm.sh android12-5.10
The harness missed a real bug: dev_ioctl's ifreq arg is a __user pointer on
<5.5 kernels, and dereferencing it directly only faults on hardware with PAN
(Privileged Access Never). QEMU emulates whatever the chosen -cpu advertises;
the <5.10 kernels must run on -cpu cortex-a57 (ARMv8.0, no FEAT_PAN), so on
the exact CPU where the bug lives there was no hardware PAN to emulate and the
bad deref silently "worked" — it only panicked on a real Pixel 4a.
Enable CONFIG_ARM64_SW_TTBR0_PAN=y in qemu.config: software PAN (TTBR0
switching) builds the user/kernel boundary into the kernel itself, independent
of the CPU model, and is the runtime fallback the kernel already uses when the
core has no hardware PAN. So PAN is now enforced under both -cpu max (GKI) and
-cpu cortex-a57 (legacy). Proven: on 4.14+cortex-a57 the pre-fix kpm panics on
the target boot (panic=4), the fixed kpm is clean (9/9); GKI 5.10 still passes
both the .ko (7/7) and KPM (9/9) harnesses.
Also refresh the KPM section of the harness README (10 hooks across 4.14–6.12,
not the old 2-hook PoC note) and add the -cpu / rodata=off knobs it needs.
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.
Both are offset-safe (no kernel-internal struct offsets — only the
uapi-stable ifreq: ifr_name@0, sizeof(ifreq)=40, struct ifconf {len@0,
ptr@8}), so they port cleanly across kernel versions.
- dev_ioctl: for SIOCGIF* (0x8910..0x8930) on a VPN iface, rewrite the
return to -ENODEV (KPM first-class ret rewrite via fargs->ret).
- sock_ioctl: for SIOCGIFCONF, compact VPN entries out of the userspace
ifreq[] array and shrink ifc_len (resolved copy_from/to_user).
Harness extended with siocgifconf (`ifconfig -a`) + dev_ioctl
(`ifconfig vpn0`). All 5 wired hooks PASS A/B on android12-5.10, no panic:
proc_route_v4, getifaddrs, proc_route_v6, siocgifconf, dev_ioctl.
Remaining 5 hooks (inet*_fill, fib_dump_info #86, rt6_fill_node,
fib_nl_fill_rule) dereference version-specific structs and need per-kver
offsets — deferred to source-based derivation + harness tuning.
Mirror fib_route via the shared seq-compactor with FIELD_LAST (iface name
is the last column in /proc/net/ipv6_route). Shares fib_route_before.
Harness extended with proc_route_v6 — PASS on android12-5.10
(notarget=6 target=0, no panic). 3 hooks validated.
The KPM backend now runs end-to-end on a real kernel under QEMU, A/B-tested.
run-kpm.sh patches the cached GKI Image with KernelPatch (prebuilt
kptools-linux + kpimg-linux from the KernelPatch releases), embeds
vpnhide.kpm, and boots the patched kernel under the same QEMU/TCG setup as
the .ko harness. KernelPatch hijacks paging_init and loads the KPM at boot,
so there's no insmod and no /proc dependency: target UIDs come via the
embedded extra-args (kptools -A), and the A/B runs across two boots
(no target -> root sees vpn0; target=0 -> root doesn't), driven by
init-kpm.sh.
Result on android12-5.10: both PoC hooks PASS, no panic —
fib_route_seq_show (/proc/net/route) and rtnl_fill_ifinfo (ip addr) hide
vpn0 from the target UID and leave it for non-targets. This validates, on
a live 5.10 kernel: kver detection (0x50afc), runtime symbol resolution,
inline hook install, the 5.x offsets (skb.len=104), the shared seq-buffer
compactor, and fargs->local state passing (the per-CPU-stash fix).
- README: rewrite the CI section for Path A (module built against the baked
kernel tree, not the kdir) and add a Design-decisions section capturing why
(kdir/baked mismatch panics on 6.12, per-version LTO from gki_defconfig, BTF
off, CFI on, rt_fill_info dropped, modules_prepare + Module.symvers).
- build-kernel.sh: add 'make modules_prepare' + 'cp vmlinux.symvers
Module.symvers' so the local producer yields a complete external-module build
tree (matches the Dockerfile), else the module build fails at modfinal/modpost.
The android13-5.10 image bake failed at the BTFIDS step (resolve_btfids over
the full-LTO vmlinux, Error 255) — not an OOM at the LTO link as first assumed
(the link completed in every run). BTF (CONFIG_DEBUG_INFO_BTF) isn't needed for
these tests, so disable it: the failing BTFIDS step is dropped and the build
lightens. Verified locally — android13-5.10 now builds clean and the harness
passes 7/7 on it.
Revert the swap bump (32G -> 16G): the LTO link was never the failure, so the
extra swap was treating the wrong cause; keep a modest 16G as headroom for the
full-LTO link on a 16 GB runner.
Adds the infrastructure to run the kmod QEMU harness in CI:
- .github/docker/ddk-qemu/Dockerfile: FROM ddk-min:<kmi> + qemu-system-aarch64
+ a virtio GKI kernel (kernel/common@<kmi> + qemu.config on gki_defconfig,
built with the DDK clang) + the Alpine rootfs. The module is NOT baked: a
kdir-built module loads on this kernel (same source/vermagic), so CI reuses
the existing kmod build artifact.
- .github/workflows/qemu-image.yml: matrix build+push of
ghcr.io/<owner>/ddk-qemu:<kmi> for all 7 KMIs; runs only on recipe changes,
monthly, or on demand (kernels change seldom).
- run.sh: honor VPNHIDE_QEMU_IMAGE/ROOTFS/KO so the same script uses the baked
artifacts in CI and the local cache otherwise.
- ci.yml: lint the new harness scripts via shellcheck.
- README: CI rollout (two steps) + ready-to-apply kmod-qemu gate job.
Verified the kdir module loads on the virtio kernel and passes 7/7 vectors
(android12-5.10). The kmod-qemu gate job itself lands after the images are
built (documented), so this commit doesn't reference not-yet-existing images.
Boots a real GKI kernel under qemu-system-aarch64 (TCG) and runs the module
against fabricated VPN state, asserting each detection vector is hidden from a
target UID but visible to a non-target UID, with no panic. This catches what
compilation and source-signature checks cannot — the actual arg->register
mapping in the built kernel (how the rt_fill_info bug was found).
- init.sh: in-VM driver; fabricates a vpn0 dummy iface + routes + policy rule,
A/B tests 7 vectors (getifaddrs, SIOCGIFCONF, /proc/net/route[6], netlink
route dumps v4/v6, policy rules), emits RESULT/SUMMARY over serial.
- run.sh: assembles an Alpine initramfs around init.sh + the module, boots
QEMU with the cached kernel, parses pass/fail.
- build-kernel.sh: canonical producer — clones kernel/common@<kmi>, merges
qemu.config onto gki_defconfig, builds Image + module with the DDK clang.
- qemu.config: minimal fragment to make stock GKI boot in qemu -machine virt.
Validated locally on android12-5.10: 7/7 vectors PASS, 0 panics, 10 hooks.
.cache/ (kernels, modules, rootfs) is gitignored. CI baking is a follow-up.