eBPF Reachability Architecture
Audience: engineers working on (or integrating with) the Stella Ops eBPF reachability collector.
This document details the internal architecture of the runtime reachability evidence system — its kernel-space probes, user-space pipeline, evidence schema, data flow, and signing chain. For an orientation and the CLI surface, see the eBPF Reachability Overview; for installation and operation, see the Deployment Guide.
Implementation status (verify against
src/). This document describes theStellaOps.Signals.Ebpflibrary (src/Signals/__Libraries/StellaOps.Signals.Ebpf). The hand-written BPF C programs (Probes/Bpf/*.bpf.c), the user-space parsing/enrichment/output/signing pipeline, the deterministic BTF source selector, and thesignals verify-chainCLI are implemented. Live kernel attachment is not yet wired: the defaultCoreProbeLoadersimulates probe loading and drains an in-memory buffer (see CoreProbeLoader below). Treat the kernel-attach/throughput claims as design targets until a libbpf binding lands.
System Overview
The eBPF reachability system is designed to capture kernel-level events to provide cryptographic proof of runtime behavior. It targets Linux eBPF (extended Berkeley Packet Filter) with CO-RE (Compile Once, Run Everywhere) for portable deployment across kernel versions.
Design Principles
- Minimal Kernel Footprint: eBPF programs perform only essential filtering and data capture
- User-Space Enrichment: Complex lookups (symbols, containers, SBOMs) happen in user space
- Deterministic Output: Same inputs produce byte-identical NDJSON output
- Chain of Custody: Every evidence chunk is cryptographically signed and linked
Component Architecture
Kernel-Space Components
Ring Buffer (BPF_MAP_TYPE_RINGBUF)
- Single shared
eventsbuffer for all event types (default 256KB, seestella_common.h) - Multi-producer; every probe emits via the reserve/commit pattern (
bpf_ringbuf_reserve()→ fill →bpf_ringbuf_submit()/bpf_ringbuf_discard()). Asubmit_event()helper wrappingbpf_ringbuf_output()is defined instella_common.hbut is currently unused (no probe calls it). - Automatic backpressure: a failed
bpf_ringbuf_reserve()increments the dropped-event counter in the per-CPUprobe_statsmap (events_dropped) and the event is skipped. Statistics live only in that per-CPU map; they are not emitted through the ring buffer.
Tracepoint Probes
Defined across syscall_openat.bpf.c, syscall_exec.bpf.c, and syscall_network.bpf.c.
| Probe | Event Type | Purpose |
|---|---|---|
tracepoint/syscalls/sys_enter_openat | File access | Track which files are opened |
tracepoint/syscalls/sys_enter_open | File access | Legacy open() for older kernels |
tracepoint/sched/sched_process_exec | Process execution | Track binary invocations (+ PID→PPID map) |
tracepoint/sched/sched_process_exit | Process exit | Clean up the PID→PPID tracking map |
tracepoint/syscalls/sys_enter_execve | Process execution | Capture argv[0] for richer exec context |
tracepoint/syscalls/sys_enter_execveat | Process execution | execveat variant |
tracepoint/sock/inet_sock_set_state | TCP state | Track TCP connection state changes |
A kprobe/tcp_set_state fallback is declared for kernels lacking the inet_sock_set_state tracepoint, but its body is currently a stub (returns 0).
Uprobe / Uretprobe Probes
Network/SSL uprobes attach by symbol within libc/libssl (see uprobe_libc.bpf.c, uprobe_openssl.bpf.c); the generic function tracer attaches to arbitrary target functions (function_tracer.bpf.c).
| Probe | Library | Purpose |
|---|---|---|
uprobe/uretprobe libc:connect | glibc/musl | Outbound connections (args saved on entry, emitted on return) |
uprobe/uretprobe libc:accept / accept4 | glibc/musl | Inbound connections |
uprobe/uretprobe libc:read / libc:write | glibc/musl | Per-(pid,fd) byte counting (no per-call event) |
uprobe/uretprobe libssl:SSL_read / SSL_write | OpenSSL | TLS traffic, aggregated per SSL session |
uprobe/uretprobe libssl:SSL_read_ex / SSL_write_ex | OpenSSL 1.1.1+ | _ex variants (delegate to the base handlers) |
uprobe function_entry | any | Generic function-call tracing with optional stack capture, symbol filter, and sampling |
BPF Maps for Filtering
The shared maps live in Probes/Bpf/stella_common.h. Container targeting is the only in-kernel filter that gates every probe (via the should_trace_cgroup() helper):
// Cgroup filter for container targeting (Probes/Bpf/stella_common.h)
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1024);
__type(key, u64); // cgroup_id
__type(value, u8); // 1 = trace, 0 = ignore
} target_cgroups SEC(".maps");
// Symbol filter for the function tracer (uprobe targets only)
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10000);
__type(key, u64); // function address
__type(value, u8); // 1 = trace
} target_symbols SEC(".maps");
Individual probes add their own filter maps in user-configurable arrays — e.g. openat_config/path_filters (file paths), tcp_filter_config/dest_cidr_filters (TCP state and CIDR), tracer_config (sampling/stack capture), and ssl_op_config.
Namespace filtering is user-space, not a BPF map. There is no
namespace_filtermap in the kernel programs. Linux-namespace-based multi-tenant isolation is implemented entirely in user space byCgroupContainerResolver/NamespaceFilter, which read/proc/{pid}/ns/*inodes (pid/mnt/net/user/cgroup) and match against a configuredNamespaceFilter.
User-Space Components
CoreProbeLoader
Manages the eBPF program lifecycle (Probes/CoreProbeLoader.cs, implements IEbpfProbeLoader):
- Resolves the compiled probe object directory (
/usr/share/stellaops/probes,/opt/stellaops/probes, or<base>/probes) and the container’s PIDs via cgroup paths //procscanning - Throws
PlatformNotSupportedExceptionoff Linux - Exposes detach, ring-buffer drain, symbol resolution, and buffer/CPU/memory stats
NOT YET A LIVE libbpf LOADER. The current
CoreProbeLoaderis a development/test harness: probe attachment is simulated and events are drained from aSimulatedRingBuffer(a bounded in-memory channel). The source explicitly notes the production path (“In real implementation, this would: 1. Load BPF object using libbpf; 2. Create ring buffer map; 3. Attach uprobes…”).GetCpuOverhead/GetBufferUtilizationreturn placeholder constants. The compiled.bpf.oartifacts and the libbpf binding are not wired in yet — treat live kernel attachment as roadmap. The hand-written BPF C programs inProbes/Bpf/are real but are not compiled or loaded by this loader today.
AirGapProbeLoader
Offline probe distribution (Probes/AirGapProbeLoader.cs, IAirGapProbeLoader):
- Loads pre-compiled probes from embedded resources or a sealed
ebpf-probes.bundle(a ZIP with amanifest.json), with no build tools or network at runtime - Verifies each probe’s SHA-256 against the manifest; can author bundles via
CreateBundleAsync - Enabled by
AddEbpfRuntimeEvidenceAirGap(...)/EbpfEvidenceOptions.UseAirGapMode
EventParser
Parses binary events from the ring buffer (Parsers/EventParser.cs):
- 48-byte common header (
timestamp_ns, pid, tid,cgroup_id, event-type discriminator,comm) followed by type-specific payloads parsed little-endian - Dispatches on the
event_typediscriminator to FileOpen / ProcessExec / TcpState / NetConnect / SslOp / FunctionCall records; resolves symbols for function-call events - Timestamps are carried through as the raw kernel boot-time
timestamp_ns(bpf_ktime_get_boot_ns); the parser does not convert them to wall-clock time
RuntimeEvidenceCollector (unified) and RuntimeSignalCollector (legacy adapter)
Two collector facades coordinate the pipeline:
RuntimeEvidenceCollector(Services/) is the unified collector: it loads probes, drains the ring buffer, parses + enriches (cgroup resolver) records, writes NDJSON, and raisesChunkCompletedon rotation.RuntimeSignalCollectoris the legacyIRuntimeSignalCollectoradapter focused on function-call signals: it aggregatesObservedCallPaths, computesNodeHash/PathHash/ combined-path-hash and BTF-selection metadata, and feeds the Scanner merger. Gates onIsSupported()(which is false when no BTF source is selected).
CgroupContainerResolver
Maps PIDs / kernel cgroup IDs to container identities (Cgroup/CgroupContainerResolver.cs):
- Parses
/proc/{pid}/cgroupfor container runtime paths - Supports containerd, Docker, CRI-O, and Podman path formats (64-hex-char IDs)
- Resolves primarily by PID; cgroup-ID lookups rely on a cache populated via
RegisterCgroupMapping(a cold cgroup-ID returns null rather than scanning/proc) - Also reads Linux namespace inodes from
/proc/{pid}/ns/*and supports aNamespaceFilterfor user-space multi-tenant isolation - Caches per-PID/per-cgroup/per-namespace identities in plain
ConcurrentDictionarymaps with no time-based eviction (theCacheTtl = 5 minconstant is declared but not enforced here — entries are dropped only viaInvalidatePid/Dispose). Note: the 5-minute TTL is live inEnhancedSymbolResolver(itsIMemoryCachesliding expiration), not in this resolver.
EnhancedSymbolResolver
Resolves addresses to human-readable symbols (Symbols/EnhancedSymbolResolver.cs):
- Parses
/proc/{pid}/mapsfor ASLR offsets, then maps the address back to a file offset within the containing mapping - Reads ELF symbol tables (
.symtab=SHT_SYMTAB,.dynsym=SHT_DYNSYM) for 64-bit ELF only; 32-bit ELF parsing is a stub that returns null and falls back to address-based identifiers. There is no DWARF parsing — the source has no debug-line support; unresolved addresses degrade toaddr:0x{hex}/{symbol}+0x{offset}forms. - Caches resolved symbols in a size-limited
IMemoryCache(sliding 5-min TTL, size limitEbpfEvidenceOptions.SymbolCacheSizeLimit) plus per-process/proc/{pid}/mapsand per-file ELF symbol-table dictionaries
RuntimeEventEnricher
Decorates events with container and SBOM metadata:
- Container ID and image digest correlation
- SBOM component (PURL) lookup
- Graceful degradation on missing metadata
RuntimeEvidenceNdjsonWriter
Produces deterministic NDJSON output (Output/RuntimeEvidenceNdjsonWriter.cs):
- Deterministic JSON:
snake_caseproperty names, nulls omitted, no indentation, relaxed escaping (the writer relies on the record’s property ordering rather than an explicit key-sort pass) - Rolling SHA-256 content hash (
IncrementalHashover each serialized line + newline); the resulting chunk hash is reported assha256:<hex>. (The source comment calls this “BLAKE3-like using SHA256 incremental” — the algorithm is SHA-256, not BLAKE3.) - Size-based (default 100 MB) and time-based (default 1 hour) rotation, optional gzip, and a
ChunkRotatedcallback that carries chunk statistics and the previous chunk hash
EvidenceChunkFinalizer
Signs and links evidence chunks (Signing/EvidenceChunkFinalizer.cs):
- Builds a
RuntimeEvidencePredicate(predicateTypestella.ops/runtime-evidence@v1) and an in-toto v0.1 statement with chunk metadata - Delegates signing to an
IEvidenceChunkSigner; submits to Rekor when requested - Maintains per-chain state (
previous_chunk_idlinkage, totals) and can persist/restore it to disk for recovery across restarts - Provides
VerifyChainAsync, which checks signatures, chain linkage, sequence continuity, and time monotonicity
IEvidenceChunkSigner implementations
AttestorEvidenceChunkSigner(production): signs via the AttestorIAttestationSigningServiceand submits viaIRekorClient. (Per the module map the historical Signer module is consolidated into Attestor — these abstractions live inStellaOps.Attestor.Core.) Payload typeapplication/vnd.in-toto+json; the statement is canonicalized withCanonJsonbefore signing.VerifyAsyncchecks Rekor inclusion when a UUID is present.LocalEvidenceChunkSigner(dev/test): produces a deterministic DSSE envelope by computing the DSSE PAE and an HMAC throughICryptoHmac/HmacPurpose.Signing(algorithm follows the active compliance profile per CoC §7.4 — HMAC-SHA256 on world/fips/kcmvp/eidas, HMAC-GOST3411 on gost, HMAC-SM3 on sm). Verifies with a fixed-time HMAC comparison; does not submit to Rekor.NullEvidenceChunkSigner: emits an unsigned predicate (no DSSE envelope) for paths where signing is disabled.
Evidence Record Schema (runtime-evidence/v1)
Each NDJSON line is a RuntimeEvidenceRecord (Schema/RuntimeEvidence.cs). Common envelope fields, then a polymorphic event object discriminated by type:
{
"ts_ns": 12345678901234, // boot-time nanoseconds (bpf_ktime_get_boot_ns)
"src": "sys_enter_openat", // probe/source name
"pid": 4711,
"tid": 4711,
"cgroup_id": 1234567890,
"container_id": "containerd://abc…", // enriched; omitted when null
"image_digest": "sha256:…", // enriched; omitted when null
"comm": "nginx",
"event": { "type": "file_open", /* … */ }
}
Event payload variants (type discriminator → fields):
type | Key fields |
|---|---|
file_open | path, flags, derived access (read/write/read_write), dfd, mode |
process_exec | filename, ppid, argv0 |
tcp_state | oldstate/newstate (named TCP states), daddr/dport, saddr/sport, family |
net_connect | fd, addr, port, success, error |
ssl_op | operation (read/write), bytes, ssl_ptr |
function_call | addr, symbol, library, runtime, stack[], node_hash |
The record carries no random identifiers, supporting the byte-identical-output goal.
Data Flow
1. Kernel Event
│
├─► Tracepoint/Uprobe fires
│ └─► BPF program captures event data
│ └─► Filter by cgroup/namespace (optional)
│ └─► Submit to ring buffer
│
2. Ring Buffer Drain
│
├─► EventParser reads binary data
│ └─► Deserialize to typed event struct
│ └─► Validate event integrity
│
3. Resolution & Enrichment
│
├─► SymbolResolver: address → symbol name (function-call events, in EventParser)
├─► CgroupResolver: cgroup_id / pid → container_id (RuntimeEvidenceCollector.EnrichRecord)
│
│ Optional deeper enrichment via RuntimeEventEnricher (Enrichment/):
│ ├─► IContainerStateProvider: container_id → image_ref / image_digest
│ ├─► IImageDigestResolver: image_ref → image_digest
│ └─► ISbomComponentProvider: image_digest → purls[]
│ (These providers are interfaces; defaults degrade gracefully — e.g.
│ NullSbomComponentProvider, LocalContainerIdentityResolver with no image digests.
│ The unified RuntimeEvidenceCollector wires only the cgroup resolver today.)
│
4. Serialization
│
├─► RuntimeEvidenceNdjsonWriter
│ ├─► Canonical JSON serialization
│ ├─► Append to current chunk file
│ └─► Update rolling hash
│
5. Rotation & Signing
│
├─► Size/time threshold reached
│ └─► Close current chunk
│ └─► ChunkFinalizer
│ ├─► Create in-toto statement
│ ├─► Sign with DSSE
│ ├─► Submit to Rekor
│ └─► Link to previous chunk
│
6. Verification
│
└─► CLI `signals verify-chain` (StellaOps.Cli SignalsCommandGroup)
│ → EvidenceChunkFinalizer.VerifyChainAsync
├─► Verify signatures (Rekor inclusion when a UUID is present)
├─► Check chain linkage (previous_chunk_id)
├─► Check sequence continuity (chunk_sequence)
└─► Validate time monotonicity (no overlapping time ranges)
The signals command group also exposes inspect, list, and summary subcommands.
Performance Characteristics
These are design targets, not measured benchmarks. The kernel-space numbers in particular assume a live libbpf attachment, which is still roadmap (see CoreProbeLoader above); the current simulated loader reports placeholder CPU/buffer figures. Rate limiting is real and enforced in
RuntimeSignalCollectorviaMaxEventsPerSecond.
Kernel-Space
- Ring buffer prevents event loss under load (backpressure)
- In-kernel filtering reduces user-space processing
- BTF enables zero-copy field access
User-Space
| Operation | Target Latency |
|---|---|
| Cached symbol lookup | < 1ms p99 |
| Uncached symbol lookup | < 10ms p99 |
| Container enrichment | < 10ms p99 |
| NDJSON write | < 1ms p99 |
Throughput
- Target: 100,000 events/second sustained
- Rate limiting available for resource-constrained environments
Memory Budget
| Component | Default | Configurable |
|---|---|---|
Ring buffer (RingBufferSize / events map) | 256 KB | Yes |
Symbol cache (EbpfEvidenceOptions.SymbolCacheSizeLimit) | 100,000 size units | Yes |
| Container / enrichment cache TTL | 5 min | Yes (constructor TTL) |
Write buffer (NdjsonWriterOptions.BufferSize) | 64 KB | Yes |
| NDJSON chunk rotation | 100 MB / 1 hour | Yes |
Note: the symbol resolver declares an internal
MaxCachedSymbolsPerProcess = 10000constant, but it is currently unreferenced (dead code) — the only live symbol-cache bound is the globalIMemoryCachesize limit above. Do not rely on a per-process cap.
Failure Modes
Ring Buffer Overflow
- Symptom: Events dropped, warning logged
- Mitigation: Increase buffer size or enable rate limiting
Symbol Resolution Failure
- Symptom: Address shown as
addr:0x{hex} - Mitigation: Ensure debug symbols available or accept address-only evidence
Container Resolution Failure
- Symptom:
container_id = "unknown:{cgroup_id}"— but only on theRuntimeEventEnricherpath (FormatUnknownContainer). The unifiedRuntimeEvidenceCollector.EnrichRecordleavescontainer_idnull/omitted when the cgroup/PID cannot be resolved; it does not emit anunknown:sentinel. - Mitigation: Verify Zastava integration, check cgroup path format support (containerd/ docker/cri-o/podman 64-hex
.scopepaths)
Signing Failure
- Symptom: Rekor submission fails — a warning is logged and the chunk’s DSSE signature is still returned (the in-toto statement is signed before Rekor submission; only transparency-log inclusion is skipped). With
NullEvidenceChunkSigner, no DSSE envelope is produced at all. - Mitigation: Check Attestor signing service (
IAttestationSigningService) and Rekor (IRekorClient) availability. For development,LocalEvidenceChunkSignerproduces a self-contained HMAC-signed envelope with no transparency-log dependency.
CO-RE (Compile Once, Run Everywhere)
The probes include bpf/bpf_core_read.h and use BTF (BPF Type Format) for kernel-version-independent field access. The illustrative CO-RE pattern is:
// Illustrative: CO-RE field access without hardcoded offsets
struct task_struct *task = (void *)bpf_get_current_task();
pid_t pid = BPF_CORE_READ(task, pid);
pid_t tgid = BPF_CORE_READ(task, tgid);
In the current probes the equivalent reads are written explicitly with bpf_probe_read_kernel(...) against generated __bindgen_anon_1 accessors (see syscall_exec.bpf.c, which reads the parent task and its tgid this way) rather than the BPF_CORE_READ convenience macro.
BTF source selection is deterministic (Services/RuntimeBtfSourceSelector.cs), in precedence order:
- Kernel built-in BTF at
/sys/kernel/btf/vmlinux - Configured external
vmlinuxBTF paths - Split-BTF fallback under
/var/lib,/usr/share, or/usr/libstellaops/btf/split/<release>/<arch>/…
The selected source’s path and SHA-256 digest are recorded on the collection summary. If no source is found (or the host is non-Linux), collection reports IsSupported() == false.
Requirements:
- Kernel 5.2+ with built-in BTF (recommended)
- Older kernels (e.g. 4.14+) require external/split BTF supplied via the paths above (offline-friendly; no btfhub network fetch in the offline-first posture)
Integration Points
Container state (Zastava or local)
IContainerIdentityResolverinterface (resolve by container-id / pid / cgroup-id, plusContainerStarted/ContainerStoppedlifecycle events)LocalContainerIdentityResolveris the default fallback — wraps the localCgroupContainerResolver, uses/procintrospection only, and does not emit lifecycle events or image digests. Container lifecycle and image-digest mapping require an external resolver (e.g. a Zastava-backed implementation).IContainerStateProvidersupplies container metadata (image ref/digest) for enrichment.
Scanner (Reachability Merger)
EbpfSignalMerger(Scanner.Reachability/Runtime/) merges eBPF call paths with the static call graph by delegating toRuntimeStaticMerger.- Correlation is by caller→callee symbol-name edges (it converts each observed call path into per-edge
RuntimeCallEvents keyed on symbol names). The collector separately computes a per-node hash (NodeHash= hash of PURL + symbol) and combinedPathHashon the runtime summary, but the merger itself correlates on symbol names, not on those hashes. (NoRuntimeNodeHashtype exists in the merger; the name appears only as a test class.)
Evidence signing (Attestor)
IAttestationSigningServicefor DSSE signatures andIRekorClientfor transparency-log submission, both fromStellaOps.Attestor.Core. The historical Signer module is consolidated into Attestor.
SBOM correlation
ISbomComponentProviderfor PURL lookup by image digestCachingSbomComponentProvideradds a TTL cache;NullSbomComponentProvideris the no-op default when no SBOM integration is configured
Authorization
StellaOps.Signals.Ebpf is an internal collection library with no HTTP surface of its own. Where runtime/reachability signals are exposed (the Signals service and the signals CLI group), access is governed by the canonical reachability-signal scopes from StellaOps.Auth.Abstractions/StellaOpsScopes.cs:
| Scope constant | Value | Grants |
|---|---|---|
SignalsRead | signals:read | Read-only access to reachability signals |
SignalsWrite | signals:write | Write/ingest reachability signals |
SignalsAdmin | signals:admin | Administrative access to signal ingestion |
(Verify the policy registration vs. the scope constant before relying on a specific endpoint’s requirement — ASP.NET policy names and scope claim values can differ.)
