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 the StellaOps.Signals.Ebpf library (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 the signals verify-chain CLI are implemented. Live kernel attachment is not yet wired: the default CoreProbeLoader simulates 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

  1. Minimal Kernel Footprint: eBPF programs perform only essential filtering and data capture
  2. User-Space Enrichment: Complex lookups (symbols, containers, SBOMs) happen in user space
  3. Deterministic Output: Same inputs produce byte-identical NDJSON output
  4. Chain of Custody: Every evidence chunk is cryptographically signed and linked

Component Architecture

Kernel-Space Components

Ring Buffer (BPF_MAP_TYPE_RINGBUF)

Tracepoint Probes

Defined across syscall_openat.bpf.c, syscall_exec.bpf.c, and syscall_network.bpf.c.

ProbeEvent TypePurpose
tracepoint/syscalls/sys_enter_openatFile accessTrack which files are opened
tracepoint/syscalls/sys_enter_openFile accessLegacy open() for older kernels
tracepoint/sched/sched_process_execProcess executionTrack binary invocations (+ PID→PPID map)
tracepoint/sched/sched_process_exitProcess exitClean up the PID→PPID tracking map
tracepoint/syscalls/sys_enter_execveProcess executionCapture argv[0] for richer exec context
tracepoint/syscalls/sys_enter_execveatProcess executionexecveat variant
tracepoint/sock/inet_sock_set_stateTCP stateTrack 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).

ProbeLibraryPurpose
uprobe/uretprobe libc:connectglibc/muslOutbound connections (args saved on entry, emitted on return)
uprobe/uretprobe libc:accept / accept4glibc/muslInbound connections
uprobe/uretprobe libc:read / libc:writeglibc/muslPer-(pid,fd) byte counting (no per-call event)
uprobe/uretprobe libssl:SSL_read / SSL_writeOpenSSLTLS traffic, aggregated per SSL session
uprobe/uretprobe libssl:SSL_read_ex / SSL_write_exOpenSSL 1.1.1+_ex variants (delegate to the base handlers)
uprobe function_entryanyGeneric 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_filter map in the kernel programs. Linux-namespace-based multi-tenant isolation is implemented entirely in user space by CgroupContainerResolver/NamespaceFilter, which read /proc/{pid}/ns/* inodes (pid/mnt/net/user/cgroup) and match against a configured NamespaceFilter.

User-Space Components

CoreProbeLoader

Manages the eBPF program lifecycle (Probes/CoreProbeLoader.cs, implements IEbpfProbeLoader):

NOT YET A LIVE libbpf LOADER. The current CoreProbeLoader is a development/test harness: probe attachment is simulated and events are drained from a SimulatedRingBuffer (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/GetBufferUtilization return placeholder constants. The compiled .bpf.o artifacts and the libbpf binding are not wired in yet — treat live kernel attachment as roadmap. The hand-written BPF C programs in Probes/Bpf/ are real but are not compiled or loaded by this loader today.

AirGapProbeLoader

Offline probe distribution (Probes/AirGapProbeLoader.cs, IAirGapProbeLoader):

EventParser

Parses binary events from the ring buffer (Parsers/EventParser.cs):

RuntimeEvidenceCollector (unified) and RuntimeSignalCollector (legacy adapter)

Two collector facades coordinate the pipeline:

CgroupContainerResolver

Maps PIDs / kernel cgroup IDs to container identities (Cgroup/CgroupContainerResolver.cs):

EnhancedSymbolResolver

Resolves addresses to human-readable symbols (Symbols/EnhancedSymbolResolver.cs):

RuntimeEventEnricher

Decorates events with container and SBOM metadata:

RuntimeEvidenceNdjsonWriter

Produces deterministic NDJSON output (Output/RuntimeEvidenceNdjsonWriter.cs):

EvidenceChunkFinalizer

Signs and links evidence chunks (Signing/EvidenceChunkFinalizer.cs):

IEvidenceChunkSigner implementations

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):

typeKey fields
file_openpath, flags, derived access (read/write/read_write), dfd, mode
process_execfilename, ppid, argv0
tcp_stateoldstate/newstate (named TCP states), daddr/dport, saddr/sport, family
net_connectfd, addr, port, success, error
ssl_opoperation (read/write), bytes, ssl_ptr
function_calladdr, 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 RuntimeSignalCollector via MaxEventsPerSecond.

Kernel-Space

User-Space

OperationTarget Latency
Cached symbol lookup< 1ms p99
Uncached symbol lookup< 10ms p99
Container enrichment< 10ms p99
NDJSON write< 1ms p99

Throughput

Memory Budget

ComponentDefaultConfigurable
Ring buffer (RingBufferSize / events map)256 KBYes
Symbol cache (EbpfEvidenceOptions.SymbolCacheSizeLimit)100,000 size unitsYes
Container / enrichment cache TTL5 minYes (constructor TTL)
Write buffer (NdjsonWriterOptions.BufferSize)64 KBYes
NDJSON chunk rotation100 MB / 1 hourYes

Note: the symbol resolver declares an internal MaxCachedSymbolsPerProcess = 10000 constant, but it is currently unreferenced (dead code) — the only live symbol-cache bound is the global IMemoryCache size limit above. Do not rely on a per-process cap.

Failure Modes

Ring Buffer Overflow

Symbol Resolution Failure

Container Resolution Failure

Signing Failure

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:

  1. Kernel built-in BTF at /sys/kernel/btf/vmlinux
  2. Configured external vmlinux BTF paths
  3. Split-BTF fallback under /var/lib, /usr/share, or /usr/lib stellaops/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:

Integration Points

Container state (Zastava or local)

Scanner (Reachability Merger)

Evidence signing (Attestor)

SBOM correlation

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 constantValueGrants
SignalsReadsignals:readRead-only access to reachability signals
SignalsWritesignals:writeWrite/ingest reachability signals
SignalsAdminsignals:adminAdministrative 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.)

See Also