Closes polling's blind spot (processes that exit between sweeps) with a real
eBPF probe and a declarative, data-driven detection engine — inspired by Snort
(rule language, signature IDs) and OSSEC (host-IDS framing).
New events/ subpackage:
- bcc_source.py : eBPF C tracing execve (filename, argv[1..2], ppid, uid,
parent comm) over a perf buffer, loaded via bcc; lazy import
+ available()/try-except so it fails closed to poll-only when
bcc/root/BTF are absent — a broken probe never downs the daemon
- exec_event.py : the ExecEvent type
- rules.py : ExecRule (sid/msg/severity/classtype + path/exec/parent/argv
conditions) and ExecRuleEngine; 4 shipped rules (fileless exec
100001, reverse-shell argv 100002, web/DB→shell RCE 100003,
curl|sh 100004); operators add more via exec_rules_file TOML
- monitor.py : runs the source on a thread, routes events through the engine
Integration:
- daemon starts the monitor, shares a lock-guarded cooldown with the sweep
loop, and feeds event alerts into the same snapshot pipeline
- Alert gains Snort-style sid + classtype; retrofitted onto all 7 poll
detectors; snapshots and JSON now carry them
- config: ebpf_exec_monitor (default on, degrades), exec_rules_file
- systemd: opt-in ebpf.conf drop-in (relaxes MemoryDenyWriteExecute + widens
caps for bcc's JIT) so the base unit stays hardened for poll-only
- sentinel-redteam: ebpf_exec drill (short-lived /tmp exec + /dev/tcp argv the
poller can't see); footer now uses the Python CLI
Tests: +14 cases for the rule engine (each default rule match/non-match, rule
validation, parent-exclude). 39/39 pass. Graceful non-root degradation verified.
NOTE: the eBPF C follows bcc's execsnoop pattern but could not be run here
(BPF needs root); it wants a root smoke-test on a real host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
4.4 KiB
Python
139 lines
4.4 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""eBPF execve event source, built on bcc.
|
|
|
|
Loads a small eBPF program that fires on every ``execve``, capturing the
|
|
executed path, the first couple of arguments, the uid, and the parent. Events
|
|
are delivered to user space over a perf buffer and decoded into ``ExecEvent``.
|
|
|
|
This is intentionally fail-safe: if bcc isn't installed, the kernel lacks BPF,
|
|
or we aren't root, ``available()`` returns False and the daemon simply runs with
|
|
its poll-based detectors. A broken probe must never take the daemon down.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Callable
|
|
|
|
from .exec_event import ExecEvent
|
|
|
|
# eBPF C. bcc rewrites pointer dereferences (e.g. task->real_parent->tgid) into
|
|
# bpf_probe_read calls, so this stays close to bcc's own execsnoop example.
|
|
_BPF_PROGRAM = r"""
|
|
#include <uapi/linux/ptrace.h>
|
|
#include <linux/sched.h>
|
|
|
|
#define ARGLEN 160
|
|
|
|
struct data_t {
|
|
u32 pid;
|
|
u32 ppid;
|
|
u32 uid;
|
|
char parent_comm[TASK_COMM_LEN];
|
|
char filename[ARGLEN];
|
|
char arg1[ARGLEN];
|
|
char arg2[ARGLEN];
|
|
};
|
|
BPF_PERF_OUTPUT(events);
|
|
|
|
int syscall__execve(struct pt_regs *ctx,
|
|
const char __user *filename,
|
|
const char __user *const __user *__argv,
|
|
const char __user *const __user *__envp)
|
|
{
|
|
struct data_t data = {};
|
|
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
|
|
|
|
data.pid = bpf_get_current_pid_tgid() >> 32;
|
|
data.ppid = task->real_parent->tgid;
|
|
data.uid = bpf_get_current_uid_gid() & 0xffffffff;
|
|
bpf_get_current_comm(&data.parent_comm, sizeof(data.parent_comm));
|
|
bpf_probe_read_user_str(&data.filename, sizeof(data.filename), filename);
|
|
|
|
const char __user *argp = NULL;
|
|
bpf_probe_read_user(&argp, sizeof(argp), &__argv[1]);
|
|
if (argp)
|
|
bpf_probe_read_user_str(&data.arg1, sizeof(data.arg1), argp);
|
|
argp = NULL;
|
|
bpf_probe_read_user(&argp, sizeof(argp), &__argv[2]);
|
|
if (argp)
|
|
bpf_probe_read_user_str(&data.arg2, sizeof(data.arg2), argp);
|
|
|
|
events.perf_submit(ctx, &data, sizeof(data));
|
|
return 0;
|
|
}
|
|
"""
|
|
|
|
|
|
def available() -> tuple[bool, str]:
|
|
"""Return (ok, reason). ok=True means the probe can be loaded."""
|
|
if os.geteuid() != 0:
|
|
return False, "not running as root (BPF requires CAP_BPF/root)"
|
|
try:
|
|
import bcc # noqa: F401
|
|
except ImportError:
|
|
return False, "python bcc (python-bpfcc) not installed"
|
|
if not os.path.exists("/sys/kernel/btf/vmlinux") and not os.path.isdir(
|
|
"/lib/modules/%s/build" % os.uname().release
|
|
):
|
|
return False, "no kernel BTF or headers for BPF compilation"
|
|
return True, "ok"
|
|
|
|
|
|
class BccExecSource:
|
|
"""Loads the eBPF probe and pumps decoded ExecEvents to a callback."""
|
|
|
|
def __init__(self, on_event: Callable[[ExecEvent], None]) -> None:
|
|
self._on_event = on_event
|
|
self._bpf = None
|
|
self._running = False
|
|
|
|
def start(self) -> None:
|
|
from bcc import BPF
|
|
|
|
self._bpf = BPF(text=_BPF_PROGRAM)
|
|
fnname = self._bpf.get_syscall_fnname("execve")
|
|
self._bpf.attach_kprobe(event=fnname, fn_name="syscall__execve")
|
|
self._bpf["events"].open_perf_buffer(self._handle, page_cnt=64)
|
|
self._running = True
|
|
|
|
def poll(self, timeout_ms: int = 200) -> None:
|
|
if self._bpf is not None:
|
|
self._bpf.perf_buffer_poll(timeout=timeout_ms)
|
|
|
|
def stop(self) -> None:
|
|
self._running = False
|
|
if self._bpf is not None:
|
|
try:
|
|
self._bpf.cleanup()
|
|
except Exception:
|
|
pass
|
|
self._bpf = None
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
return self._running
|
|
|
|
def _handle(self, cpu, data, size) -> None:
|
|
event = self._bpf["events"].event(data)
|
|
argv = tuple(
|
|
a for a in (_decode(event.arg1), _decode(event.arg2)) if a
|
|
)
|
|
ev = ExecEvent(
|
|
pid=event.pid,
|
|
ppid=event.ppid,
|
|
uid=event.uid,
|
|
parent_comm=_decode(event.parent_comm),
|
|
filename=_decode(event.filename),
|
|
argv=argv,
|
|
)
|
|
try:
|
|
self._on_event(ev)
|
|
except Exception:
|
|
# one bad event must not kill the poll loop
|
|
pass
|
|
|
|
|
|
def _decode(raw) -> str:
|
|
if isinstance(raw, bytes):
|
|
return raw.split(b"\x00", 1)[0].decode("utf-8", "replace")
|
|
return str(raw)
|