Building a Small Rust eBPF EDR 2: Stable Event ABI
목차
1편에서는 rand-guard의 전체 구조를 설명했다. 이번 글에서는 eBPF program과 userspace runtime 사이의 경계에 집중한다. 이 경계는 단순한 데이터 전달 통로가 아니다. 커널에서 만들어진 raw event를 userspace가 안전하게 해석하려면 이벤트 구조체의 layout, version, size, kind가 안정적으로 정의되어 있어야 한다.
rand-guard에서는 이 역할을 crates/common이 맡는다. 이 crate는 eBPF와 userspace가 함께 사용하는 공통 이벤트 schema를 정의한다.
왜 ABI가 중요한가
eBPF program은 커널 context에서 event struct를 만들고 EVENTS ring buffer에 넣는다. userspace는 ring buffer에서 byte slice를 읽고 그것을 특정 Rust struct로 해석한다.
이때 양쪽이 struct layout을 다르게 이해하면 문제가 생긴다. eBPF는 ProcessExecEvent를 썼다고 생각하지만 userspace가 다른 크기나 다른 field order로 읽으면 잘못된 pid, path, port를 출력할 수 있다. 보안 telemetry에서 잘못된 event는 단순한 버그보다 위험하다. 조사자가 잘못된 근거로 판단할 수 있기 때문이다.
그래서 rand-guard는 공통 ABI를 다음 원칙으로 관리한다.
- eBPF와 userspace가 같은 Rust type을 참조한다.
- ring buffer로 넘어가는 struct는
#[repr(C)]를 사용한다. - 모든 event는 공통
EventHeader를 앞에 둔다. - schema version과 event size를 userspace에서 확인한다.
- 문자열 field는 고정 길이 buffer로 둔다.
- 잘린 문자열은 truncation flag로 표시한다.
crates/common의 역할
crates/common/src/lib.rs는 #![no_std]로 작성되어 있다. eBPF 쪽에서도 사용할 수 있어야 하기 때문이다.
공통 상수는 대략 다음 의미를 갖는다.
pub const EVENT_SCHEMA_VERSION: u16 = 1;
pub const COMM_LEN: usize = 16;
pub const PATH_LEN: usize = 256;
pub const EVENT_FLAG_FILENAME_TRUNCATED: u16 = 1 << 0;
COMM_LEN은 Linux process comm 길이를 반영한다. PATH_LEN은 이벤트에 담을 path buffer 크기다. 더 긴 path가 들어오면 무리하게 전부 수집하려 하지 않고, 가능한 만큼만 담고 truncation flag를 남긴다.
이 방식은 완전한 정보 수집보다 안전성과 일관성을 우선한다. eBPF에서 긴 문자열을 자유롭게 다루거나 dynamic allocation을 하는 것은 좋은 방향이 아니다. 작은 고정 buffer와 명시적인 truncation이 현재 프로젝트에는 더 맞다.
EventHeader와 EventKind
모든 raw event는 공통 header를 가진다. header에는 schema version, event kind, struct size, timestamp, pid, tid, uid, gid 같은 기본 metadata가 들어간다.
userspace는 ring buffer에서 bytes를 읽었을 때 먼저 header를 확인한다.
raw bytes
-> read EventHeader
-> check schema version
-> check event kind
-> check expected size
-> read concrete event struct
event kind는 ProcessExec, FileOpen, NetworkConnect 같은 구체적인 event type을 구분한다. userspace dispatcher는 이 kind를 보고 어떤 struct로 읽을지 결정한다.
flowchart TD
A[Ring buffer bytes] --> B[Read EventHeader]
B --> C{schema version ok?}
C -- no --> X[InvalidSchema]
C -- yes --> D{event kind}
D --> E[Process event]
D --> F[File event]
D --> G[Network event]
E --> H[Normalize]
F --> H
G --> H
이 흐름은 crates/user/src/dispatch.rs에서 확인할 수 있다. dispatch_event는 먼저 bytes 길이가 header보다 작은지 확인한다. 그 다음 EVENT_SCHEMA_VERSION과 header version이 맞는지 본다. 이후 kind별로 expected struct size를 확인하고, 맞을 때만 read_unaligned로 event를 읽는다.
size 검증이 필요한 이유
schema version만으로는 충분하지 않다. 개발 중에 event struct에 field를 추가하거나 순서를 바꾸면 size가 달라질 수 있다. userspace가 예전 크기로 읽으면 event 뒤쪽을 놓치거나, 반대로 없는 bytes를 읽으려 할 수 있다.
그래서 각 event에는 SIZE가 있고, dispatcher는 header.size와 core::mem::size_of::<EventType>()를 비교한다. 둘이 맞지 않으면 normalized event로 넘기지 않고 InvalidSchema로 처리한다.
이 처리는 보수적이다. 모르는 event를 억지로 출력하지 않는다. telemetry pipeline에서는 불완전한 event를 그럴듯한 JSON으로 만드는 것보다 버리는 편이 낫다.
문자열과 truncation
process name이나 file path는 event에서 중요한 정보다. 하지만 eBPF에서 문자열을 다룰 때는 항상 길이 제한이 필요하다.
rand-guard는 comm과 path를 고정 길이 byte array로 둔다. eBPF program은 bounded read를 수행하고, userspace는 null terminator나 유효 길이를 기준으로 String을 만든다. path가 buffer보다 길면 flag를 통해 filename_truncated를 표시한다.
이 정보는 output에도 반영된다.
{"event_type":"process_start","comm":"bash","filename_truncated":false}
truncation flag가 중요한 이유는 조사자가 path를 완전한 값으로 오해하지 않게 하기 위해서다. 보안 로그에서는 값 자체뿐 아니라 그 값이 완전한지 여부도 의미가 있다.
FileFilterConfig도 ABI다
공통 crate에는 event struct뿐 아니라 FileFilterConfig도 있다. 이것은 userspace가 eBPF map인 FILE_FILTER에 넣는 설정 구조체다.
pub struct FileFilterConfig {
pub prefix_count: u32,
pub prefixes: [[u8; FILE_FILTER_PREFIX_LEN]; FILE_FILTER_MAX_PREFIXES],
pub prefix_lens: [u32; FILE_FILTER_MAX_PREFIXES],
}
이 구조체도 eBPF와 userspace가 같은 layout으로 이해해야 한다. userspace가 watch path prefix를 채워 map에 넣으면 eBPF file program은 그 prefix를 기준으로 kernel-side filter를 적용할 수 있다.
다만 watch pattern이 설정되어 있거나 prefix 수가 너무 많으면 kernel-side prefix filter는 비활성화된다. pattern matching은 userspace가 처리하는 것이 더 안전하고 유연하기 때문이다.
ABI 변경을 다루는 태도
이 프로젝트에서 ABI 변경은 가볍게 보면 안 된다. event struct field를 추가하는 일은 단순한 Rust refactor가 아니라 kernel/userspace boundary 변경이다.
좋은 변경은 다음 조건을 만족해야 한다.
- 새 field가 실제 detection이나 output에 필요한가.
- eBPF에서 안전하게 수집할 수 있는가.
- fixed-size struct 안에서 stack과 ring buffer 비용이 감당되는가.
- userspace normalization과 output test가 함께 변경되는가.
- truncation이나 missing value가 명확하게 표현되는가.
이 기준을 통과하지 못하면 field를 추가하지 않는 편이 낫다. 작은 event schema는 성능뿐 아니라 이해 가능성에도 도움이 된다.
2편 정리
이번 글에서는 rand-guard의 event ABI를 살펴봤다.
crates/common은 eBPF와 userspace가 공유하는 schema다.- 모든 event는
EventHeader를 통해 version, kind, size를 가진다. - userspace dispatcher는 schema와 size를 검증한 뒤에만 event를 정규화한다.
- 문자열은 고정 길이 buffer로 담고 truncation flag를 남긴다.
- ABI 변경은 보안 telemetry의 신뢰성과 직접 연결된다.
다음 글에서는 process lifecycle telemetry를 다룬다. execve, execveat, fork, exit 이벤트가 어떻게 process table로 이어지고, file/network event enrichment에 사용되는지 살펴본다.