Building a Mini EDR with eBPF 1: MVP Design

주니어 eBPF 보안 엔지니어 포트폴리오로 보여줄 만한 작은 EDR을 하나 만들기로 했다. 목표는 거대한 보안 제품을 흉내 내는 것이 아니다. Linux에서 발생하는 프로세스 행위를 eBPF로 관찰하고, 그중 의심스러운 이벤트를 JSON Lines 로그로 남기는 최소 구조를 끝까지 만들어보는 것이다.

이번 글은 3부작 중 1편이다. 코드 구현을 깊게 설명하기보다, 어떤 문제를 보려고 했고 어떤 구조로 MVP를 잡았는지 정리한다. 2편과 3편에서는 이 MVP 위에서 탐지 품질을 개선하는 과정을 다룬다.

왜 만들었나

EDR은 결국 endpoint에서 벌어지는 행위를 수집하고, 그 행위가 수상한지 판단하는 시스템이다. 상용 EDR은 훨씬 복잡하지만, 아주 작게 쪼개면 다음 흐름으로 볼 수 있다.

행위 발생 -> 이벤트 수집 -> 컨텍스트 정리 -> 룰 매칭 -> 로그 저장

이 프로젝트에서는 이 흐름을 Linux syscall 기반으로 단순화했다. 프로세스 실행, 민감 파일 접근, 외부 네트워크 연결만 먼저 본다. 이 세 가지는 공격 행위 전체를 설명하기에는 부족하지만, 보안 로그 파이프라인을 설계하고 구현해보기에는 충분한 출발점이다.

MVP에서 볼 이벤트

MVP에서 수집할 syscall은 세 개로 제한했다.

  • execve
  • openat
  • connect

execve는 프로세스 실행을 본다. 어떤 파일이 실행됐는지, 어떤 프로세스 이름으로 실행됐는지, 부모 프로세스가 무엇인지 확인할 수 있다. 임시 디렉터리에서 실행되는 바이너리를 탐지하는 데 쓴다.

openat은 파일 접근 시도를 본다. MVP에서는 /etc/shadow 접근 시도를 탐지한다. 다만 진입 시점의 syscall만 보기 때문에 실제 접근 성공 여부까지 알지는 않는다.

connect는 네트워크 연결 시도를 본다. MVP에서는 IPv4만 파싱하고, 쉘이나 스크립트 런타임이 외부 IP로 연결하는 경우를 reverse shell 의심 행위로 본다.

전체 구조

전체 구조는 아래처럼 잡았다.

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

그림으로 보면 다음과 같다.

    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]

커널 영역에서는 eBPF 프로그램이 syscall tracepoint에 붙는다. syscall 인자와 현재 프로세스 메타데이터를 읽고, 하나의 이벤트 구조체로 만든다. 그 이벤트는 BPF ring buffer를 통해 유저랜드로 넘어간다.

유저랜드에서는 libbpf skeleton으로 eBPF object를 로드하고 tracepoint에 attach한다. 이후 ring buffer를 polling하면서 이벤트를 받고, 설정 파일을 기준으로 룰을 적용한다. 최종 결과는 ./log/events.jsonl에 JSON Lines 형식으로 저장한다.

이 구조를 선택한 이유는 단순하다. 커널 쪽은 이벤트 수집에 집중하고, 판단과 출력은 유저랜드에서 처리하는 편이 안전하고 수정하기 쉽다. 룰을 바꾸기 위해 eBPF 프로그램을 다시 컴파일하는 구조는 MVP에도 부담이 크다.

tracepoint를 선택한 이유

syscall 이벤트를 추적하는 방법으로 tracepoint를 선택했다.

tracepoint는 커널이 미리 노출한 이벤트 지점이다. syscall 진입점을 추적하기에 적합하고, kprobe보다 안정적인 인터페이스에 가깝다. MVP에서는 sys_enter_execve, sys_enter_openat, sys_enter_connect만 보면 되기 때문에 tracepoint로 충분했다.

kprobe는 더 유연하다. 커널 내부 함수에 직접 붙을 수 있고 더 깊은 정보를 얻을 수도 있다. 대신 커널 버전이나 내부 구현 변화에 더 민감할 수 있다. 이번 프로젝트의 목적은 고급 커널 후킹보다, 안정적인 이벤트 수집 파이프라인을 먼저 완성하는 것이다.

이벤트에 담을 정보

세 이벤트는 공통적으로 프로세스 메타데이터를 가진다.

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

이 정보만으로도 단순 syscall 로그보다 해석이 쉬워진다. 예를 들어 같은 /etc/shadow 접근이라도 사용자가 직접 실행한 cat인지, 어떤 부모 프로세스에서 나온 동작인지에 따라 의미가 달라진다. namespace 정보는 컨테이너나 격리 환경을 해석할 때 필요하다.

이벤트별로는 다음 값을 추가로 담는다.

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

MVP에서는 command line 전체를 커널에서 욕심내서 수집하지 않는다. eBPF에는 반복 횟수와 문자열 길이 제약이 있고, verifier가 안전성을 증명해야 한다. 그래서 커널에서는 작은 범위만 읽고, 나중에 필요하면 유저랜드에서 /proc/<pid>/cmdline으로 보강하는 방향을 둔다.

탐지 룰

MVP 룰은 세 개다.

첫 번째는 EXEC_FROM_TMP이다. /tmp/, /var/tmp/, /dev/shm/ 아래 경로에서 실행되는 바이너리를 탐지한다. 임시 디렉터리에 파일을 내려받고 실행하는 행위는 공격에서도 자주 보이는 패턴이다. 물론 정상적인 테스트나 임시 실행도 잡힐 수 있으므로 중간 심각도인 medium으로 둔다.

두 번째는 SHADOW_READ다. /etc/shadow 접근 시도를 탐지한다. 이 파일은 Linux 계정의 패스워드 해시와 관련된 민감 파일이다. 접근 시도만으로도 조사 가치가 있으므로 심각도는 high로 둔다.

세 번째는 SUSPICIOUS_CONNECT다. sh, bash, zsh, python, perl, ruby, nc, socat 같은 프로세스가 외부 IPv4로 연결하면 탐지한다. reverse shell을 확정하는 룰은 아니다. 단일 connect 이벤트만으로 공격이라고 단정할 수 없기 때문에 휴리스틱 룰로 본다.

내부망은 기본적으로 제외한다.

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

룰 처리 흐름은 단순하게 유지했다.

    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

설정 파일로 룰을 관리한다

룰 값은 코드에 직접 박아두지 않고 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
    }
  }
}

이렇게 두면 탐지 대상 프로세스나 severity를 바꿀 때 다시 빌드하지 않아도 된다. MVP에서는 JSON만 사용하고, YAML이나 더 복잡한 룰 DSL은 나중으로 미룬다.

JSON Lines 로그

출력은 JSON Lines로 정했다. 이벤트 하나가 한 줄의 JSON이 된다.

예를 들어 /tmp 아래 바이너리 실행을 탐지하면 이런 형태가 된다.

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

JSON Lines를 고른 이유는 단순하다. append가 쉽고, rg, jq, log pipeline과 붙이기 쉽다. 작은 포트폴리오 프로젝트에서 이벤트 저장 형식으로 다루기 좋다.

현재 한계

MVP는 의도적으로 많은 것을 포기한다.

openat은 syscall 진입 시점만 본다. 그래서 실제 파일 접근에 성공했는지 실패했는지 알 수 없다. 이 문제는 sys_exit_openat 같은 exit tracepoint를 추가해야 해결된다.

경로 복원도 제한적이다. openat의 filename 인자는 상대 경로일 수 있고, dirfd와 mount namespace까지 고려하면 정확한 절대 경로 복원은 간단하지 않다. MVP에서는 syscall 인자로 들어온 문자열을 그대로 기록한다.

connect는 IPv4만 파싱한다. IPv6는 address family만 남기고 목적지 주소와 port 파싱은 이후 작업으로 둔다.

reverse shell 탐지도 확정 탐지가 아니다. 쉘이나 스크립트 런타임이 외부 IP로 연결한다고 해서 항상 공격은 아니다. 정상적인 운영 스크립트나 배포 도구도 비슷한 이벤트를 만들 수 있다.

마지막으로 command line 전체 수집도 아직 완성하지 않았다. 커널 쪽에서 긴 argv를 모두 읽기보다, 유저랜드에서 /proc/<pid>/cmdline으로 보강하는 편이 낫다고 봤다.

1편 정리

1편에서는 미니 EDR의 MVP 설계를 잡았다. 핵심은 세 가지다.

첫째, syscall tracepoint로 execve, openat, connect를 관찰한다.

둘째, eBPF 프로그램은 이벤트 수집에 집중하고, 룰 매칭과 JSONL 저장은 유저랜드에서 처리한다.

셋째, 탐지 룰은 설정 파일로 관리해서 재빌드 없이 조정할 수 있게 한다.

다음 글에서는 파일 접근 탐지를 조금 더 정확하게 만들기 위해 openat enter/exit를 연결하고, /etc/shadow 접근 시도와 성공을 구분하는 과정을 정리한다.

GitHub 레포

이번 글의 코드는 ebpf-guard part-1에서 볼 수 있다.