A while ago, I was sitting with a friend over coffee and we started talking about Android security. It was not supposed to be a design meeting. We were just having one of those technical conversations where one topic leads to another, and after a while the discussion moved toward a security problem he had been thinking about. The interesting part was not only how to enforce something, because Android already has several strong enforcement mechanisms, but how to get better visibility into what was actually happening inside the system while it was running.
At some point, eBPF came to my mind. My first thought was simple: if the information we care about already passes through the kernel, maybe we do not need to modify every service above it just to observe that information. We could place a small observer closer to the point where the kernel already knows what is happening, collect only what we need, and leave the rest of the system unchanged.
I should make one thing clear here. The exact problem we discussed that day was not the same problem I am presenting in this article. That conversation was simply where the idea of using eBPF as an observation mechanism started for me. Later, when I wanted to test the idea properly, I needed a problem that was small enough for a Proof of Concept, but still real enough to force me to deal with Android security, kernel integration, SELinux, process identity, and platform restrictions.
Binder looked like a very good candidate.
So I started a small project around a more concrete question: can I observe Android’s Binder trust boundary at runtime, build a live picture of who is talking to whom, and compare that picture with the communication that the system is supposed to have?
That experiment became the Android eBPF Binder Trust Observer. I am sharing it partly because I wanted proper documentation of the design decisions and the problems I met along the way. I know from experience that six months later I may remember what I built but not why one map was designed in a certain way, why a SELinux rule had to exist, or why something that looked simple at first ended up needing an AOSP patch. Writing it down gives me something I can come back to later, and if part of it helps someone working on a similar problem, even better.
Before going into Binder, it is useful to keep the Android software stack in mind. The observer crosses several of these layers, from kernel tracepoints to native userspace and finally an Android viewer, which is one reason this quickly became more than a small tracing experiment.

Why Binder?
Binder is not only Android’s IPC mechanism. It is also one of the places where Android establishes a security-relevant identity that the rest of the platform depends on. current_euid(). The observer runs at the same binder_transaction tracepoint, but independently reads the current task’s UID through bpf_get_current_uid_gid(). These are related but not identical identity values: Binder records the effective UID, while the eBPF helper returns the real UID. Separately, Binder’s SELinux checks use the Binder process credentials stored when the Binder device was opened. This distinction matters when interpreting the observed graph as a view of Binder caller identity rather than assuming every Android security layer reads exactly the same credential field.
That detail matters because a lot of security logic above Binder depends on it. Framework code can use getCallingUid(), SELinux can control Binder communication between domains, and services can make access decisions based on the identity that came through Binder. Different parts of Android may enforce policy in different ways, but they depend on the same basic question being answered correctly: who is calling whom?
This is why I started thinking about Binder as a trust boundary, not only as a transport.
The official Binder documentation gives a useful view of a call from process A, through a Binder proxy and the kernel driver, to the Binder node and server code in process B.

Once I looked at Binder this way, another question appeared naturally. Android is already very good at enforcing rules around this boundary, but how easy is it to see the live communication graph that those rules are protecting?
Imagine a system where system_server is expected to call one native service, the shell reaches another service during testing, and an application UID should never talk directly to a certain hardware service. Permissions and SELinux rules may already exist around these components, but if I look at the running device and ask, “what Binder edges are actually happening right now?”, there is no ready-made trust graph waiting for me.
Binder already has tracing support, of course. I can enable its tracepoints and stream events through tracefs, which is very useful for debugging. The problem is that this gives me a stream of raw events. On a busy device, userspace then has to receive every event, parse it, count it, correlate it, and finally reduce it to the information I actually wanted.
For this project I wanted something smaller. I wanted the kernel side to do a tiny amount of aggregation and let userspace read a summary.
That is where eBPF started to make sense.
Where eBPF Fits
I do not want to turn this article into an eBPF tutorial. The project depends on only a few basic ideas: an eBPF program can run at a kernel hook, the verifier checks the program before the kernel accepts it, and BPF maps can keep state that programs or userspace can access later. If these concepts are new, the eBPF introduction is a better place to start before continuing here.
For this project, the useful part is simple. Instead of sending every Binder transaction to userspace and counting it there, I can attach a small program to a Binder tracepoint, extract the caller and target information I need, and update a counter in a BPF map. The kernel side records facts, while userspace decides what those facts mean.
Android already uses a similar kernel/userspace pattern for its own eBPF-based traffic monitoring. That is a different use case, but the official design is useful context because it shows eBPF programs, maps, userspace components, and Android’s boot-time BPF loading model working together inside AOSP.

The distinction between fact and meaning became one of the most important design choices in my project. The kernel can tell me that caller UID X submitted a transaction to target process Y. It should not decide whether that edge is normal, suspicious, or forbidden. Those are policy questions, and policy changes much more often than the mechanism used to observe the event.
By this point the PoC had a clear goal. I wanted to know which caller UID was talking to which target process, whether that communication matched the intended design, and, as an additional operational signal, how long a transaction waited between Binder submission and delivery to the target thread.
I also knew what I did not want. I did not want to decode Binder payloads, understand every AIDL method, or put another enforcement mechanism in the Binder hot path. Android already has enforcement there. The observer should add visibility without becoming a second gatekeeper.
That led to the sentence that eventually became the simplest description of the whole design:
eBPF observes. SELinux and the framework enforce. binderobs verifies.
Once I had that boundary, the project stopped looking like an eBPF program plus an app. It became a small system with separate responsibilities and separate trust levels.
From an Observer to a System Architecture
The architecture has two main planes. The first is the trusted observation and verification path. The second is presentation, which stays deliberately outside the trusted path.
At the bottom, two Binder tracepoints feed the eBPF sensor. The sensor keeps an edge counter for (caller_uid, target_tgid), a dispatch-latency aggregate, and an internal in-flight map used to match transaction submission with delivery. Above that, the native binderobs reader opens only the maps it needs, and it opens them read-only. It resolves target process names where Android’s /proc access controls allow it, computes deltas and rates, and compares observed edges with a declared trust policy. The result can be printed, sent to logcat, or exported as a text snapshot. The Android viewer consumes that snapshot and does not need direct access to BPF state.

The mechanism/policy split is the part of this design I care about most. It is an old operating-system idea, but it fits this problem very well. The eBPF side contains mechanism: observe a Binder event, read kernel-derived identity, update bounded state, and do the same thing every time. Userspace contains policy: resolve names, compare traffic with intended design, classify it, and present the result. A classic reference for this separation is Levin et al.’s Policy/Mechanism Separation in Hydra.
The platform itself pushed the design in the same direction. The eBPF verifier rewards small, bounded programs, while Android’s BPF model makes the kernel-side object part of the platform image and boot process. A product-specific trust policy, on the other hand, may need to change much more often. Keeping that policy in a normal file and a userspace component is easier to review and easier to test.
The shared ABI between the sensor and reader is also kept explicit. Both sides use the same definitions for the map key and value structures, with compile-time size checks to catch layout drift. It is a small detail, but it turns the map boundary into a real interface contract instead of two pieces of code that only happen to agree today.
typedef struct {
uint32_t caller_uid;
uint32_t to_proc;
} EdgeKey;
BTO_STATIC_ASSERT(
sizeof(EdgeKey) == 8,
"EdgeKey layout changed; eBPF/reader ABI mismatch");
Source: bpf/include/binderobs/binder_edge.h, lines 40-54
The viewer has an equally intentional boundary. It exists to make the result easy to see, not to become a privileged security component. Giving a normal Android UI direct access to bpffs, or allowing it to execute a privileged observer, would make the demo easier but the architecture worse. The viewer therefore receives an exported snapshot and nothing more.
To make the result easier to inspect, I also built a small Kotlin system app called BinderTrustViewer. The app is only a presentation layer for exported binderobs classification snapshots. It has an Overview view for the current observation status and summary, a Graph view where the observed edges can be searched and filtered by normal, suspicious, and violating, and an About view describing the data source. The important part is what the app does not do: it does not read bpffs and it does not execute /system/bin/binderobs. The native reader remains the confined path that reads and classifies the kernel data; the app only displays the result.

BinderTrustViewer showing an engineered demo snapshot built from real observed Binder edges. The demo policy is adjusted to exercise the normal, suspicious, and violating UI states.The screenshot is useful for seeing the final output of the pipeline, but it is deliberately the least privileged component in the design. Everything security-sensitive happens before the snapshot reaches this UI.
This is the point where the architecture looked clean on paper. The harder part was getting the pieces into Android without weakening the platform around them.
When Android Starts Designing With You
On a normal Linux system, the first version might be straightforward: compile the BPF object, load it, attach it to a tracepoint, and let a permitted userspace process open the maps. Android makes this path more controlled.
The BPF object becomes part of the platform build and system image. Android’s loader has to know about it, and the maps and programs inside the object must be described to the loader. This means the eBPF sensor is not really an application feature. It is a platform integration.
There is also an easy trap here: loading and attaching are not the same thing. A program can exist in the image, pass the verifier, appear under /sys/fs/bpf/, and still observe nothing because it was never attached to the tracepoint. In the AOSP tree used for this PoC, both Binder tracepoint programs are declared with auto_attach: true, so the loader also performs the attachment step. Without that setting, the programs can be loaded and pinned but remain inactive.
ProgDesc {
auto_attach: true,
..ProgDesc::new(
GID_SYSTEM,
"tracepoint_binder_binder_transaction"
)
},
Source: integration/bpfloader-allowlist.patch, loader program descriptors
The receive-side tracepoint is declared the same way.
The public AOSP eBPF documentation still describes the more explicit userspace path of obtaining the pinned program with bpf_obj_get() and attaching it with bpf_attach_tracepoint(). The auto_attach behavior described here is specific to the loader integration used by this PoC.
For the sensor, the submit tracepoint is where the caller identity is captured. The observer reads the caller UID while the calling task is still the current context and pairs it with the target TGID. Replies are ignored because they travel in the reverse direction and would otherwise create misleading edges.
In the implementation, that trust-graph edge is deliberately small. The caller side comes from the current UID at the submit tracepoint, while the target side comes directly from Binder’s to_proc field:
EdgeKey key = {};
key.caller_uid =
(uint32_t)(bpf_get_current_uid_gid() & 0xffffffff);
key.to_proc = (uint32_t)args->to_proc;
Source: bpf/binderObserver.c, lines 77-79
For dispatch timing, the sensor also stores the Binder transaction debug_id with a timestamp. When the delivery tracepoint fires, the same debug_id is used to match the event and calculate the time between submit and receive. Because submission and delivery can happen on different CPUs, this correlation state has to be shared across CPUs. I used a bounded LRU_HASH for that temporary state so an entry that never completes cannot grow the map forever.
The submit and delivery sides are joined with Binder’s debug_id. The submit handler stores the edge and timestamp, and the receive handler uses the same ID to recover them:
uint32_t debug_id = (uint32_t)args->debug_id;
InflightVal inflight = {};
inflight.key = key;
inflight.ts_ns = bpf_ktime_get_ns();
bpf_binder_inflight_map_update_elem(
&debug_id, &inflight, BPF_ANY);
Source: bpf/binderObserver.c, lines 95-99
And for recive side:
uint32_t debug_id = (uint32_t)args->debug_id;
InflightVal* inflight =
bpf_binder_inflight_map_lookup_elem(&debug_id);
if (!inflight) {
return 0;
}
uint64_t now = bpf_ktime_get_ns();
uint64_t ts = inflight->ts_ns;
EdgeKey key = inflight->key;
bpf_binder_inflight_map_delete_elem(&debug_id);
uint64_t latency = now - ts;
Source: bpf/binderObserver.c, receive-side correlation
The important point is still the responsibility of the layer, not the C code. The kernel side knows that UID X called TGID Y, how often that edge appeared, and how long a transaction waited before the target Binder thread received it. It does not know whether Y is allowed by product design.
That meaning starts in binderobs.
The reader is an on-demand native C++ tool rather than a permanent daemon. It opens the edge and latency maps read-only, snapshots them, processes the data, produces output, and exits. It cannot update the observation and it cannot load or run BPF programs.
The target is stored as a TGID, so the reader tries to resolve it through /proc/<tgid>/cmdline, with /proc/<tgid>/comm as a fallback. Resolution is intentionally best effort and depends on both normal /proc access controls and SELinux. If the process has exited, or the reader is not allowed to inspect it, the target stays as tgid:<n>. I prefer that to expanding the reader’s visibility only to make the output look nicer.
The reader can also compare snapshots to calculate deltas and rates. The latency view uses the time from binder_transaction to binder_transaction_received, so I call it dispatch delay. It is not service execution time and it is not complete round-trip Binder latency. Naming that correctly matters because a precise-looking number can still be misleading if it is attached to the wrong meaning. There are two limits worth keeping in mind here. The graph is asymmetric: callers are identified by UID, while targets are identified by TGID. Because Linux can reuse process IDs after a process exits, long-lived captures can become ambiguous if an old edge remains in the map and the same TGID later belongs to another process. The latency aggregate also includes one-way Binder transactions, whose queueing behavior is different from synchronous calls, so mixed workloads should be interpreted with that in mind.
The last reader responsibility is policy comparison, and this is where runtime observation becomes design verification.
Comparing Runtime With Intended Design
The policy describes communication that the system is expected to have. A rule is deliberately simple:
# caller_uid target decision
1000 surfaceflinger allow # system UID -> SurfaceFlinger
1000 servicemanager allow # system UID -> servicemanager
2000 system_server allow # shell UID -> system_server
10123 android.hardware.audio.service deny # app UID -> audio HAL
The useful part is not the file syntax but the three-state model behind it.
An observed edge is normal when it matches an allow rule and no deny rule. It is violating when it matches an explicit deny. If there is no matching rule at all, it is suspicious.
That third state is important. An unknown edge is not automatically an attack. It may be unexpected behavior, but it may also mean the policy is incomplete. Treating every unknown edge as a violation would make the tool claim more knowledge than it actually has.
So suspicious means “review this edge”, while violating means “this edge was already declared forbidden”. If both an allow and a deny could match, deny wins.

The classifier mirrors the design directly. An explicit deny returns immediately; otherwise an observed allow becomes normal, and no matching rule remains suspicious:
if (r.decision == Decision::kDeny) {
return Classification::kViolating;
}
allowed = true;
return allowed
? Classification::kNormal
: Classification::kSuspicious;
Source: reader/src/policy.cpp, lines 94-105
The policy also describes intent, not history. This is why binderobs --bootstrap-policy only creates a draft. It can save time by turning the current graph into candidate rules, but there is no automatic path that watches traffic and decides everything already happening must be trusted.
That distinction is important for a security tool. If a device already contains an unwanted edge, a bad configuration, or a compromise, learning directly from current traffic would simply rename the current state as normal. A human still has to review the generated policy against the intended system design.
There is a practical benefit to keeping this logic in userspace as well. The classifier does not depend on the kernel or Binder, so its parser and comparison rules can be unit-tested on the host. The observation path needs a running Android system; the policy engine does not.
At this point the complete data path exists: Binder events become small kernel aggregates, the reader turns those aggregates into meaningful edges, and the policy compares the live graph with intended design.
But there is one more question that matters more than any parser or map choice: if binderobs can inspect security-relevant state, who gives it permission to do that?
The answer is SELinux.
The Observer Has to Pass Through Android Security Too
This is the recursive part of the design that I found most interesting. The observer has to be accepted by the same security architecture it is trying to observe.
I did not want binderobs running as a generic privileged process. If it only needs read access to two BPF maps and limited process information, its SELinux domain should say exactly that.
The reader therefore runs in its own binderobs domain. On the development build used for the PoC, running the binary from adb shell transitions it into:
u:r:binderobs:s0
The domain can read the dedicated BPF state it needs, but it gets no map_write permission and no permission to execute BPF programs. The internal in-flight map is not part of the reader interface.
Android’s BPF SELinux policy adds another important boundary. A domain that needs access to bpffs has to fit the platform’s BPF policy model, including the required bpfdomain attribute, and the reader should not simply receive access to the root fs_bpf type. The project therefore gives its own bpffs area a dedicated type, so binderobs gets access to its own small part of BPF state rather than everybody else’s.

bpfloader controls what is loaded, while SELinux controls what the reader is allowed to inspect.This also shows why the Android viewer remains outside the trusted path. The eBPF sensor sees the kernel event. binderobs receives limited read access so it can interpret the sensor state. The viewer receives only the exported result. As we move toward presentation, the amount of authority goes down.
The SELinux policy makes that boundary explicit. binderobs is a dedicated coredomain and bpfdomain, but its BPF access is read-only:
type binderobs, domain, coredomain;
typeattribute binderobs bpfdomain;
allow binderobs fs_bpf_binderobs:dir search;
allow binderobs fs_bpf_binderobs:file read;
allow binderobs bpfloader:bpf map_read;
Source: sepolicy/binderobs.te
It would have been much easier to demonstrate everything as root and let an application call a privileged helper. That would also have hidden the most interesting security questions. With the confined design, every component has to explain why it needs each capability.
The eBPF program needs the Binder tracepoints.
The reader needs read access to its maps and limited process information.
The viewer needs the final result.
None of them needs permission to change Binder policy or block a transaction.
This brings the design back to the same sentence:
eBPF observes. SELinux and the framework enforce. binderobs verifies.
Verifying the Design, Not Just the Files
Testing this project changed how I verify Android platform work. The main lesson was that many signals can look like success without proving that the system is actually doing what I intended.
A successful build proves that the build succeeded. A .bpf file in the system image proves that the file is there. A pin under /sys/fs/bpf/ proves that an object was created. None of these proves that a program is attached and receiving Binder traffic.
I hit exactly that situation during development. Everything looked healthy, but the counters did not move. That was the signal that mattered.
After that, the useful test became behavioral: generate real Binder traffic, read the map, generate more traffic, and confirm the counter changes. Presence is useful for debugging, but movement proves the observation path is alive.
SELinux created a similar trap. Deploying platform files to Cuttlefish often requires adb root, and if I test immediately afterward I may be testing the binary with more privilege than it is supposed to have. In that mode the process can run outside the intended binderobs domain, /proc resolution may look better, and denials that should appear in the confined path may never occur. The output can look more complete while proving less.
So the final verification has to happen after adb unroot, with the process domain confirmed using ps -Z. If I am testing confinement, I need to test the configuration that actually uses confinement.
I also became more careful with the latency numbers. At one point I compared two maximum dispatch values and produced a dramatic ratio. The arithmetic was correct, but the conclusion was not, because the maximum was a lifetime high-water mark, not an interval baseline. The interval average was the better number for that comparison. The broader lesson was simple: before quoting a metric, define the physical event it actually measures.
These issues also changed how I rank sources during platform work. Public documentation is valuable, but for release-specific details I put the source tree I am actually building and the running device above my own notes or memory. Android moves, and some restrictions only become visible when the exact tree is built.
Looking back, several restrictions that felt annoying at first ended up improving the design. Small verifier-friendly programs kept the kernel logic narrow. Android’s controlled BPF loading model kept changeable policy out of the kernel. SELinux forced the reader into a clear capability boundary. The lack of an inline enforcement path stopped the observer from slowly becoming another Binder gatekeeper.
The constraints did not fight the architecture. In the end, they helped shape it.
Conclusion
Binder is one of the places where Android has a strong answer to a basic security question: who is calling whom?
The kernel establishes caller identity at the IPC boundary, and framework permissions, services using APIs such as getCallingUid(), and SELinux Binder rules all depend on that identity. This makes Binder a natural place to enforce security, but also a useful place to observe the system.
What I wanted to add was visibility, not another permission system or another Binder firewall. The goal was to observe the communication graph that already exists and compare it with what the system was designed to do.
The architecture eventually came down to one sentence:
eBPF observes. SELinux and the framework enforce. binderobs verifies.
The eBPF sensor collects a small amount of kernel-derived information into bounded maps. The confined native reader opens that state read-only, resolves what it is allowed to resolve, and compares the observed graph with a human-reviewed policy. The Android viewer stays outside the trusted path and displays an exported result.

The policy also stays careful about what the observer really knows. An allowed edge is normal, an explicitly forbidden edge is violating, and an unknown edge is only suspicious, because missing knowledge is not proof of an attack.
There are clear limits to the claim. This is an IDS-style observer, not an IPS. It does not block, delay, or revoke Binder calls. It sees that caller UID X communicated with target process Y, but it does not decode Binder payloads or understand the meaning of every AIDL method. It also trusts the foundation it runs on, including the kernel, loader, and built SELinux policy. A kernel-level attacker is outside what this observer can independently verify.
For me, that boundary is part of defining the system correctly.
The project started with a simple thought during a conversation: maybe eBPF could provide useful security visibility inside Android. Building the PoC forced that idea through Binder internals, eBPF constraints, Android’s loader model, SELinux, userspace policy, process identity, and runtime verification.
By the end, the most interesting result was not that eBPF could count Binder calls. The more useful result was that runtime visibility could be added beside Android’s existing security model without turning the observer into a new authority inside it.
The enforcement stays where Android already puts it. The observer gives us another way to see whether the system is behaving the way we thought it was.
The complete Proof of Concept, including the eBPF sensor, native reader, SELinux integration, policy examples, verification scripts, and Android viewer, is available here:
github.com/mominux/android-ebpf-binder-trust-observer
I mainly built it to test an idea and to understand the design properly. Writing this article is the second part of that process. It gives me something I can return to later, and if parts of the architecture are useful to someone facing a similar problem, then the coffee discussion did more work than I expected.
Some figures on this page are reproduced from work created and shared by the Android Open Source Project. Links to the original source pages are included in the figure captions, and the figures are used according to the applicable AOSP content license.
References
- Android Open Source Project, Architecture overview
- Android Open Source Project, Binder overview
- Android Open Source Project, eBPF traffic monitoring
- Android Open Source Project, Extend the kernel with eBPF
- Android Open Source Project, SELinux in Android
- eBPF.io, What is eBPF?
- Levin et al., Policy/Mechanism Separation in Hydra, SOSP 1975
- Android eBPF Binder Trust Observer
- Android Common Kernel,
binder.c, verified Cuttlefish kernel commit3ec022196c4e(Android 16 / Linux 6.12) Verified guest kernel:6.12.74-android16-6-g3ec022196c4e-ab15076761. - AOSP SELinux policy,
binder_callmacro
