Runtime Reachability Beyond eBPF
eBPF is a strong runtime reachability primitive where you can reach the kernel. Here's what to do on the compute where you can't.

- eBPF confirms a vulnerable function actually executed by hooking the kernel function it calls into, but it only works where a program can be loaded into the kernel.
- AWS Fargate and similar serverless container compute give tasks no access to the underlying host, so kernel-level tools including eBPF-based agents structurally cannot run there.
- Runtime reachability doesn't require a kernel hook. Reading a process's own memory mappings and loaded packages from outside the application works identically whether or not the kernel is reachable.
- Function-level precision, not just library-level detection, is achievable for interpreted languages like Python and Node by hooking the language runtime itself, with instrumentation that removes itself after the first observed call.
- Runtime reachability, with or without eBPF, proves a path was reached. It cannot prove a path is unreachable, since code that hasn't fired yet during observation isn't the same as code that never will.
A scanner flags a critical CVE across your fleet: some of it on bare EC2 hosts, some in Kubernetes, some on ECS running on EC2, and some on ECS Fargate. You want to know which of those instances are actually calling the vulnerable function right now, not just shipping it. On three of those four compute models, a kernel-level tool can answer that. On the fourth, it can't get near the kernel at all.
Runtime reachability means watching a running system and recording which code paths actually fire, then checking that against your vulnerability list. eBPF is one way to do that: a set of kernel primitives that let you observe execution from inside the kernel without modifying the application. It's a good way, and a widely used one. It's also not the only way, and it has a real ceiling: it needs kernel access, and not every place your workload runs gives you that.
This piece covers eBPF fairly first: what a kprobe and a tracepoint actually are, how a program attaches to the kernel safely, and why so many runtime security tools reach for it. Then it covers the constraint that breaks eBPF-only approaches on compute like Fargate, and what runtime reachability looks like when it's built to work whether or not the kernel is reachable.
eBPF, from the kernel up
eBPF stands for extended Berkeley Packet Filter, a name that undersells what it does today. The original BPF, from 1992, was a small bytecode format for filtering packets in the kernel without a context switch to userspace for every one. eBPF generalized that idea. It's a restricted, verifiable instruction set that runs inside the kernel, triggered by events. It now attaches to far more than packets: syscalls, function entry and exit, scheduler decisions, and USDT probes in userspace binaries.
Three things make this a genuinely good primitive for observability, security tooling included:
- You don't recompile the kernel. eBPF programs get JIT-compiled and loaded into a running kernel at runtime.
- You don't modify the application. The hook attaches from outside the process, so there's no library to link, no agent to inject into the runtime, no sidecar proxy in the data path.
- You don't get to crash the kernel. Every program passes through an in-kernel verifier before it's allowed to load, which is the part that makes 'let third-party code run in kernel space' something you can actually ship in production.
Kprobes: dynamic hooks into any kernel function
A kprobe is a dynamic hook. It lets you attach eBPF code to the entry or return of almost any kernel function, by name, at load time. The kernel patches in a trap at that instruction address; when execution hits it, control diverts into your eBPF program, then returns to the original code path. No recompilation, no reboot.
The tradeoff is stability. Kprobes hook internal kernel functions, and internal kernel functions are not a stable ABI. A function name or its argument layout can change between kernel versions, which means a kprobe written against one kernel release can silently stop matching, or match the wrong thing, on another. That's the cost of the flexibility: you can hook almost anything, but you're coupling to implementation detail, not a contract.
Tracepoints: stable hooks, narrower coverage
Tracepoints are the other end of that tradeoff. They're hook points the kernel maintainers deliberately placed and committed to keeping stable across releases, exposed under /sys/kernel/tracing/events/. You get far fewer of them than you'd have kprobe targets, but the ones that exist won't move under you.
In practice, an eBPF-based agent uses both: tracepoints where one exists and covers the event you need, kprobes for everything else, including the deep, code-path-specific hooks that no tracepoint was ever going to cover.
Uprobes work the same way as kprobes but target userspace binaries instead of kernel functions, useful for hooking library calls inside an application process without modifying it. LSM BPF hooks into Linux Security Module decision points for policy enforcement rather than pure observation. Both extend the same attach-without-modifying model beyond the kernel/syscall boundary, and both still need the same thing kprobes need: a kernel to attach to.
The verifier: why this is safe to run in production
None of the above matters if loading a bad eBPF program can panic the kernel. That's what the verifier exists to prevent. Before any program is allowed to load, the kernel statically walks every possible execution path through it. It rejects the program unless it can prove three things: the program terminates, it never touches memory out of bounds, and it never reads an uninitialized value. Verification is a safety check, not a security policy. It guarantees the program won't crash or corrupt the kernel, not that its logic is doing something sane. That distinction is why eBPF-based agents are viable in latency-sensitive production environments on the hosts they can reach.
That last clause matters more than it sounds like it should. Everything above, kprobes, tracepoints, the verifier, assumes you have a kernel to load a program into and the privilege to load it. That assumption holds on a bare host, on a Kubernetes node you or your cloud provider manages, and on ECS tasks running on EC2. It doesn't hold everywhere.
The constraint that breaks eBPF-only tooling
AWS Fargate runs each ECS or EKS task on infrastructure AWS controls end to end, and that isolation is the point of the product: no privileged containers, no CAP_SYS_ADMIN or CAP_NET_ADMIN, and per AWS's own documentation, no access to the underlying host at all, not for the customer and not for AWS operators working on your behalf. That's a deliberate security boundary, not an oversight. It also means there is no kernel to attach a kprobe to, because there's no host-level access to attach one from.
This isn't a fringe edge case. Fargate and equivalent serverless container compute are a normal, common deployment target precisely because they remove host management from the picture, and vendors that ship eBPF-based runtime security have had to build separate, non-eBPF fallbacks to cover it. Datadog's own workload protection docs describe a ptrace-based agent specifically for 'eBPF disabled environments, such as AWS Fargate,' which is a tell: the eBPF story and the Fargate story are two different code paths even for vendors who lead with eBPF everywhere else.
So a fleet spanning bare host, Kubernetes, ECS on EC2, and ECS on Fargate has a real split down the middle. Three of those four give you kernel access. The fourth, by design, does not. A runtime reachability strategy that depends on eBPF as its core mechanism inherits that split: full visibility on most of the fleet, and a blind spot on exactly the compute model chosen because it needed the least operational overhead.
What static reachability actually gets you, and where it stops
Static reachability analysis reads source code, without running it, and tries to prove whether a call path exists from an entry point to a specific vulnerable function. It's a real technique that answers a real question: is a call path even possible. Our earlier deep dive on this, built against a deliberately vulnerable FastAPI app called DropVault, covers how that call graph gets constructed and where it breaks down (see the related read below).
The honest summary: static analysis has to choose between soundness (catch every real call) and completeness (never report a fake one), and in a dynamic language it can't fully have both. Three specific patterns break it consistently:
- Dynamic dispatch. When the method actually invoked depends on a runtime type or a config value, a static call graph has to either guess or enumerate every possibility, and both produce noise.
- Reflection and metaprogramming. Code that calls a function by string name, or a framework that wires handlers by convention (Spring, Django, dependency injection containers), has no static edge for the analyzer to find. The call exists; the source code doesn't say so directly.
- Lazy-loaded and conditionally imported modules. A module imported behind a feature flag, an environment check, or a plugin loader is syntactically present and semantically maybe-never-runs. Static analysis sees the import; it can't see the runtime condition.
None of this makes static reachability wrong. It makes it a proof of possibility, computed once, against source. It cannot tell you whether that possible path is the one your production traffic actually walks, today, on this build, with this config, on this compute model. That's a different question, and it needs a different kind of evidence, gathered from the running system itself.
Runtime reachability without depending on the kernel
If eBPF's ceiling is kernel access, the fix isn't to accept a gap on Fargate-style compute, it's to stop making kernel access a requirement in the first place. That means observing execution from a layer that doesn't need host privilege to work, and reserving anything that does need deeper access for where it's actually available.
Think of it in two layers, doing different jobs:
- An out-of-process layer that reads a process's own memory mappings, the list of files the process has actually loaded into memory, and matches each one against that container's package metadata. It doesn't touch application code and doesn't require a kernel hook, so it runs the same way on a bare host, inside Kubernetes, on ECS-EC2, and on ECS Fargate, provided it can see the process's memory map at all, which needs only ordinary process-inspection access, not kernel privilege. This is the layer that gives you library-level reachability everywhere your workload actually runs, including the compute model where kernel-level tooling structurally cannot follow.
- An in-process layer, scoped to interpreted languages, that hooks into how the language runtime itself loads modules and resolves function calls, giving finer precision than 'this library loaded.' It watches for a specific, pre-selected set of vulnerable functions, and once one of them is called for the first time, it records that observation and then removes its own hook, so steady-state overhead stays near zero instead of taxing every call on the hot path for the life of the process.
That second layer is deliberately scoped. Function-level precision works for interpreted-language packages, where the language runtime exposes enough structure to observe a specific call cleanly, in Python and Node, for instance. It's not claimed for compiled or native code, where that structure doesn't exist in the same way. For compiled dependencies, the out-of-process layer still gives you library-level reachability: this shared library is loaded and mapped into this process, just not a function-level trace inside it.
Both layers report through one common identity model. The out-of-process layer, because it inspects the operating system directly, can independently verify which container and process actually made an observation, rather than trusting a self-report from inside the application. On compute like Fargate, where a container can't always describe its own identity that way, the platform's own container metadata fills that gap. Either way, an observation is tied to a real container and process, not inferred.
The point isn't that eBPF is wrong. It's that a runtime reachability engine which only works where the kernel is reachable will always have a gap shaped exactly like your serverless compute. A layered approach that doesn't require kernel access closes that gap without needing eBPF to be available everywhere it would otherwise be useful.
“A tool that can only see the hosts it's allowed to touch will only ever be as complete as the hosts it's allowed on.”
Two comparisons: what each approach proves, and where each one runs
| Dimension | Static reachability | Runtime reachability |
|---|---|---|
| Detection point | Source code / call graph, before deployment | A running process, observed in production |
| What it proves | A call path from entry point to vulnerable function is theoretically possible | That call path was actually executed, by this process, at this time |
| Blind spots | Dynamic dispatch, reflection, lazy-loaded modules, DI-wired handlers | Code that hasn't run yet, including rare or attacker-only paths never exercised in normal traffic |
| Performance overhead | None at runtime; cost is analysis time in CI/CD | Near zero when instrumentation is scoped to known-vulnerable functions and removes itself after first use |
| False positive rate | Higher; conservative call graphs flag paths that exist syntactically but never execute | Very low for what it reports; a captured execution event is direct evidence, not inference |
| When it can run | Pre-deployment, on every commit or build | Only once the workload is running, so it can't gate a merge the way static analysis can |
| Dimension | eBPF-only runtime tooling | Layered runtime reachability |
|---|---|---|
| Bare host | Works, full kernel access available | Works |
| Kubernetes (self-managed nodes) | Works, node-level kernel access available | Works |
| ECS on EC2 | Works, host-level kernel access available | Works |
| ECS Fargate / serverless containers | Cannot run: no privileged access, no access to the underlying host | Works: out-of-process layer needs no kernel or host privilege |
| Coupling to kernel internals | Kprobes tie to kernel-version-specific function names and layouts | Out-of-process layer avoids that coupling entirely |
| Function-level precision | Possible via uprobes, where host access allows it | Available for interpreted-language packages; library-level everywhere else |
| Steady-state overhead | Ongoing, bounded by hooked-event volume | Near-zero once first-observation instrumentation self-removes |
Where runtime reachability still falls short
This limitation holds regardless of mechanism, eBPF or otherwise. Absence of a runtime signal is not absence of risk. A vulnerable function that hasn't fired yet during the observation window looks identical, from the trace data, to a vulnerable function that can never fire. Those are very different security postures, and no runtime observation technique can tell them apart from silence alone. An attacker-only code path, one that only executes when someone deliberately crafts the input to trigger it, is exactly the kind of thing that produces zero runtime signal right up until the day it's exploited.
This is why runtime reachability works best as a second, corroborating pass over what static reachability already flagged as theoretically possible, not as a replacement for it. Static analysis keeps the wide net; runtime observation tells you which parts of that net have live evidence behind them, right now, in this environment, on this compute model. A finding with both a static path and a confirmed runtime hit is about as close to 'stop what you're doing and fix this' as vulnerability prioritization gets. A finding with a static path and no runtime hit yet is still worth tracking, just not with the same urgency.