35 KiB
vpnhide — control & stats protocol
Single source of truth for the wire format exchanged between the app and every
backend (.ko, KPM, Zygisk, LSPosed), and for the architecture that carries it.
This document is the arbiter: when two implementations disagree, this file
decides who is wrong. Every implementation (C / Rust / Kotlin) and every test
vector references it.
Status: version 1 frozen — all seven OPEN items (§10) are resolved.
Key shape: one always-on Java (LSPosed) layer + exactly one active native
backend, priority kmod > KPM > Zygisk (§1.5); three kinds — config (in), stats + status (out, §4.3);
UID is the key on every channel incl. Zygisk; u64 cumulative counters; debug
folded into config; the .ko channel is one folded node /proc/vpnhide_ctl
(write=config, read=status+stats); KPM uses the kpm ctl0 supercall (§7).
Threat model: an unprivileged app — root-level detection is out of scope.
Storage & activation live in storage.md. This document is the frozen wire spec (the bytes exchanged at runtime). The layer above it — a single JSON canonical on disk, the activator that derives this wire for the native backends, LSPosed reading the JSON directly (it does not consume this wire), and the APatch superkey — is specified there, and supersedes the storage-related statements in §1.3–§1.4, §6, and §7.4 below. The wire format (§4–§5) is unchanged.
1. Architecture
1.1 No hub — app is the orchestrator
There is no resident root daemon. The app (Kotlin) is the only orchestrator: it
serialises config snapshots into every channel via su, and reads stats back
the same way. Each backend reads its own channel independently. There is no
intermediary process between the app and the backends.
Rationale: the exchange is rare (per user action) and pull-based; a resident
root process would add a named, enumerable component on a device where node
visibility is deliberately minimised, and it would not remove the unavoidable
in-system_server Kotlin reader anyway (see §3). A hub buys nothing here and
costs stealth + lifecycle complexity.
1.2 State is per-backend; the protocol is shared
Two orthogonal axes, kept separate:
- State is NOT unified. Each backend owns its own channel/file. Backends do
not know about each other. One writer per file, atomic replace
(
tmp+rename). This is what prevents races and torn reads, and keeps each SELinux label scoped to a single reader domain. Merging state into one file would mean multi-writer across three domains and one label that must satisfy every reader — rejected. - The protocol IS unified. One grammar, one parser core per language, one codegen'd hook registry. Format is not a shared resource, so unifying it creates no races. This is the only axis we unify.
One kernel backend at a time. The .ko and KPM are alternatives, not
co-residents: they wrap the same kernel functions
(rtnl_fill_ifinfo, inet6_fill_ifaddr, fib_route_seq_show, …), and running
both at once hard-froze a device (kretprobe + KernelPatch inline hook on one
symbol, deadlocking the netlink path on the first target enumeration — observed
on a Pixel 8 Pro, 6.1). So at init each kernel backend MUST detect the other and
refuse: the KPM resolves a .ko marker via kallsyms (the .ko is a real module;
the reverse uses the KPM's /proc marker), and a refusal is surfaced as
status.error = conflicting_backend (§4.3/§5.1) — a readable state, not a silent
no-op. This does not weaken §1.2's "backends don't know about each other" for
state: it is a one-bit liveness check at load, not shared runtime state.
1.3 Channels
Every channel carries the same text snapshot format (§4). They differ only in transport and in which records they carry (profiles, §6).
| Channel | Write transport | Read transport | Reader runs in |
|---|---|---|---|
app ↔ .ko |
echo > /proc/vpnhide_ctl |
cat /proc/vpnhide_ctl (seq_file) |
kernel |
| app ↔ KPM | supercall ctl0 (args) |
supercall ctl0 (out_msg) |
kernel |
| app ↔ Zygisk | targets.txt via module dir-fd |
n/a (stats via §7 if added) | Zygisk process (zygote-forked) |
| app ↔ LSPosed | /data/system/vpnhide_* files |
same files | system_server |
Channel-specific notes:
- Zygisk reads through the root-privileged module-directory fd Zygisk hands
the module before app specialisation (
openat(dir_fd, "targets.txt", …)), which bypasses SELinux. Read on every app launch; edits picked up on next force-stop + relaunch. No companion, no socket. - LSPosed has no privileged endpoint with the app. The only shared medium is
the filesystem: app writes via
su, the hook insystem_serverwatches viaFileObserver(inotify) and reloads. Files areroot:system, mode0640, labelsystem_data_file—system_serverreads via groupsystem,untrusted_appfalls to "other" and gets EACCES. - KPM is the only true request/response channel (no file, no node) — see §5.
1.4 Where protocol code lives
No hub ⇒ the serialiser lives in the app (Kotlin). Three languages total, by process:
| Language | Process | Role in protocol |
|---|---|---|
| C | kernel (.ko + KPM) |
parse config; emit stats. Freestanding, kmod/shared/vpnhide_logic.h. |
| Rust | Zygisk process | parse its config profile (targets.txt). |
| Kotlin | app | serialise all config snapshots into every channel; parse stats readback. The "thick" end. |
| Kotlin | system_server (LSPosed hook) |
parse its config profile from its file. Not removable — cannot inject a .so into system_server. |
The UI itself originates data (package→UID via PackageManager, per-app hook selection) but the app is also the orchestrator, so wire serialisation lives in Kotlin here. Parser parity across C / Rust / Kotlin is held by golden vectors + differential testing (§8), not by shared code.
1.5 Backend selection: one Java layer + exactly one active native
Backends split into two layers that run together:
- Java layer — LSPosed, always active. It hooks Java APIs inside
system_server(PackageManager, ConnectivityManager, …) — a vantage no native backend has. It is orthogonal to the native layer and is never "the one of N". - Native layer — exactly one of {
.kokmod, KPM, Zygisk} is active. They cover the same native surface (netlink / proc / libc), so running two is at best redundant double-hiding and at worst fatal: the.ko(kretprobe) and KPM (KernelPatch inline) wrap the same kernel functions and deadlocked a device when both fired (§1.2). So the product runs one native backend, period.
Selection is by fixed priority kmod > KPM > Zygisk, among the backends
actually loaded (read from each backend's status, §4.3). The app:
- reads
statusfrom every native backend that is present; - picks the highest-priority loaded one as active, writes it the real config snapshot, and writes the others an empty snapshot (no targets ⇒ hooks installed but idle);
- if more than one native backend is loaded, warns the user (a second loaded backend is a misconfiguration to clean up, not a supported mode).
Two backstops make this safe even if the app's policy is wrong or not yet run:
- Kernel guard. A native kernel backend refuses to install hooks if the
other kernel backend is already present (KPM checks for the
.kovia kallsyms; the.kochecks the KPM's/procmarker) and reportsstatus.error = conflicting_backend(§5.1). This prevents the deadlock at the source, independent of userspace. - No self-activation of dangerous overlap. Boot scripts may load a backend, but the single-active decision is the app's; a loaded-but-unconfigured native backend filters nothing.
This does not contradict §1.1 (no hub) or §1.2 (per-backend state): selection is
a read-only policy the app computes from status, not a shared runtime store.
2. Design constraints that shaped the format
These are why the format is what it is. Do not "simplify" against them.
- One consumer is freestanding kernel C (KPM,
-nostdinc, no allocator, no libc). This rules out protobuf/flatbuffers/serde runtimes and JSON-in-kernel. Text with a hand-written parser is not a compromise — it is the only thing that compiles everywhere. - KPM delivery is a CLI that takes a string and returns a string. A binary format would have to be hex/base64-wrapped through argv anyway. Text matches the transport.
- Debugging is by an LLM agent reading/writing raw over
adb(cat/echo). The format must survive shell quoting (no",$, backtick,\in payload) and be self-describing enough to read without external context. This is why keywords are words, not magic numbers. - Pull only. Kernel aggregates per-(uid, hook) counters; userspace reads periodically. No push, no per-hit events on the hot path.
3. Semantics
- Snapshot, not deltas. One write = the entire desired state. The reader
replaces its state wholesale. There is no add/remove/clear. This makes state a
pure function of the last write — it never depends on history, which removes
the whole class of "partially-applied" / "stale leftover" bugs. It also matches
echo > node(truncating redirect) andFileObserver("file changed", inotify gives no deltas). - Mandatory header gating. A payload without a valid header (§4.2) is
rejected whole — a stray
echo 'debug 0' > nodedoes not silently wipe state, it errors. "Valid full state, or a loud refusal, never silent-partial." - Version gates compatibility. A reader knows only its own version. A payload whose version is greater than known is rejected whole (not parsed). This turns any drift that slips past tests into a loud refusal instead of a silent misparse — exactly what is wanted across the 7-KMI matrix where userspace↔kernel skew is inevitable.
4. Wire format
4.1 Lexical rules
- Encoding: ASCII only, bytes
0x20–0x7Eplus\n. Any other byte in a line → reject that line. - Line separator:
\nonly. On read, a trailing\r(CRLF) is stripped. On write, never emit\r. A trailing\nat end of payload is optional on read (but see §5 for its meaning under KPM truncation). - Whitespace: one or more spaces/tabs between tokens is a single separator. On write, emit exactly one space.
- Comments: a line whose first non-whitespace character is
#is ignored whole. No inline comments after data — keeps the parser from scanning for#mid-line. - Blank lines: ignored.
- Line order: not guaranteed; readers must not depend on it.
4.2 Header
The first significant (non-blank, non-comment) line MUST be:
vpnhide <version> <kind>
vpnhide— literal magic.<version>— decimal integer (this line is parsed specially; the hex rule in §4.4 does not apply to it). Current:1.<kind>—config(app → backend),stats(backend → app), orstatus(backend → app: module health + errors, §4.3).
A payload without this header, or with version > the reader's known version,
is rejected whole (§3).
"Significant" is decided by non-blank, non-comment alone — a non-ASCII line is significant. So if the first significant line is non-ASCII, it is the line that MUST be the header; being an invalid header, it rejects the payload whole. The §4.1 "reject that line" (skip-and-continue) rule applies to records, once a valid header is established — it does not let the header search step over a non-ASCII first significant line to find a header on a later line.
4.3 Records
config (kind = config):
debug <flag>
target <uid> <hookmask>
debug <flag>—flagis the literal0or1. At most onedebugline; absent ⇒ unchanged-from-default (define default as0).target <uid> <hookmask>— one per target app.uidis the app UID;hookmaskis a hex bitset of enabled hooks (bit N ⇔hook_idN, §5/registry). Duplicateuid⇒ last-wins (producer guarantees uniqueness; consumer defends). UID is the key for every backend, including Zygisk: the kernel backends have no PackageManager so they cannot key on package names, and Zygisk (which runs in the target's own process and could match either way) keys ongetuid()for one grammar across all channels. The package→UID resolution is the producer's job (app / boot script), the same for all backends.
stats (kind = stats):
<uid> <hook_id>:<count> <hook_id>:<count> ...
- One line per uid that has any non-zero counter. Sparse: only non-zero
hook_id:countpairs are emitted.hook_idandcountare hex;countis au64cumulative since the backend loaded (OPEN-3) — reads never reset it, so two readers don't race and the app computes deltas itself.
Asymmetry is deliberate: config uses a dense mask (a set over a small fixed
universe, applied in the kernel in O(1)); stats uses sparse id:count (most
counters are zero, and adding a hook shifts no columns — an old reader simply
skips an unknown id).
status (kind = status) — backend health, read the same way as stats:
backend <id>
kver <kver>
hooks <installed_mask>
error <code>
backend <id>— which backend answered (registry ids:0x0.ko,0x1KPM,0x2Zygisk,0x3LSPosed).kver <kver>— running kernel version as the backend sees it (KernelPatchkver, e.g.0x6019d);0x0for non-kernel backends.hooks <installed_mask>— bitset of hooks that actually installed, in the same id space as a confighookmask. Lets the app show "requested vs active".error <code>—0x0= healthy; non-zero is the single dominant fault code (§5.1). At most oneerrorline.
status exists because a kernel backend can refuse or only partially install for
reasons the app cannot otherwise see (no node, no logs unless debug). It turns
those into a readable state instead of a silent no-op — most importantly the
mutually-exclusive-backend case (§1.2): when the KPM detects the .ko (or vice
versa) it refuses and reports error = conflicting_backend, which the app
surfaces as "disable the other backend".
4.4 Numeric primitive
One primitive for every data field (uid, hookmask, hook_id, count):
0x followed by one or more hex digits, any case on read
0xprefix is mandatory on data fields. (If it is ever dropped it must be forbidden, never optional — an optional prefix = two valid spellings of one number = a drift seam. See OPEN-1.)- Read accepts any case (
0xFF==0xff==0xFf); write always emits lowercase. Liberal-in / strict-out: producers are deterministic, the consumer never trips on case. (Pin this with a vector — it is a classic silent-drift corner: C ASCII-folds, Rust forgets, they diverge on0xCvs0xc.) - Widths:
uid,hookmask,hook_idareu32;count(stats) isu64(OPEN-3). A value that overflows its field's width → reject the line (not saturate, not wrap).
The debug flag (0/1) and the header version are the only non-hex tokens,
and both are special-cased to their keyword/line.
4.5 Forward compatibility
- Unknown first token (keyword/record type) → skip that line, do not fail the payload. Lets a newer producer add record types without breaking older readers.
- Unknown
hook_id→ the app keeps it as "unknown hook N" when reading stats; the kernel masks unknown bits when applying a config mask (mask & known_hooks). New hooks never misfire on an old backend. - Empty payload after the header (zero records) is valid.
4.6 Example
config (app → kernel):
vpnhide 1 config
debug 0
target 0x27fa 0x3ff
target 0x2947 0x004
stats (kernel → app):
vpnhide 1 stats
0x27fa 0x0:0x5 0x3:0xc 0x9:0x1
0x2947 0x2:0x1
5. Hook registry (global ID space)
The shared vocabulary between config masks and stats IDs. Global ID space:
one hook_id = one specific hook in one backend; numbering is project-wide;
bit N in a mask ⇔ hook_id N in stats. Adding a hook to any backend takes the
next free global id.
This is the only thing that is codegen'd. The wire grammar/parser is
hand-written (trivial); the registry is data referenced from five places
(.ko, KPM, Zygisk, app, system_server reader + UI labels), so id drift would
turn "hooks for app X" into the wrong hooks on another backend. One TOML →
codegen emits:
- C
enum+ names (kernel labels its counters), - Rust
enum+ the per-backend "which bits are mine" mask, - Kotlin
enum(per-app picker UI + thesystem_serverreader).
Registry source row (illustrative):
[[hook]]
id = 0
name = "fib_route_seq_show"
backend = "kernel"
Each backend applies mask & own_hooks and ignores foreign bits. A config mask
is global; a backend only acts on its own bits.
Current kernel hooks (.ko / KPM, 11): fib_route_seq_show,
ipv6_route_seq_show, rtnl_fill_ifinfo, inet_fill_ifaddr,
inet6_fill_ifaddr, dev_ioctl, sock_ioctl, fib_dump_info, rt6_fill_node,
fib_nl_fill_rule, socket_bind_interface. Current LSPosed Java hooks (8):
lsposed_link_properties,
lsposed_network_capabilities, lsposed_network_info, lsposed_network,
lsposed_connectivity_result, lsposed_connectivity_callback,
lsposed_connectivity_network, lsposed_package_visibility. Current Zygisk
libc hooks (8): zygisk_ioctl, zygisk_getifaddrs, zygisk_openat,
zygisk_recvmsg, zygisk_recv, zygisk_recvfrom,
zygisk_recvfrom_chk, zygisk_setsockopt.
5.1 Error codes (status)
A small fixed enum, codegen'd from the same registry TOML as the hooks, so the
numbers are one source of truth across C / Rust / Kotlin (a status reader must
not invent its own codes). The error field of a status payload (§4.3) is one
of:
| code | name | meaning |
|---|---|---|
0x0 |
ok |
healthy; every requested, owned hook installed |
0x1 |
unsupported_kver |
no offset table for the running kernel — refused, no hooks |
0x2 |
conflicting_backend |
the other kernel backend (.ko↔KPM) is loaded — refused (§1.2) |
0x3 |
symbol_resolution_failed |
a required kallsyms symbol was missing — refused |
0x4 |
partial_hooks |
installed, but some owned hooks did not resolve (see the hooks mask) |
error is a single dominant code, not a bitset: a refusal is one reason, and
"healthy-but-partial" is the only non-fatal non-zero. The hooks mask carries
the per-hook detail when error = partial_hooks.
6. Profiles
One grammar, N profiles. A profile is the subset of records a channel carries, by hook ownership — not by "how many features we shipped". Because per-app hooks and stats come to all backends, every channel carries the full grammar; there is no "bare" profile.
The parser is profile-agnostic: the §4.5 "unknown keyword → skip" rule means one parser reads any channel and acts only on records it understands. The app is the only writer of all profiles; each backend reads its own.
| Channel | config records it acts on | emits stats? | emits status? |
|---|---|---|---|
.ko / KPM |
debug, target (kernel-owned mask bits) |
yes | yes (§4.3) |
| Zygisk | debug, target (zygisk-owned mask bits) |
no, not yet (§7) | yes, via the app heartbeat |
| LSPosed | debug, target (lsposed-owned mask bits, incl. package visibility) |
yes | yes |
A backend ignores target mask bits it does not own (mask & own_hooks), so the
same target 0x27fa 0x3ff line is valid on every channel and each backend takes
its slice.
Active vs idle (§1.5). The grammar is the same on every channel, but the app
only writes a real config to the LSPosed channel and the one active native
channel; the other native channels get an empty config (or none), so a
loaded-but-not-selected native backend parses nothing to act on. Selection is the
app's policy from status, not part of the wire format — a backend never knows
whether it is "the active one"; it just applies whatever config it is given (and
the empty config makes it idle).
7. KPM supercall binding
KPM has no file and no node. The channel is the KernelPatch supercall, already
wired as KPM_CTL0(vpnhide_kpm_ctl0) with signature:
static long vpnhide_kpm_ctl0(const char *args, char *__user out_msg, int outlen);
The config parser is already shared with .ko (apply_targets →
vpnhide_parse_config from kmod/shared/). KPM reuses the §4 format unchanged;
only the transport differs and out_msg/outlen (currently (void)) become the
read-back channel for stats and status.
Confirmed on-device (OPEN-5): the KPatch-Next kpatch CLI does forward
out_msg to stdout — a probe build that wrote a marker into out_msg and
returned its length printed the marker via kpatch kpm ctl0 vpnhide stats
(Pixel 8 Pro, KSU-Next, runtime d05). The long return is not printed by
the CLI (a target-arg call that left out_msg untouched produced no stdout).
So: data flows out only through out_msg/copy_to_user; the return value is
reserved for short codes (bytes written, or a negative kind/error code), never
for payload.
7.1 Dispatch
ctl0 dispatches on the header kind:
static long vpnhide_kpm_ctl0(const char *args, char *__user out_msg, int outlen)
{
if (kind_is_config(args)) { /* "vpnhide 1 config\n..." */
apply_snapshot(args); /* same shared parser as .ko */
return nr_target_uids; /* short code only (NOT surfaced as text) */
}
if (kind_is_stats(args) || kind_is_status(args)) {
char buf[OUT_MAX]; /* "vpnhide 1 stats" / "...status" */
int n = kind_is_stats(args) ? format_stats(buf, sizeof buf)
: format_status(buf, sizeof buf);
if (n > outlen)
n = clamp_to_line(buf, outlen); /* never mid-record */
if (_copy_to_user && out_msg)
_copy_to_user(out_msg, buf, n); /* the ONLY path data exits */
return n;
}
return -1;
}
7.2 The one KPM-specific rule: no pagination
out_msg/outlen is a single fixed buffer per call — unlike seq_file, which
paginates. Therefore:
- The reader passes a generously large
outlen(stats for tens of uids is a few KiB). - The module truncates only on a line boundary (
clamp_to_line), never mid-record — otherwise the app parses a garbage tail. - Truncation is signalled by the absence of a trailing
\n. The app, seeing no trailing\n, knows the snapshot is incomplete and either re-reads with a bigger buffer or accepts the partial. (This is exactly the §4.1 rule that a trailing\nis optional on read but meaningful here.)
On write, the whole snapshot must fit in args; bound it by MAX_TARGET_UIDS
and have the parser truncate honestly at that ceiling (already the case for
vpnhide_parse_config(..., MAX_TARGET_UIDS)).
7.3 Delivery
Via the KernelPatch KPM ctl0 supercall. KPatch-Next exposes this through its
keyless runtime kpatch CLI subcommand kpm ctl0:
kpatch kpm ctl0 vpnhide "<payload>"
Write (multi-line payload as one argv argument):
kpatch kpm ctl0 vpnhide "vpnhide 1 config
debug 0
target 0x27fa 0x3ff
target 0x2947 0x004"
Read (response on stdout — out_msg, §7.1):
kpatch kpm ctl0 vpnhide "vpnhide 1 stats"
kpatch kpm ctl0 vpnhide "vpnhide 1 status"
The userspace entry must match the running KernelPatch runtime. The .kpm
module itself is cross-version (it loaded and ran on both c02 and d05), but
the userspace ABI is not portable. The low 16-bit KPM command ids are the
same (load=0x1020, ctl0=0x1022, list=0x1031), and both runtimes currently
dispatch on those low bits, but the calling conventions differ:
- APatch: syscall number
45, arg0 is the real SuperKey, command word uses APatch's0x1158marker. APatch's publicapdCLI manages APM ZIP modules only; it does not exposekpm load/ctl0. APatch's own app controls KPMs through native JNIsc_kpm_*calls, so the vpnhide activator does the same directly. The activator probesSUPERCALL_HELLObefore load/control, so a missing KernelPatch runtime or stale SuperKey fails before a stray original syscall can run. - KPatch-Next: syscall number
45, arg0 isNULL, and the kernel side gates calls by root UID rather than a SuperKey. The activator issues no raw command-word marker here — it drives KPatch-Next through the runtime's ownkpatch kpm ...CLI. With the standalone KPatch-Next-Module that binary lives under/data/adb/modules/KPatch-Next/bin/kpatch.
Do not ship one generic kpatch binary for both runtimes.
Caveats from CLI/shell delivery on KPatch-Next: shell-quote the payload (single
quotes; the format contains no '). APatch does not pass the key through an
external CLI argv; the activator calls the supercall from its own process.
7.4 Distribution & boot integration
The .kpm ships as a thin flashable module (Magisk/KSU format; APatch reads
it too). The module delivers the binary and a boot script; the actual KPM-load
mechanism is delegated to the activator and runtime. KPatch-Next is keyless;
APatch can also activate at boot when the user has opted into persisting the
SuperKey under /data/adb/vpnhide/superkey (root-only DE storage):
- KPatch-Next (Magisk / KSU),
d05, keyless — the recommended path. The boot script does everything itself: detect the runtime, enforce single-active (skip KPM if the.kois loaded, §1.5),kpatch kpm load vpnhide.kpm, then apply the persisted config snapshot viakpatch kpm ctl0. Fully automatic, no user interaction. This is what we recommend users run. - APatch,
c02, SuperKey-required. If the user enabledrememberSuperkey, the app persists the key root-only at/data/adb/vpnhide/superkey, and the boot activator loads/configures APatch via direct supercalls after theSUPERCALL_HELLOprobe. Without that saved key, boot writesawaiting_superkey; activation resumes after the app supplies the key.
This keeps KPatch-Next fully automatic, while APatch is automatic only when the user explicitly stores the root-only SuperKey for boot.
8. Drift prevention (C ↔ Rust ↔ Kotlin parity)
Program equivalence is undecidable; "never diverge" is unachievable as a proof. The achievable goal: make drift loud — caught in CI before merge with probability empirically indistinguishable from never. Layered, each layer catching what the previous misses.
- Layer 0 — this spec. "Compatible" is undefined without an arbiter. Every
ambiguous decision in §4 (0x policy, hex case, overflow, duplicate uid,
unknown id/keyword, truncation, trailing
\n) is a pin point and MUST have a vector. - Layer 1 — shared golden vectors. One language-independent file of
(input → expected parse)and(struct → expected string), both directions. The C host test and the Rust/Kotlin tests run the same vectors against the spec. Not codegen'd — the parser is not generated, so a runtime-read data file (TSV /|-delimited, ten-line C harness) is enough; there is no generated code to itself drift. Catches mutual bugs on covered cases; misses uncovered corners. - Layer 2 — differential testing (the key layer). Compare the two
implementations against each other over a large generated input space. The C
parser is already host-compilable (
__VPNHIDE_HOST_TEST); link it viacc+bindgeninto the Rust test binary (test only — production stays pure Rust, the freestanding constraint is untouched) and assertc_parse(s) == rust_parse(s)andc_serialize(x) == rust_serialize(x)overproptestinputs. Catches divergence on the long tail; misses mutual bugs (both wrong identically) — which is why Layer 1 anchors correctness to the spec. - Layer 3 — fuzzing the diff oracle.
cargo-fuzzharness: raw bytes → both parsers → assert they agree (both reject, or both accept with identical structure). libFuzzer links freestanding C trivially. Input is trusted (root-only channel), so this is not about hostile input — it is about "never silently misparse" under pressure: NUL mid-string, overlong lines, leading zeros, u32-overflow boundary, mid-line truncation, non-ASCII, duplicate uid. The showcase case is KPM truncation: propertyparse(truncate(serialize(x), n))is always a valid prefix-subset. - Layer 4 — version fuse.
versionin the header turns any drift that slips all tests into a loud refusal rather than a silent misparse (§3).
CI currently gates C host vectors, Rust/Kotlin vectors, the Rust↔C proptest differential oracle, and the registry drift-check (TOML regenerated and committed). Layer 3 remains the next hardening step; no fuzz-smoke gate is claimed until a bounded PR corpus and scheduled long run are actually wired. The spec is always the correctness oracle; the diff says "they diverged", the spec says "who is wrong".
If the format ever grows real structure, the parity cost of two implementations
rises faster than the cost of a single C implementation linked into the hub via
cc+bindgen (one source, no second parser) — revisit then. For the current
flat text, two impls + this harness is the cheaper, catability-preserving choice.
9. Rejected alternatives (and why)
Recorded so they are not re-litigated.
- JSON / protobuf / flatbuffers at the kernel boundary — their runtimes do not compile in freestanding KPM. (JSON-in-userspace-C, as a fork does via a daemon, is internally consistent but adds a daemon; we have no hub.)
- netlink / genetlink — solves push; we are pull-only, and it is single-
backend (
.ko, no KPM equivalent), so it does not unify anything. Reserved for a future low-rate kernel→userspace event channel if one is ever needed. - Binary fixed-layout structs at the kernel boundary (ioctl) — type-safe and
a clean single source via
bindgen, but loses catability (nocat/echofor the agent debugger), andMAX_*arrays bake ceilings into the ABI: a new field shifts layout and forces a synchronous rebuild across 7 KMIs. Fragile to evolution. (If binary is ever needed, prefer TLV over flat structs.) - Per-op commands / deltas (
set/add/del/clear) — friendlier toecho >>, but reintroduces stateful, history-dependent application: an interrupted batch leaves a half-applied state not derivable fromcat. Snapshot is a pure function of the last write. Rejected. - A resident root hub / daemon — reconsidered explicitly (and after reviewing
vpnhide_next, whose daemon is a narrow netlink poller justified by an IP-spoof feature we do not have; its real "hub" is the kmod's/devmisc device, which couples everything to the.kobeing loaded — incompatible with "any one of N native backends"). Re-rejected: a resident named root process is enumerable (ps//proc), exactly the fingerprint this project minimises; it adds keep-alive/SELinux/IPC surface; and it does not remove the irreducible in-system_serverKotlin reader. Its one real win — live reaction to events without the app open — is not a requirement (targets change rarely; boot + app-open re-resolution suffice), and its other wins (single code path, root syscalls, coordinated single-active) are had by the app-as-orchestrator + a thin boot script + the kernel guard (§1.5) with no resident process. Revisit only if a feature needs a live event loop. The narrow KPM coordination it would buy is already covered by the kernel guard. - Unified single state file across backends — multi-writer across three SELinux domains, torn reads, one label for all readers. State stays per-backend.
- Routing LSPosed/Java state through the kmod via ioctl (a fork's approach) —
centralises state but couples LSPosed to the kmod being loaded and needs
system_serversepolicy for the device node. We keep backends autonomous.
10. Open decisions (resolve before freezing version 1)
- OPEN-1 —
0xprefix. RESOLVED: mandatory on data fields. It is a visual/parse anchor at ~zero cost, and "mandatory" (not "optional") removes the two-spellings-of-one-number drift seam. - OPEN-2 — agent self-description level. RESOLVED: positional-after-keyword.
target <uid> <hookmask>— the keyword names the record, fields are positional.key=valuebuys marginal readability for extra bytes and a second split; the keyword already self-describes for an agent reading raw. - OPEN-3 — stats counter type. RESOLVED:
u64cumulative-since-load. Reads are non-destructive (no reset-on-read race between two readers), it never wraps in practice, and deltas are the app's job — which suits the pull model (§2).countis therefore au64hex value; kernel counters are per-CPUu64. - OPEN-4 —
.kostats node. RESOLVED: one folded node, renamed/proc/vpnhide_ctl..proc_writetakes the config snapshot,.proc_readreturns status+stats — one node, no new ones. Renamed fromvpnhide_targetsfor semantic accuracy (it is now a control channel, not just targets), NOT for stealth: the node is0600root-only and the threat model is an unprivileged app (root-level detection is explicitly out of scope — you cannot defend a device whose owner gives root to a VPN-detector), so the name is never observable by an adversary. The persistent/datafiles keep their names. Debug folds in here too (OPEN-6), so/proc/vpnhide_debuggoes away. (KPM has no node either way — supercall read.) - OPEN-5 — KPM userspace transport. RESOLVED (on-device). KPatch-Next
exposes
kpm ctl0 <name> <payload>through its keylesskpatchCLI, and the CLI does forwardout_msgto stdout — so no extra root binary is needed for read-back there. APatch does not exposekpm load/ctl0throughapd; the activator calls the runtime-specific supercalls directly with the saved SuperKey. The.kpmis cross-version, but the userspace transport is not. - OPEN-6 —
debugplacement. RESOLVED: folded into the config snapshot.debug <flag>is the one global (non-target) config record (§4.3). Today each backend has its owndebug_loggingfile (and the.koa/proc/vpnhide_debugnode); folding it into the config snapshot removes those per-backend files/nodes (less enumerable surface), makes it atomic with the rest of config, and needs one parse instead of a second channel. - OPEN-7 — self-documenting read. RESOLVED: yes, emit an in-band header. The
read side prepends a one-line
#comment (# vpnhide v1 — WRITE REPLACES ENTIRE STATE …) before the current state. It is a comment line (ignored by parsers, §4.1), so it costs nothing structurally and lets an agent learn the grammar + the replace-whole semantics from a singlecat(§2). All seven OPEN items are now resolved;version 1is frozen.