Building a Small Rust eBPF EDR 1: MVP Architecture
rand-guard는 Rust와 Aya로 만든 작은 eBPF EDR 프로젝트다. 여기서 EDR이라는 단어를 쓰지만, 목표는 상용 보안 제품을 흉내 내는 것이 아니다. 이 프로젝트의 목적은 Linux endpoint에서 어떤 행위가 발생했을 때 그것을 커널에서 관찰하고, userspace에서 의미 있는 이벤트로 정리하고, 간단한 탐지 결과를 JSON 로그로 남기는 전체 흐름을 직접 구현해 보는 것이다.
나는 이 프로젝트를 넓은 기능 목록이 아니라 작은 end-to-end 파이프라인으로 설계했다. process가 실행되고, 파일이 열리거나 수정되고, 네트워크 연결이 발생하는 몇 가지 신호만 다룬다. 대신 그 신호가 eBPF program에서 userspace runtime까지 어떻게 이동하는지, 어디에서 정책 판단을 해야 하는지, 어떤 정보를 출력해야 조사자가 이해할 수 있는지를 분명하게 만들고 싶었다.
왜 작은 EDR인가
EDR은 결국 endpoint의 행위를 수집하고, 그 행위가 조사할 가치가 있는지 판단하는 시스템이다. 실제 제품은 훨씬 복잡하다. 많은 이벤트 소스, 복잡한 correlation, 원격 관리, response action, tamper resistance, 대용량 저장소가 필요하다.
하지만 학습용 프로젝트에서 처음부터 그런 범위를 잡으면 핵심을 놓치기 쉽다. 그래서 rand-guard는 다음 질문에 집중한다.
- Linux에서 process, file, network 행위를 어디서 관찰할 수 있는가.
- eBPF에서 안전하게 읽을 수 있는 정보는 어디까지인가.
- 커널에서 해야 할 일과 userspace에서 해야 할 일을 어떻게 나눌 것인가.
- raw event를 사람이 읽고 테스트할 수 있는 JSON record로 어떻게 바꿀 것인가.
- 탐지 룰을 어디에 두어야 변경하기 쉬운가.
이 질문에 답하기 위해 현재 프로젝트는 작지만 완결된 파이프라인을 갖는다.
Linux tracepoints
-> eBPF programs
-> EVENTS ring buffer
-> userspace runtime
-> normalization and enrichment
-> detections and rules
-> NDJSON output전체 구조
저장소는 크게 네 개의 축으로 나뉜다.
crates/common은 eBPF와 userspace가 공유하는 이벤트 ABI를 정의한다.crates/ebpf는 Aya 기반no_std,no_maineBPF program이다.crates/user는 eBPF object를 load하고, ring buffer를 읽고, 이벤트를 정규화하고, 룰을 평가하고, JSON을 출력한다.xtask는 build, test, package, run, smoke, throughput 같은 반복 작업을 묶는다.
아키텍처를 그림으로 보면 다음과 같다.
flowchart TD
A[Linux tracepoints] --> B[crates/ebpf]
B --> C[EVENTS ring buffer]
C --> D[crates/user]
D --> E[Normalize raw events]
E --> F[Process enrichment]
F --> G[Detections and rules]
G --> H[NDJSON stdout]
I[config.toml] --> D
중요한 점은 커널 쪽에서 탐지 정책을 크게 넣지 않는다는 것이다. eBPF program은 syscall argument와 process metadata를 제한된 방식으로 읽고, 고정 크기 이벤트 구조체를 ring buffer에 넣는다. 그 후의 정규화, 필터링, 탐지, 출력은 userspace에서 한다.
이 경계는 의도적인 선택이다. eBPF는 verifier 제약이 있고, allocation이나 복잡한 파싱, 긴 loop, 큰 stack 사용을 피해야 한다. 반면 userspace는 config를 읽고, 문자열을 다루고, process table을 유지하고, JSON을 출력하기 훨씬 쉽다. 룰을 바꿀 때마다 eBPF object를 다시 빌드하는 것도 좋은 구조가 아니다.
현재 수집하는 이벤트
현재 MVP는 세 종류의 이벤트 family를 다룬다.
첫 번째는 process lifecycle이다. execve, execveat, fork, exit 관련 tracepoint를 통해 process start, parent-child relationship, process exit을 관찰한다. userspace에서는 이 정보를 process table에 저장해서 이후 file event나 network event에 ppid, comm, exe_path 같은 context를 붙인다.
두 번째는 file telemetry다. open, write, rename, unlink 계열 syscall을 관찰한다. 모든 파일을 같은 비중으로 다루기보다 watch_paths, watch_patterns, exclude_paths를 통해 관심 있는 경로를 줄인다. 여기에 systemd service나 cron path처럼 persistence와 관련된 위치를 built-in detection으로 다룬다.
세 번째는 network telemetry다. 네트워크 수집은 기본적으로 꺼져 있다. 설정에서 events.network = true와 network.enabled = true를 모두 켜야 connect, bind, listen syscall tracepoint가 attach된다. 현재 DNS, payload, accept, socket lifecycle correlation은 구현하지 않았다.
출력 모델
rand-guard는 한 줄에 하나의 JSON object를 출력한다. 즉 NDJSON 형태다.
예를 들어 process event는 다음과 같은 형태가 된다.
{"event_type":"process_start","pid":100,"comm":"bash","source":"execve"}
file detection이 붙은 event는 다음처럼 source event 자체에 alert와 detection_type이 들어갈 수 있다.
{"event_type":"file_write","resolved_path":"/etc/systemd/system/demo.service","alert":true,"detection_type":"systemd_service_modified"}
generic [[rules]]가 match되면 별도의 stable alert record도 출력한다.
{"event_type":"alert","rule_id":"FILE-001","rule_type":"file","source_event_type":"file_write"}
NDJSON을 선택한 이유는 단순하다. 테스트하기 쉽고, jq나 rg로 확인하기 쉽고, stdout이나 journald 같은 기존 흐름에 붙이기 쉽다. 지금 단계에서는 복잡한 저장소보다 안정적인 event shape가 더 중요하다.
첫 실행 흐름
개발 환경에서는 xtask를 통해 빌드하고 실행할 수 있다.
cargo run -p xtask -- build
cargo run -p xtask -- run
eBPF program을 load하려면 root 또는 적절한 Linux capability가 필요하다. 실행 후 다른 터미널에서 간단한 process event를 만들 수 있다.
/bin/true
기본 설정에서는 process와 file event가 켜져 있고 network event는 꺼져 있다. 이 기본값은 첫 실행에서 noise를 줄이기 위한 선택이다.
현재 제한
이 프로젝트는 production-ready EDR이 아니다. root 권한을 가진 공격자로부터 agent를 보호하는 기능도 없고, tamper-proof logging도 없다. 네트워크 payload나 DNS도 수집하지 않는다. 룰 엔진은 single-event matcher MVP이며, regex, expression DSL, time window, multi-event correlation은 없다.
이 제한은 약점이기도 하지만, 프로젝트의 범위를 선명하게 만드는 장치이기도 하다. 지금 목표는 많은 기능을 얕게 붙이는 것이 아니라, eBPF에서 userspace까지 이어지는 보안 telemetry 파이프라인을 작고 정확하게 이해하는 것이다.
1편 정리
이번 글에서는 rand-guard의 전체 구조를 정리했다.
- 커널에서는 tracepoint 기반 event collection만 작게 수행한다.
- eBPF와 userspace는 고정 ABI와 ring buffer로 연결된다.
- userspace는 normalization, enrichment, detection, rule evaluation, NDJSON output을 담당한다.
- 현재 범위는 process, file, opt-in network telemetry와 단일 이벤트 룰 평가다.
다음 글에서는 eBPF와 userspace 사이의 ABI를 다룬다. raw bytes가 어떻게 안정적인 Rust event로 해석되는지, 왜 fixed-layout struct와 schema version이 중요한지 살펴본다.