17 KiB
Architecture - System Design and Technical Decisions
High Level Architecture
┌────────────────────────────────────────────────────────┐
│ User Space │
│ │
│ ┌─────────┐ │
│ │ main.py │ CLI entrypoint │
│ │ (Typer) │ parses args, wires components │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌─────────────┐ ┌────────────────┐ │
│ │loader.py │──▶│processor.py │──▶│ renderer.py │ │
│ │ │ │ │ │ │ │
│ │ Compiles │ │ Parses raw │ │ JSON / Live / │ │
│ │ & loads │ │ events, │ │ Table output │ │
│ │ eBPF C │ │ enriches │ │ │ │
│ │ programs │ │ from /proc │ └────────────────┘ │
│ │ │ │ │ │
│ │ Sets up │ │ Filters by │ │
│ │ ring buf │ │ severity, │ │
│ │ callback │ │ PID, comm │ ┌────────────────┐ │
│ └──────────┘ │ │──▶│ detector.py │ │
│ └─────────────┘ │ │ │
│ │ Stateless │ │
│ │ rules + │ │
│ │ stateful │ │
│ │ correlation │ │
│ └────────────────┘ │
├────────────────────────────────────────────────────────┤
│ Kernel Space │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Ring Buffer (shared) │ │
│ │ BPF_RINGBUF_OUTPUT, 256KB │ │
│ └──────────┬──────────┬──────────┬─────────────┘ │
│ │ │ │ │
│ ┌──────────┴───┐ ┌────┴────┐ ┌──┴──────────┐ │
│ │process_tracer│ │file_ │ │network_ │ │
│ │ .c │ │tracer.c │ │tracer.c │ │
│ │ │ │ │ │ │ │
│ │ sys_enter_ │ │sys_enter│ │sys_enter_ │ │
│ │ execve │ │_openat │ │connect │ │
│ │ sys_enter_ │ │sys_enter│ │sys_enter_ │ │
│ │ clone │ │_unlinkat│ │accept4 │ │
│ │ │ │sys_enter│ │sys_enter_ │ │
│ │ │ │_rename │ │bind/listen │ │
│ └─────────────┘ └─────────┘ └─────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │privilege_ │ │system_ │ │
│ │tracer.c │ │tracer.c │ │
│ │ │ │ │ │
│ │sys_enter_ │ │sys_enter_ │ │
│ │setuid │ │ptrace │ │
│ │sys_enter_ │ │sys_enter_ │ │
│ │setgid │ │mount │ │
│ │ │ │sys_enter_ │ │
│ │ │ │init_module │ │
│ └─────────────┘ └─────────────┘ │
└────────────────────────────────────────────────────────┘
Component Breakdown
main.py - CLI and Orchestration
Parses command line arguments via Typer and wires together the loader, processor, detector, and renderer. Handles signal-based shutdown. This is the thinnest layer: it contains no business logic, just plumbing.
loader.py - eBPF Program Lifecycle
Reads .c files from the ebpf/ directory, compiles them via BCC, attaches them to kernel tracepoints, and sets up ring buffer polling. Also handles cleanup: detaching eBPF programs and freeing BPF objects when the tool stops.
processor.py - Event Parsing and Enrichment
Defines RawEvent (a ctypes Structure mirroring the C struct) and TracerEvent (a Python dataclass with enriched fields). Converts raw bytes from the ring buffer into structured Python objects. Enriches events with data from /proc (parent process name, username resolution). Implements filtering logic.
detector.py - Detection Engine
Contains all security detection logic. Stateless rules evaluate individual events (e.g., "is this a setuid(0) by non-root?"). Stateful rules correlate events across time using a per-PID sliding window (e.g., "was there a connect before this shell execve?"). Returns Detection objects that get stamped onto events.
renderer.py - Output Formatting
Three output modes. LiveRenderer uses Rich for color-coded streaming. JsonRenderer writes one JSON object per line to stdout. TableRenderer buffers events and periodically renders Rich tables. FileRenderer writes JSON to a file alongside any other output mode.
config.py - Constants and Rule Metadata
All magic numbers, file paths, detection rule definitions, severity levels, and event type mappings live here. Nothing is hardcoded elsewhere. Changing a detection rule's severity or adding a new sensitive file path only requires editing this file.
ebpf/*.c - Kernel-Space Programs
Five C files, one per syscall category. Each defines a TRACEPOINT_PROBE that fires on the corresponding syscalls:sys_enter_* event. Programs capture event data into a shared struct and push it to the ring buffer. The C code is intentionally minimal, all detection logic stays in Python.
Data Flow
Step by Step: From Syscall to Alert
1. Process calls execve("/bin/bash")
│
2. Kernel hits tracepoint syscalls:sys_enter_execve
│
3. eBPF program (process_tracer.c) fires:
- Reserves space in ring buffer
- Fills struct: pid, ppid, uid, comm, filename, timestamp
- Submits to ring buffer
│
4. Python callback (on_event in main.py) fires:
- parse_raw_event() casts raw bytes to RawEvent ctypes struct
- Converts to TracerEvent dataclass
- Decodes comm/filename from null-terminated bytes
- Converts kernel timestamp to wall clock datetime
- Resolves UID to username via pwd module
│
5. enrich_event() adds parent process name from /proc
│
6. detector.evaluate() checks:
- Stateless: Is the event itself suspicious? No.
- Stateful: Is this a shell? Yes (bash). Was there a
recent connect from this PID? Check history deque.
If yes -> Detection("Reverse Shell", CRITICAL)
│
7. should_include() applies user's filters:
- Severity >= minimum? PID matches? Comm matches?
│
8. renderer.render() outputs:
[14:30:01] CRITICAL execve pid=1234 comm=bash
/bin/bash [Reverse Shell]
Design Patterns
Pattern: Kernel Simplicity, Userspace Complexity
The eBPF C programs do the bare minimum: read syscall arguments, fill a struct, push to ring buffer. All the interesting work (detection, correlation, enrichment, formatting) happens in Python.
Why? eBPF programs run inside the kernel with strict constraints:
- 512-byte stack limit
- No dynamic memory allocation
- No string manipulation beyond
bpf_probe_read_* - The verifier rejects anything complex
Moving logic to userspace also means you can change detection rules without recompiling eBPF programs, and you can unit test detection logic without root privileges.
Pattern: Single Event Struct
All five eBPF programs use the same struct event layout, even though not every field is relevant to every event type. A process event doesn't need addr_v4 and a network event doesn't need filename, but they share the same struct.
This seems wasteful (the struct is ~300 bytes with mostly-zero fields for most events), but it has major advantages:
- One
RawEventctypes definition in Python, not five - One ring buffer callback, not five
- Simpler code, fewer bugs
The alternative (per-type structs with discriminated unions) would save memory but add complexity that isn't justified at this scale.
Pattern: Deque-Based Correlation
The detection engine maintains a collections.deque per PID with a max length. Events older than the correlation window are pruned on each evaluation. This gives O(1) append and O(n) scanning where n is small (max 64 events per PID, 10-second window).
For a tool tracing a typical server, this means ~1000 deques in memory (one per active PID), each holding a few events. Total memory for correlation: a few megabytes at most.
Trade-offs
Ring buffer vs perf buffer: Ring buffer (used here) requires kernel 5.8+ but provides event ordering guarantees and lower overhead via the reserve/submit zero-copy API. Perf buffer works on older kernels (4.4+) but has per-CPU allocation waste and no ordering guarantee.
BCC vs libbpf: BCC requires LLVM on the host and uses ~80MB per tool. libbpf with CO-RE produces ~9MB standalone binaries. For a learning tool, BCC's Python API and iterative development experience win. For production, you'd switch to libbpf.
Tracepoints vs kprobes: Tracepoints are stable ABI, they won't break between kernel versions. Kprobes hook arbitrary kernel functions and can break when internal APIs change. This tool uses tracepoints exclusively.
Data Models
RawEvent (C struct / ctypes)
| Field | Type | Bytes | Purpose |
|---|---|---|---|
| timestamp_ns | u64 | 8 | Kernel monotonic clock |
| pid | u32 | 4 | Process ID |
| ppid | u32 | 4 | Parent process ID |
| uid | u32 | 4 | User ID |
| gid | u32 | 4 | Group ID |
| event_type | u32 | 4 | Enum: EXECVE=1...INIT_MODULE=14 |
| ret_val | u32 | 4 | Return value or flags |
| comm | char[16] | 16 | Process name (TASK_COMM_LEN) |
| filename | char[256] | 256 | File path or device name |
| addr_v4 | u32 | 4 | IPv4 address (network order) |
| port | u16 | 2 | Port number (host order) |
| protocol | u16 | 2 | Address family (AF_INET=2) |
| target_uid | u32 | 4 | Target UID for setuid |
| target_gid | u32 | 4 | Target GID for setgid |
| ptrace_request | u32 | 4 | ptrace operation type |
| target_pid | u32 | 4 | Target PID for ptrace |
| Total | 324 |
TracerEvent (Python dataclass)
Extends RawEvent with:
timestampasdatetime(converted from kernel nanoseconds)usernameresolved from UIDseverity,detection,detection_id,mitre_idfrom detection engineextradict for enrichment data (parent_comm, etc.)
Security Architecture
Privilege Model
The tool requires root (CAP_SYS_ADMIN) to load eBPF programs. It checks at startup with os.geteuid() and exits with a clear message if not root.
eBPF Safety
The kernel verifier ensures eBPF programs cannot:
- Access memory outside their stack or BPF maps
- Execute unbounded loops
- Call arbitrary kernel functions
- Crash the kernel
Cleanup
Signal handlers (SIGINT, SIGTERM) trigger clean shutdown. The TracerLoader.cleanup() method calls bpf.cleanup() on each BPF object, which detaches tracepoints and frees kernel resources. A try/finally block in main.py ensures cleanup runs even on exceptions.
Input Validation
The tool reads from kernel ring buffers (trusted) and /proc (trusted). There's no user input beyond CLI arguments, which Typer validates via type annotations.
Configuration
All configuration lives in config.py as module-level constants:
| Setting | Value | Purpose |
|---|---|---|
| RING_BUFFER_BYTES | 256KB | Size of shared ring buffer |
| CORRELATION_WINDOW_SEC | 10 | Sliding window for stateful detection |
| MAX_EVENTS_PER_PID | 64 | Max events in correlation deque |
| MIN_KERNEL_MAJOR/MINOR | 5.8 | Minimum kernel version |
| SENSITIVE_READ_PATHS | /etc/shadow, etc. | Files that trigger D002 |
| SHELL_BINARIES | sh, bash, etc. | Binaries that count as "shells" |
Performance Considerations
Ring buffer sizing: 256KB is enough for typical workloads. Under extreme syscall rates (>100K/sec), events may be dropped when ringbuf_reserve returns NULL. Increase RING_BUFFER_BYTES for high-throughput environments.
Event enrichment: Reading /proc/<pid>/comm for every event adds latency. The --no-enrich flag disables this for high-volume scenarios.
Username caching: UID-to-username resolution uses a dict cache to avoid repeated pwd.getpwuid() calls.
Detection engine: Stateless rules are O(1) per event. Stateful rules scan the deque, which is bounded at 64 entries, so worst case is O(64) comparisons.
Design Decisions
Why Python, not Go or Rust?
BCC has mature, well-documented Python bindings. Go bindings exist (via cilium/ebpf) but use libbpf, not BCC. Rust has libbpf-rs. For a beginner project focused on teaching eBPF concepts, Python lets readers focus on the eBPF and security concepts rather than language complexity.
Why one ring buffer, not per-tracer?
Each BPF program gets its own BPF_RINGBUF_OUTPUT, but they all use the same struct layout and the same Python callback. This keeps the callback logic simple. The alternative (per-tracer callbacks with per-tracer structs) would require five separate parsing paths.
Why tracepoints, not raw_tracepoints?
Raw tracepoints provide a bpf_raw_tp_args struct with fewer abstractions. They're slightly faster but harder to work with, you need to manually cast arguments. Standard tracepoints provide args-> access with named fields, which is much more readable for a learning project.
Why Typer for CLI?
Consistency with other projects in the repository. Typer provides automatic help generation, type validation, and shell completion with minimal code.
Extensibility
Adding a New Syscall
- Add the event type to
EventTypeenum inconfig.py - Add it to
EVENT_TYPE_CATEGORIES - Write a
TRACEPOINT_PROBEin the appropriate.cfile (or create a new one) - If it needs a new detection rule, add to
DETECTION_RULESand implement indetector.py
Adding a New Detection Rule
- Add a
DetectionRuleentry toDETECTION_RULESinconfig.py - Implement the check in
_check_stateless()or_check_stateful()indetector.py - Add a test in
test_detector.py
Adding a New Output Format
- Create a new renderer class in
renderer.pywith arender(event)method - Add the format name to the
OutputFormatliteral type inconfig.py - Handle it in
create_renderer()
Limitations
- IPv6: Network tracer only parses IPv4 (
sockaddr_in). IPv6 support would require handlingsockaddr_in6and a 128-bit address field. - Container awareness: No container ID or namespace detection. Adding this would require reading
/proc/<pid>/cgroupor using BPF helpers for namespace IDs. - Argument capture: Only the first argument (filename) is captured for execve. Full argv capture requires reading the pointer array, which is complex in eBPF due to verifier constraints.
- File descriptor tracking: The tool doesn't track fd-to-file mappings, so it can't correlate a
connect()fd with a subsequentdup2()call. - No persistence: Events are not stored. For historical analysis, pipe JSON output to a file or a log aggregation system.
Comparison with Production Tools
| Feature | This Tool | Falco | Tetragon | Tracee |
|---|---|---|---|---|
| eBPF backend | BCC (Python) | libs (C) | libbpf (Go) | libbpf (Go) |
| Syscall coverage | 14 | 50+ | 30+ | 40+ |
| Detection rules | 10 | 100+ | Policy-based | 70+ |
| Enforcement | Detect only | Detect only | Detect + block | Detect only |
| Container awareness | No | Yes | Yes | Yes |
| Memory usage | ~80MB | ~50MB | ~30MB | ~60MB |
| Production ready | No (learning) | Yes | Yes | Yes |
This tool is a learning resource. It teaches the same fundamentals that power Falco and Tetragon, but at a scale where every line of code is readable and understandable.