Skip to content

Latest commit

 

History

19 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Chiral

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.

The premise

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.

Status

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.

chiral-probe

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 bpfloader already 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.

Running it

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.

Emulator

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.

What it touches

  • 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 Drop guard and a signal handler. A SIGKILL during that window would leave the device permissive; --no-selinux-toggle avoids the risk at the cost of leaving blocked-vs-absent unresolved.

See ADR 0007.

chiral-idprobe

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.

1. Discrimination — how well does each signal separate processes?

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.

2. Attribution — how close does a UID get to naming a package?

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.

3. What the SELinux context actually encodes

"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.

See ADR 0009 and ADR 0010.

Building

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.

Testing

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

Documentation

  • 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.

Licence

Apache-2.0.

About

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.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages