Zygote-aware kernel provenance for Android.
An on-device eBPF system that attributes security-relevant kernel events — network egress, credential-adjacent file access, process lifecycle — to installed Android packages rather than to PIDs or UIDs.
Server-oriented eBPF security tools (Falco, Tetragon, Tracee) assume process
ancestry encodes provenance. Android breaks that assumption. Every app process
is forked from a single Zygote, so ancestry is uniform across the entire
application layer and carries no discriminating signal. Identity instead lives
in the UID namespace, in shared-UID groupings, in isolated-process semantics,
and in a package↔UID mapping owned by user-space system_server.
Attribution therefore requires a hybrid kernel/user-space identity oracle. It is not a port of existing tooling.
Phase 1 of 4: capability detection. Nothing else is built yet, deliberately.
Before designing an identity oracle it is necessary to know what the platform
actually permits — which program types load, which hooks attach, what SELinux
refuses, and what Android's own eBPF programs already occupy. chiral-probe
answers that empirically, per device.
| Crate | Purpose | State |
|---|---|---|
crates/chiral-probe |
On-device eBPF capability detection | built |
crates/chiral-idprobe |
Identity-surface measurement | built |
crates/chiral-common |
Both report schemas, plus the analysis they hold — host-tested | built |
crates/chiral-ebpf |
Kernel-side programs | not started |
crates/chiral-agent |
Userspace collector + identity oracle | not started |
harness/ |
Ground-truth test apps | not started |
xtask/ |
Cross-compile, deploy, collect | built |
Both probes are measurement tools. Neither resolves identity, caches it, or models how an agent should — that is the oracle's job, and the oracle does not exist yet.
Reports, as diffable JSON, what eBPF can actually do on a given device:
- kernel version, GKI vs vendor, KMI generation, architecture, page size;
- BTF availability, and what remains possible without it;
- which program types load: kprobe, tracepoint, raw_tracepoint, cgroup_skb, cgroup_sock, cgroup_sock_addr, sk_msg, BPF-LSM, fentry/fexit;
- which map types are creatable, ring buffer and perf buffer included;
- whether BPF-LSM is compiled in and active in the boot-time
lsm=list; - SELinux enforcing state, and the denials produced by each attach attempt;
- whether the specific hooks this project needs exist and attach;
- what netd and
bpfloaderalready occupy, so the contested surface is visible.
Every probe performs a real load and attach, then detaches. A hook that exists but is refused by policy is reported as unavailable, with the reason. Nothing is inferred from a version number.
The report is designed to be diffed across devices — see docs/compatibility.md.
Requires a rooted device over adb. The root mechanism is detected, not
configured: direct uid 0, adb root on userdebug/eng builds, and su -c under
Magisk or KernelSU all work.
cargo xtask build # cross-compile for the attached device's ABI
cargo xtask run # build, push, execute on device
cargo xtask collect # run and pull the JSON into docs/devices/
cargo xtask test # run the unit tests on the device
cargo xtask check # type-check for every target (no NDK needed)
The build target is taken from the device's ro.product.cpu.abi, so a bare
cargo xtask run does the right thing whether that's a phone or an emulator.
Override with --target arm64|x86_64, and pass -s <serial> when more than one
device is attached.
cargo xtask collect writes docs/devices/<device>-<android>-<kernel>.json.
Diff two of them to build the compatibility matrix.
x86_64 is supported so the probe can be exercised end-to-end without a rooted
phone. Use a google_apis or default (AOSP) system image — both are
userdebug, so adb root works. google_apis_playstore images are user
builds and cannot be rooted.
An emulator validates the code. It does not produce compatibility-matrix data: the kernel is not a vendor kernel, the SELinux domain is far more permissive so blocks are under-reported, and netd's BPF surface is reduced. Mark any emulator-derived row as such — docs/compatibility.md §6.
- Loads two-instruction BPF programs and detaches them immediately. Return values are chosen per program type so an attached program is inert — an LSM program returning non-zero would deny operations system-wide.
- Creates and removes a scratch cgroup under the first writable cgroup2 mount. Never attaches to the cgroup root, where netd's programs live.
- Briefly sets SELinux permissive to get a differential reading, then restores
it via a
Dropguard and a signal handler. ASIGKILLduring that window would leave the device permissive;--no-selinux-toggleavoids the risk at the cost of leaving blocked-vs-absent unresolved.
See ADR 0007.
Measures what there is to attribute an observation to, and quantifies it. It answers three questions, in order of how much each constrains a design.
Shannon entropy over every running app process, per candidate signal, with each tagged by whether an eBPF program in kernel context could read it and whether an unprivileged app can choose its own value. On the first run (GKI 6.6, 35 app processes, maximum 5.129 bits):
| signal | bits | efficiency | largest collision class | kernel-visible | app-chosen |
|---|---|---|---|---|---|
| ancestry | 0.000 | 0% | 35 of 35 | yes | no |
| selinux_context | 2.883 | 56% | 13 | yes | no |
| package | 4.984 | 98.8% | 2 | no — user space only | no |
| uid | 5.072 | 98.9% | 2 | yes | no |
| uid + selinux_context | 5.072 | 98.9% | 2 | yes | no |
| comm / cmdline | 5.129 | 100% | 1 | yes | yes |
Every app process had the identical parent chain zygote64 < /system/bin/init.
Entropy measures separation, not trustworthiness. comm and cmdline score
highest and are precisely the two signals an app sets for itself. That is a
column in the schema, not a footnote, so it cannot be lost when the table is
quoted. Note also that pairing UID with the SELinux context scores identically
to UID alone — the second signal is a function of the first.
Separation between processes is a proxy. The question a security tool asks is which package acted, and the two diverge exactly where the package↔UID mapping is not injective:
| uniquely attributable from UID | 33 of 35 — 94.29% |
| expected residual ambiguity | 0.133 bits/process |
| worst case | 5 candidate packages |
UID scores 98.9% discrimination and 94.29% attribution. The failure is
concentrated, not spread: two processes are not slightly ambiguous, they are
one-of-five. Five shared-UID groups existed, the largest mapping 11 packages
onto UID 1000 — and one mapping 5 packages onto UID 10062, inside the ordinary
app range, so this is not a platform-app quirk that can be special-cased away.
"SELinux context" is not one signal. AOSP's seapp_contexts decides per
domain whether the MLS category set is derived from the app (levelFrom=all)
or only from the user (levelFrom=user):
| processes | app id recoverable in kernel context | |
|---|---|---|
| per-app categories | 11 of 35 (31.4%) | yes — 11/11 exact |
| user-only categories | 24 of 35 | no |
For a third of app processes the app id is recoverable from task->security
alone, with no user-space lookup — a materially better result than "56%"
suggested, and invisible while the two populations were averaged together. It
recovers the app id, not the package: the categories are a function of the
UID, so they cannot separate packages that share one.
The probe cross-checks every decoded app id against the one the kernel assigned, and reports disagreement as a finding rather than a rounding error.
cargo xtask run --bin chiral-idprobe # unprivileged; root adds seinfo
cargo xtask run --bin chiral-idprobe -- --samples 10 # report figures as distributions
cargo xtask collect --bin chiral-idprobe
Unlike chiral-probe this needs no root — pm list packages -U and /proc are
readable as shell — so it runs on a locked production device.
Needs the Rust aarch64-linux-android target and an Android NDK. The NDK is
discovered from ANDROID_NDK_HOME, ANDROID_NDK_ROOT, or the newest one inside
$ANDROID_HOME/ndk.
rustup target add aarch64-linux-android
cargo xtask build
Both probes are Linux binaries and do not compile for a Windows or macOS host —
they use /proc, /sys and Linux syscall numbers directly. Use
cargo xtask check (which needs no NDK, since cargo check never links) rather
than a bare cargo check on those hosts.
The arithmetic that produces every number in a report — Shannon entropy, the
SELinux MLS category decoding, Android's UID model, the report reduction, the
date math — lives in chiral-common, which is platform-agnostic on purpose. So
most of the test suite needs no device and no NDK:
cargo xtask test-host # or: cargo test -p chiral-common
That includes a regression test against a committed fixture of a real device run, so every figure published in the README, the ADRs and the paper is asserted in code. A refactor that changes a published number has to change that file too.
What genuinely cannot run on a host — BTF parsing against a live kernel, AVC
parsing, /proc walks — runs on the device:
cargo xtask test # builds for the device, pushes, runs there
- docs/compatibility.md — both report schemas, how to diff them, and what the results do not prove.
- docs/decisions/ — one ADR per non-obvious choice, including the ones deliberately left open.
Apache-2.0.