This project is a small eBPF-based EDR prototype for tracing suspicious process activity on Linux and storing the results as JSON Lines logs.
The current implementation collects execve, openat, and connect syscall events through tracepoints. openat correlates syscall entry and exit to record success or failure, and connect is enriched in user space with command line and allowlist context before being written to ./log/events.jsonl.
EXEC_FROM_TMP: Binary execution from/tmp/,/var/tmp/, or/dev/shm/SHADOW_OPEN_ATTEMPT: Failedopenatattempt for/etc/shadowSHADOW_OPEN_SUCCESS: Successfulopenatfor/etc/shadowSUSPICIOUS_CONNECT: Suspicious public IPv4 connections from shells or script runtimes that are not covered by the allowlist
syscall
-> tracepoint/syscalls/sys_enter_*, sys_exit_openat
-> eBPF program
-> BPF ring buffer
-> user-space loader
-> /proc/<pid>/cmdline enrichment for connect
-> rule engine
-> JSONL writer
-> ./log/events.jsonl
Key files:
src/edr.bpf.c: eBPF tracepoint programsrc/main.c: libbpf skeleton loader and ring buffer polling loopsrc/config.c:config/rules.jsonloadersrc/rules.c: Rule enginesrc/json_writer.c: JSON Lines writerinclude/events.h: Shared event structure for kernel and user spaceconfig/rules.json: Detection rule configurationscripts/: Test trigger scripts
The target environment is Arch Linux with a recent LTS kernel.
Required packages:
sudo pacman -S --needed base-devel clang llvm libbpf bpftool pkgconf makeVerification commands:
bpftool version
clang --version
llvm-strip --version
llc --version | grep bpfmakeBuild outputs:
build/edr.bpf.oinclude/edr.skel.hbuild/mini-edr
Clean up:
make cleanLoading the eBPF program requires root privileges.
sudo -n ./build/mini-edrIf non-interactive sudo is not configured, run it like this.
sudo ./build/mini-edrAt startup, the loader reads config/rules.json and appends JSONL logs to the configured path.
Default log path:
./log/events.jsonl
The default configuration file is config/rules.json.
{
"output": {
"type": "file",
"path": "./log/events.jsonl"
},
"rules": {
"exec_from_tmp": {
"enabled": true,
"severity": "medium",
"paths": ["/tmp/", "/var/tmp/", "/dev/shm/"]
},
"shadow_open_attempt": {
"enabled": true,
"severity": "medium",
"paths": ["/etc/shadow"]
},
"shadow_open_success": {
"enabled": true,
"severity": "high",
"paths": ["/etc/shadow"]
},
"suspicious_connect": {
"enabled": true,
"severity": "high",
"process_names": ["sh", "bash", "dash", "zsh", "python", "python3", "perl", "ruby", "nc", "ncat", "socat"],
"cmdline_tokens": ["/dev/tcp/", "bash -i", "sh -i", "python -c", "python3 -c", "perl -e", "socat ", "ncat "],
"exclude_private_ip": true,
"allow_dst_ips": ["1.1.1.1"],
"allow_cmdline_prefixes": ["python3 /opt/backup/"],
"suppression_mode": "mark"
}
}
}The current configuration loader is a minimal JSON parser for the MVP schema. It is intended to read this structure and is not a general-purpose JSON parser.
suppression_mode supports mark and drop. mark stores the event with suppressed=true and suppress_reason; drop omits suppressed events from the JSONL output.
Run the loader in terminal 1.
sudo -n ./build/mini-edrRun the test triggers in terminal 2.
./scripts/run_tests.shIndividual tests:
./scripts/test_exec_tmp.sh
./scripts/test_shadow_read.sh
./scripts/test_suspicious_connect.shSample-log regression check:
./tests/test_connect_samples.shCheck the results:
rg '"rule":"EXEC_FROM_TMP"|"rule":"SHADOW_OPEN_ATTEMPT"|"rule":"SHADOW_OPEN_SUCCESS"|"rule":"SUSPICIOUS_CONNECT"' log/events.jsonl{"timestamp":6405702218137,"event_type":"execve","pid":82943,"ppid":82936,"uid":1000,"gid":1000,"comm":"zsh","parent_comm":"zsh","cmdline":"/tmp/mini-edr-test-echo","path":"/tmp/mini-edr-test-echo","flags":0,"dst_ip":null,"dst_port":null,"address_family":0,"mnt_ns":4026531832,"pid_ns":4026531836,"rule":"EXEC_FROM_TMP","severity":"medium"}{"timestamp":6405707911504,"event_type":"openat","pid":82945,"ppid":82936,"uid":1000,"gid":1000,"comm":"cat","parent_comm":"zsh","cmdline":"","path":"/etc/shadow","flags":0,"retval":-13,"error_code":13,"success":false,"dst_ip":null,"dst_port":null,"address_family":0,"mnt_ns":4026531832,"pid_ns":4026531836,"rule":"SHADOW_OPEN_ATTEMPT","severity":"medium"}{"timestamp":6418125706741,"event_type":"connect","pid":83018,"ppid":83012,"uid":1000,"gid":1000,"comm":"bash","parent_comm":"zsh","cmdline":"bash -c exec 3<>/dev/tcp/203.0.113.10/4444","path":"","flags":0,"dst_ip":"203.0.113.10","dst_port":4444,"address_family":2,"mnt_ns":4026531832,"pid_ns":4026531836,"dst_ip_class":"public","suppressed":false,"suppress_reason":null,"rule":"SUSPICIOUS_CONNECT","severity":"high"}An allowlisted or private-IP connect event is stored like this.
{"event_type":"connect","comm":"bash","cmdline":"bash -c exec 3<>/dev/tcp/1.1.1.1/80","dst_ip":"1.1.1.1","dst_ip_class":"public","rule":null,"severity":null,"suppressed":true,"suppress_reason":"allow_dst_ip"}openatcorrelates entry and exit, but other file-open syscalls such asopen,openat2, andcreatare not covered yet.openatpaths are recorded as the raw filename syscall argument. Relative paths,dirfd, and mount namespace based absolute path reconstruction are not handled.connectparses destination IP and port only for IPv4. IPv6 is left as future work.SUSPICIOUS_CONNECTis heuristic. Command line enrichment and allowlists do not make it an attack-confirmation rule.connectcommand line enrichment is best-effort through/proc/<pid>/cmdline; it can fail because of process exit, permissions, or namespace differences.timestampis currently the kernel monotonic nanosecond value. Wall-clock timestamp conversion is future work.- The configuration loader is a minimal implementation for the MVP schema.
- Parse IPv6
connectevents - Expand process ancestry context
- Add network context such as DNS, SNI, or HTTP Host
- Separate output filtering from stdout debug options
- Capture a README-driven demo