Building a Mini EDR with eBPF 1: MVP Design

I decided to build a small EDR that can work as a portfolio project for a junior eBPF security engineer role. The goal is not to imitate a large commercial security product. The goal is to observe process behavior on Linux with eBPF, identify a few suspicious events, and write them as JSON Lines logs from end to end.

This is the first post in a three-part series. Instead of going deep into code implementation, this post explains what problem I wanted to observe and how I designed the MVP structure. In parts 2 and 3, I improve detection quality on top of this MVP.

Why I Built It

An EDR is ultimately a system that collects behavior from an endpoint and decides whether that behavior is suspicious. Commercial EDRs are much more complex, but at a small scale the flow can be viewed like this.

behavior occurs -> event collection -> context cleanup -> rule matching -> log storage

In this project, I simplified that flow around Linux syscalls. I start with process execution, sensitive file access, and external network connections. These three signals are not enough to explain every attack, but they are enough as a starting point for designing and implementing a security log pipeline.

Events Observed in the MVP

The MVP limits collection to three syscalls.

  • execve
  • openat
  • connect

execve observes process execution. It can show which file was executed, which process name was used, and what the parent process was. I use it to detect binaries executed from temporary directories.

openat observes file access attempts. In the MVP, I use it to detect attempts to access /etc/shadow. However, because the MVP only looks at the syscall entry point, it cannot tell whether the access actually succeeded.

connect observes network connection attempts. In the MVP, I only parse IPv4. If a shell or script runtime connects to an external IP, I treat it as a suspicious reverse-shell-like behavior.

Overall Structure

I designed the overall structure like this.

Linux syscall
  -> tracepoint
  -> eBPF program
  -> BPF ring buffer
  -> user-space loader
  -> rule engine
  -> JSONL log file

As a diagram, it looks like this.

    flowchart TD
    A[Linux syscall\nexecve/openat/connect] --> B[Tracepoint\nsys_enter_*]
    B --> C[eBPF program\nsrc/edr.bpf.c]
    C --> D[BPF ring buffer]
    D --> E[User-space loader\nsrc/main.c]
    E --> F[Rule engine\nsrc/rules.c]
    G[Config\nconfig/rules.json] --> F
    F --> H[JSONL writer\nsrc/json_writer.c]
    H --> I[Log file\nlog/events.jsonl]

In the kernel, the eBPF program attaches to syscall tracepoints. It reads syscall arguments and current process metadata, then builds a single event structure. That event is passed to user space through a BPF ring buffer.

In user space, the loader uses the libbpf skeleton to load the eBPF object and attach tracepoints. It then polls the ring buffer, receives events, applies rules based on the configuration file, and writes the final result to ./log/events.jsonl in JSON Lines format.

I chose this structure for a simple reason. The kernel side should focus on event collection, while decision-making and output are safer and easier to change in user space. Recompiling the eBPF program every time a rule changes would be too much overhead even for an MVP.

Why I Chose Tracepoints

I chose tracepoints to track syscall events.

Tracepoints are event points already exposed by the kernel. They are suitable for tracking syscall entry points and are closer to a stable interface than kprobes. For the MVP, I only need sys_enter_execve, sys_enter_openat, and sys_enter_connect, so tracepoints are enough.

kprobes are more flexible. They can attach directly to internal kernel functions and sometimes provide deeper information. But they can also be more sensitive to kernel versions and internal implementation changes. The goal of this project is not advanced kernel hooking. The first goal is to complete a stable event collection pipeline.

Event Fields

All three events share common process metadata.

  • timestamp
  • event_type
  • pid, ppid
  • uid, gid
  • comm
  • parent_comm
  • mnt_ns
  • pid_ns

Even this information makes the logs easier to interpret than raw syscall logs. For example, accessing /etc/shadow means different things depending on whether it was a cat command run by a user or a process spawned by some parent process. Namespace information is also needed when interpreting containers or isolated environments.

Each event adds its own fields.

execve  -> path, partial cmdline
openat  -> path, flags
connect -> dst_ip, dst_port, address_family

In the MVP, I do not try to collect the full command line in the kernel. eBPF has limits around loops and string lengths, and the verifier must be able to prove safety. So the kernel reads only a small amount, and I leave room to enrich it later from /proc/<pid>/cmdline in user space if needed.

Detection Rules

The MVP has three rules.

The first rule is EXEC_FROM_TMP. It detects binaries executed from paths under /tmp/, /var/tmp/, and /dev/shm/. Downloading a file into a temporary directory and executing it is a common attack pattern. Of course, normal tests and temporary execution can also trigger it, so I set the severity to medium.

The second rule is SHADOW_READ. It detects attempts to access /etc/shadow. This file is sensitive because it is related to Linux account password hashes. Even an access attempt is worth investigating, so I set the severity to high.

The third rule is SUSPICIOUS_CONNECT. It detects cases where processes such as sh, bash, zsh, python, perl, ruby, nc, or socat connect to an external IPv4 address. This does not prove a reverse shell. A single connect event is not enough to conclude that an attack happened, so I treat it as a heuristic rule.

Private networks are excluded by default.

127.0.0.0/8
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16

The rule processing flow stays simple.

    flowchart TD
    A[Event received] --> B{event_type}
    B -- execve --> C{path starts with tmp paths?}
    C -- yes --> D[EXEC_FROM_TMP\nmedium]
    C -- no --> Z[rule null]
    B -- openat --> E{path == /etc/shadow?}
    E -- yes --> F[SHADOW_READ\nhigh]
    E -- no --> Z
    B -- connect --> G{comm in suspicious list?}
    G -- yes --> H{dst_ip is external IPv4?}
    G -- no --> Z
    H -- yes --> I[SUSPICIOUS_CONNECT\nhigh]
    H -- no --> Z
    D --> J[Write JSONL]
    F --> J
    I --> J
    Z --> J

Managing Rules with a Config File

Rule values are not hardcoded. They are read from 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_read": {
      "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"],
      "exclude_private_ip": true
    }
  }
}

With this structure, I can change target process names or severity without rebuilding. For the MVP, JSON is enough. YAML or a more complex rule DSL can wait.

JSON Lines Logs

The output format is JSON Lines. Each event becomes one line of JSON.

For example, detecting a binary executed from /tmp produces a log like this.

{"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"}

I chose JSON Lines because it is simple. It is easy to append, easy to search with rg, easy to process with jq, and easy to connect to a log pipeline. For a small portfolio project, it is a practical event storage format.

Current Limitations

The MVP intentionally leaves out many things.

openat only observes the syscall entry point. So it cannot tell whether the file access actually succeeded or failed. This requires an exit tracepoint such as sys_exit_openat.

Path reconstruction is also limited. The filename argument of openat can be a relative path, and restoring the exact absolute path is not simple when dirfd and mount namespaces are involved. In the MVP, I record the string passed as the syscall argument as-is.

connect only parses IPv4. For IPv6, the MVP keeps only the address family and leaves destination address and port parsing for later.

Reverse shell detection is not definitive. A shell or script runtime connecting to an external IP is not always an attack. Normal operation scripts or deployment tools can produce similar events.

Finally, full command line collection is not complete yet. Instead of trying to read long argv values in the kernel, I think enriching them from /proc/<pid>/cmdline in user space is a better direction.

Part 1 Summary

In part 1, I designed the MVP for a mini EDR. The key points are these.

First, observe execve, openat, and connect through syscall tracepoints.

Second, keep the eBPF program focused on event collection, and handle rule matching and JSONL output in user space.

Third, manage detection rules through a configuration file so they can be adjusted without rebuilding.

In the next post, I improve file access detection by connecting openat enter and exit events, and I separate /etc/shadow access attempts from successful access.

GitHub Repository

The code for this post is available at ebpf-guard part-1.