Add event-driven memory syscall telemetry

This commit is contained in:
Luna 2026-06-13 05:45:30 -07:00
parent 893409b549
commit a51478fa22
18 changed files with 589 additions and 32 deletions

View file

@ -2,8 +2,8 @@
"""Event-driven detection layer.
Where the poll-based detectors sweep system state every few seconds, this layer
reacts to kernel events the instant they happen so a process that executes and
exits between two sweeps (fileless droppers, short-lived reverse shells) is still
caught. Events come from eBPF (``bcc``) and are matched against a declarative,
Snort-style rule set.
reacts to kernel events the instant they happen so a process that executes,
decrypts memory, changes page protections, or exits between two sweeps is still
caught. Events come from eBPF (``bcc``) and are matched against declarative,
Snort-style rule sets.
"""

View file

@ -0,0 +1,229 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""eBPF source for security-relevant syscall telemetry."""
from __future__ import annotations
from collections.abc import Callable
from .bcc_source import _decode, available
from .syscall_event import SyscallEvent
_BPF_PROGRAM = r"""
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#define TEXTLEN 80
#define SYS_MPROTECT 1
#define SYS_MMAP 2
#define SYS_MEMFD_CREATE 3
#define SYS_PTRACE 4
#define SYS_PRCTL 5
#define SYS_SECCOMP 6
#define SYS_PROCESS_VM_READV 7
#define SYS_PROCESS_VM_WRITEV 8
#define SYS_MLOCK 9
#define SYS_MLOCK2 10
#define SYS_MLOCKALL 11
struct data_t {
u32 pid;
u32 ppid;
u32 uid;
char comm[TASK_COMM_LEN];
u32 syscall_id;
u64 arg0;
u64 arg1;
u64 arg2;
u64 arg3;
u64 arg4;
u64 arg5;
char text[TEXTLEN];
};
BPF_PERF_OUTPUT(events);
static int submit(struct pt_regs *ctx, u32 syscall_id,
u64 a0, u64 a1, u64 a2, u64 a3, u64 a4, u64 a5,
const char __user *textp)
{
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.comm, sizeof(data.comm));
data.syscall_id = syscall_id;
data.arg0 = a0;
data.arg1 = a1;
data.arg2 = a2;
data.arg3 = a3;
data.arg4 = a4;
data.arg5 = a5;
if (textp)
bpf_probe_read_user_str(&data.text, sizeof(data.text), textp);
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
int syscall__mprotect(struct pt_regs *ctx, unsigned long start, unsigned long len,
unsigned long prot)
{
return submit(ctx, SYS_MPROTECT, start, len, prot, 0, 0, 0, 0);
}
int syscall__mmap(struct pt_regs *ctx, unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags, unsigned long fd,
unsigned long off)
{
return submit(ctx, SYS_MMAP, addr, len, prot, flags, fd, off, 0);
}
int syscall__memfd_create(struct pt_regs *ctx, const char __user *name,
unsigned int flags)
{
return submit(ctx, SYS_MEMFD_CREATE, flags, 0, 0, 0, 0, 0, name);
}
int syscall__ptrace(struct pt_regs *ctx, long request, long pid,
unsigned long addr, unsigned long data)
{
return submit(ctx, SYS_PTRACE, request, pid, addr, data, 0, 0, 0);
}
int syscall__prctl(struct pt_regs *ctx, int option, unsigned long arg2,
unsigned long arg3, unsigned long arg4, unsigned long arg5)
{
return submit(ctx, SYS_PRCTL, option, arg2, arg3, arg4, arg5, 0, 0);
}
int syscall__seccomp(struct pt_regs *ctx, unsigned int operation,
unsigned int flags, const void __user *args)
{
return submit(ctx, SYS_SECCOMP, operation, flags, (u64)args, 0, 0, 0, 0);
}
int syscall__process_vm_readv(struct pt_regs *ctx, int pid,
const void __user *lvec,
unsigned long liovcnt,
const void __user *rvec,
unsigned long riovcnt,
unsigned long flags)
{
return submit(ctx, SYS_PROCESS_VM_READV, pid, liovcnt, riovcnt, flags, 0, 0, 0);
}
int syscall__process_vm_writev(struct pt_regs *ctx, int pid,
const void __user *lvec,
unsigned long liovcnt,
const void __user *rvec,
unsigned long riovcnt,
unsigned long flags)
{
return submit(ctx, SYS_PROCESS_VM_WRITEV, pid, liovcnt, riovcnt, flags, 0, 0, 0);
}
int syscall__mlock(struct pt_regs *ctx, const void __user *addr, unsigned long len)
{
return submit(ctx, SYS_MLOCK, (u64)addr, len, 0, 0, 0, 0, 0);
}
int syscall__mlock2(struct pt_regs *ctx, const void __user *addr, unsigned long len,
int flags)
{
return submit(ctx, SYS_MLOCK2, (u64)addr, len, flags, 0, 0, 0, 0);
}
int syscall__mlockall(struct pt_regs *ctx, int flags)
{
return submit(ctx, SYS_MLOCKALL, flags, 0, 0, 0, 0, 0, 0);
}
"""
_PROBES = {
"mprotect": "syscall__mprotect",
"mmap": "syscall__mmap",
"memfd_create": "syscall__memfd_create",
"ptrace": "syscall__ptrace",
"prctl": "syscall__prctl",
"seccomp": "syscall__seccomp",
"process_vm_readv": "syscall__process_vm_readv",
"process_vm_writev": "syscall__process_vm_writev",
"mlock": "syscall__mlock",
"mlock2": "syscall__mlock2",
"mlockall": "syscall__mlockall",
}
_SYSCALL_NAMES = {
1: "mprotect",
2: "mmap",
3: "memfd_create",
4: "ptrace",
5: "prctl",
6: "seccomp",
7: "process_vm_readv",
8: "process_vm_writev",
9: "mlock",
10: "mlock2",
11: "mlockall",
}
class BccSyscallSource:
def __init__(self, on_event: Callable[[SyscallEvent], None]) -> None:
self._on_event = on_event
self._bpf = None
self._running = False
self.attach_errors: list[str] = []
def start(self) -> None:
from bcc import BPF
self._bpf = BPF(text=_BPF_PROGRAM)
attached = 0
for syscall, fn_name in _PROBES.items():
try:
event = self._bpf.get_syscall_fnname(syscall)
self._bpf.attach_kprobe(event=event, fn_name=fn_name)
attached += 1
except Exception as exc:
self.attach_errors.append(f"{syscall}: {exc!r}")
if attached == 0:
raise RuntimeError("no syscall probes attached")
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)
ev = SyscallEvent(
pid=event.pid,
ppid=event.ppid,
uid=event.uid,
comm=_decode(event.comm),
syscall=_SYSCALL_NAMES.get(int(event.syscall_id), "unknown"),
arg0=int(event.arg0),
arg1=int(event.arg1),
arg2=int(event.arg2),
arg3=int(event.arg3),
arg4=int(event.arg4),
arg5=int(event.arg5),
text=_decode(event.text),
)
try:
self._on_event(ev)
except Exception:
pass

View file

@ -1,5 +1,5 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Runs the eBPF exec source on a thread and routes events through the engine."""
"""Runs optional eBPF event sources on threads and routes them through rules."""
from __future__ import annotations
import threading
@ -7,9 +7,11 @@ from collections.abc import Callable
from ..alert import Alert
from ..config import Config
from . import bcc_source
from . import bcc_source, bcc_syscall_source
from .exec_event import ExecEvent
from .rules import ExecRuleEngine
from .syscall_event import SyscallEvent
from .syscall_rules import SyscallRuleEngine
class ExecMonitor:
@ -53,3 +55,46 @@ class ExecMonitor:
def stop(self) -> None:
self._stop.set()
self._source.stop()
class SyscallMonitor:
"""Owns the syscall event source + rule engine; emits Alerts via callback."""
def __init__(self, cfg: Config, on_alert: Callable[[Alert], None]) -> None:
self.cfg = cfg
self._on_alert = on_alert
self.engine = SyscallRuleEngine()
self._source = bcc_syscall_source.BccSyscallSource(self._on_event)
self._thread: threading.Thread | None = None
self._stop = threading.Event()
def start(self) -> tuple[bool, str]:
ok, reason = bcc_syscall_source.available()
if not ok:
return False, reason
try:
self._source.start()
except Exception as exc:
return False, f"probe load failed: {exc!r}"
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
reason = "ok"
if self._source.attach_errors:
reason = "ok (some probes unavailable: " + "; ".join(
self._source.attach_errors[:4]) + ")"
return True, reason
def _run(self) -> None:
while not self._stop.is_set():
try:
self._source.poll(timeout_ms=200)
except Exception:
break
def _on_event(self, ev: SyscallEvent) -> None:
for alert in self.engine.match(ev):
self._on_alert(alert)
def stop(self) -> None:
self._stop.set()
self._source.stop()

View file

@ -0,0 +1,32 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Security-relevant syscall event captured by the optional eBPF layer."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class SyscallEvent:
pid: int
ppid: int
uid: int
comm: str
syscall: str
arg0: int = 0
arg1: int = 0
arg2: int = 0
arg3: int = 0
arg4: int = 0
arg5: int = 0
text: str = ""
def to_dict(self) -> dict:
return {
"pid": self.pid,
"ppid": self.ppid,
"uid": self.uid,
"comm": self.comm,
"syscall": self.syscall,
"args": [self.arg0, self.arg1, self.arg2, self.arg3, self.arg4, self.arg5],
"text": self.text,
}

View file

@ -0,0 +1,128 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Default rules for security-relevant syscall telemetry."""
from __future__ import annotations
from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass
from ..alert import Alert, Severity
from .syscall_event import SyscallEvent
PROT_WRITE = 0x2
PROT_EXEC = 0x4
PTRACE_TRACEME = 0
PTRACE_ATTACH = 16
PTRACE_SEIZE = 0x4206
PR_SET_SECCOMP = 22
Predicate = Callable[[SyscallEvent], bool]
@dataclass(frozen=True)
class SyscallRule:
sid: int
msg: str
severity: Severity
classtype: str
syscalls: frozenset[str]
predicate: Predicate
def matches(self, ev: SyscallEvent) -> bool:
return ev.syscall in self.syscalls and self.predicate(ev)
def to_alert(self, ev: SyscallEvent) -> Alert:
detail = (
f"sid={self.sid} {self.msg} — pid={ev.pid} ppid={ev.ppid} "
f"comm={ev.comm} syscall={ev.syscall} "
f"args=[{ev.arg0:#x}, {ev.arg1:#x}, {ev.arg2:#x}, {ev.arg3:#x}]"
)
if ev.text:
detail += f" text=[{ev.text[:80]}]"
return Alert(
severity=self.severity,
signature=f"syscall_rule.{self.classtype}",
key=f"syscall:{self.sid}:{ev.pid}:{ev.syscall}:{ev.arg0:x}:{ev.arg2:x}",
detail=detail,
pids=(ev.pid,),
sid=self.sid,
classtype=self.classtype,
)
def _prot_wx(prot: int) -> bool:
return bool((prot & PROT_WRITE) and (prot & PROT_EXEC))
def _ptrace_sensitive(request: int) -> bool:
return request in {PTRACE_TRACEME, PTRACE_ATTACH, PTRACE_SEIZE}
DEFAULT_SYSCALL_RULES: tuple[SyscallRule, ...] = (
SyscallRule(
sid=100060,
msg="mprotect made memory writable and executable",
severity=Severity.CRITICAL,
classtype="memory-obfuscation",
syscalls=frozenset({"mprotect"}),
predicate=lambda ev: _prot_wx(ev.arg2),
),
SyscallRule(
sid=100061,
msg="mmap requested writable and executable memory",
severity=Severity.CRITICAL,
classtype="memory-obfuscation",
syscalls=frozenset({"mmap"}),
predicate=lambda ev: _prot_wx(ev.arg2),
),
SyscallRule(
sid=100062,
msg="memfd_create used for anonymous in-memory file staging",
severity=Severity.MEDIUM,
classtype="fileless-execution",
syscalls=frozenset({"memfd_create"}),
predicate=lambda _ev: True,
),
SyscallRule(
sid=100063,
msg="ptrace anti-debug or attach operation observed",
severity=Severity.HIGH,
classtype="anti-analysis",
syscalls=frozenset({"ptrace"}),
predicate=lambda ev: _ptrace_sensitive(ev.arg0),
),
SyscallRule(
sid=100064,
msg="seccomp sandboxing call observed (possible anti-analysis hardening)",
severity=Severity.MEDIUM,
classtype="anti-analysis",
syscalls=frozenset({"prctl", "seccomp"}),
predicate=lambda ev: ev.syscall == "seccomp" or ev.arg0 == PR_SET_SECCOMP,
),
SyscallRule(
sid=100065,
msg="cross-process memory read/write syscall observed",
severity=Severity.HIGH,
classtype="credential-access",
syscalls=frozenset({"process_vm_readv", "process_vm_writev"}),
predicate=lambda _ev: True,
),
SyscallRule(
sid=100066,
msg="memory locking syscall observed (possible protected in-memory payload)",
severity=Severity.MEDIUM,
classtype="memory-obfuscation",
syscalls=frozenset({"mlock", "mlock2", "mlockall"}),
predicate=lambda _ev: True,
),
)
class SyscallRuleEngine:
def __init__(self, rules: Iterable[SyscallRule] | None = None) -> None:
self.rules = list(rules if rules is not None else DEFAULT_SYSCALL_RULES)
def match(self, ev: SyscallEvent) -> Iterator[Alert]:
for rule in self.rules:
if rule.matches(ev):
yield rule.to_alert(ev)