Building a Small Rust eBPF EDR 3: Process Lifecycle Telemetry
목차
이번 글에서는 rand-guard의 process lifecycle telemetry를 다룬다. EDR에서 process event는 가장 기본적인 신호다. 어떤 process가 실행되었는지, 부모 process가 무엇인지, 언제 종료되었는지 알 수 있어야 file event나 network event도 해석할 수 있다.
rand-guard는 process event를 단순 실행 로그로만 보지 않는다. userspace process table을 유지해서 이후 이벤트에 context를 붙이는 기반으로 사용한다.
수집 대상
현재 process 관련 설정은 config.example.toml에서 다음 형태로 볼 수 있다.
[process]
enabled = true
hooks = ["execve", "fork", "exit", "execveat"]
collect_args = false
collect_env = false
collect_cwd = false
현재 구현된 process hook은 execve, execveat, fork, exit이다. command line argument, environment, current working directory는 아직 수집하지 않는다. 이것은 의도적인 제한이다. eBPF에서 긴 argv나 env를 다루려면 verifier 제약, 길이 제한, privacy 문제가 함께 생긴다. MVP에서는 process identity와 lifecycle을 안정적으로 잡는 데 집중한다.
tracepoint 선택
userspace runtime은 설정된 hook에 따라 tracepoint를 attach한다. 핵심 tracepoint는 다음과 같다.
sched:sched_process_execsched:sched_process_forksched:sched_process_exitsyscalls:sys_enter_execvesyscalls:sys_enter_execveat
sched_process_exec는 process가 실제로 exec된 뒤의 정보를 주는 scheduler tracepoint다. 여기서는 실행된 filename과 process metadata를 얻을 수 있다. sys_enter_execve와 sys_enter_execveat는 syscall entry 시점의 source를 구분하는 데 사용한다.
왜 둘을 같이 쓰는가? sched_process_exec만 보면 exec가 발생했다는 것은 알 수 있지만, 이 실행이 execve에서 왔는지 execveat에서 왔는지 구분하기 어렵다. 반대로 syscall entry만 보면 실제 exec 완료와 lifecycle 관점의 정보를 다루기 애매하다. 그래서 userspace process table에서 syscall source를 잠깐 보관하고, 뒤이어 오는 exec event와 연결한다.
process table
crates/user/src/process_table.rs의 ProcessTable은 (pid, tid)를 key로 process record를 저장한다. record에는 다음 정보가 들어간다.
pid,tid,ppiduid,gidcommexe_pathfirst_seen,last_seenexit_timestamp,exitedpending_source
process table은 단순 캐시가 아니라 enrichment의 중심이다. file event나 network event는 eBPF 쪽에서 현재 process의 comm 정도는 담을 수 있지만, parent pid나 executable path 같은 context는 userspace에서 보강하는 편이 더 낫다.
흐름은 다음과 같다.
flowchart TD
A[exec syscall tracepoint] --> B[store pending source]
C[sched_process_exec] --> D[update process table]
E[sched_process_fork] --> F[insert child process]
G[sched_process_exit] --> H[mark process exited]
D --> I[enrich later file/network events]
F --> I
H --> I
exec source correlation
execve와 execveat source는 ExecSyscallEvent로 들어온다. 이 event는 바로 output으로 나가지 않고 internal event처럼 처리된다. userspace는 ProcessTable::set_pending_source를 호출해 (pid, tid)에 source string을 저장한다.
그 다음 sched_process_exec가 들어오면 ProcessTable::update_from_exec에서 pending source를 가져와 ProcessStart normalized event에 넣는다. output에서는 source field로 확인할 수 있다.
{"event_type":"process_start","pid":123,"comm":"true","source":"execve"}
이 구조는 완벽한 multi-event correlation engine은 아니다. 아주 작은 목적의 correlation이다. syscall source를 process start event에 붙이기 위한 짧은 상태만 유지한다.
fork와 parent relationship
sched_process_fork는 parent와 child 정보를 제공한다. rand-guard는 이것을 ProcessRelationship normalized event로 만든다.
{"event_type":"process_relationship","parent_pid":100,"parent_comm":"bash","child_pid":101,"child_comm":"bash"}
이 이벤트는 process rule에서도 사용할 수 있다. 예를 들어 sample config에는 web server가 shell을 spawn하는 관계를 잡는 rule이 있다.
[[rules]]
id = "PROC-001"
name = "Shell spawned by web server"
enabled = true
type = "process"
severity = "high"
action = "alert"
parent_names = ["nginx", "apache2", "httpd"]
process_names = ["sh", "bash", "dash"]
이 rule은 단일 relationship event에 대한 matcher다. web shell을 확정 탐지한다고 말할 수는 없다. 하지만 web server process가 shell child를 만들었다면 조사할 가치가 있는 signal이 될 수 있다.
exit와 eviction
sched_process_exit가 들어오면 process table은 해당 record를 바로 삭제하지 않고 exited = true로 표시한다. 이렇게 하는 이유는 종료 직후에도 관련 event가 늦게 도착하거나, health/debug 목적으로 상태를 유지할 수 있기 때문이다.
하지만 process table이 무한히 커져서는 안 된다. 설정에는 다음 limit이 있다.
[performance]
max_process_cache_entries = 5000
max_pending_exec_sources = 500
record 수가 limit을 넘으면 exited record를 먼저 제거하고, 그 다음 오래된 record를 제거한다. 이 정책은 단순하지만 MVP에는 충분하다. 단점도 있다. evicted process에 대해서는 이후 file/network event의 enrichment가 약해질 수 있다. 이 제한은 health record의 process cache size와 eviction count로 관찰할 수 있다.
process event가 다른 event를 돕는 방식
process telemetry의 가치는 독립 event보다 context에 있다. /etc/systemd/system/demo.service 파일이 수정되었다는 사실만으로는 부족하다. 어떤 process가 수정했는지, 그 process의 parent가 무엇인지, executable path가 무엇인지 알면 해석이 달라진다.
network event도 마찬가지다. 4444번 포트로 연결했다는 사실보다 어떤 process가 연결했는지가 더 중요하다. nc인지, python인지, 정상 agent인지에 따라 false positive 가능성이 달라진다.
그래서 rand-guard는 process table을 userspace 중심에 둔다. eBPF에서 모든 context를 한 번에 수집하려 하지 않고, lifecycle event를 기반으로 userspace에서 보강한다.
데모
agent를 실행한 뒤 간단한 process를 실행한다.
/bin/true
예상 output은 환경에 따라 다르지만 다음과 같은 record를 볼 수 있다.
{"event_type":"process_start","comm":"true","source":"execve"}
{"event_type":"process_exit","comm":"true"}
실제 record에는 timestamp, pid, tid, uid, gid, ppid, exe_path, truncation flag 등이 더 포함된다.
현재 제한
현재 process telemetry는 argv, env, cwd를 수집하지 않는다. 따라서 command line 기반 rule이나 environment 기반 탐지는 구현되어 있지 않다. process table은 bounded cache이므로 오래된 process context가 사라질 수 있다. 또한 root attacker가 agent를 중지하거나 config를 바꾸는 상황을 방어하지 않는다.
이 제한을 감수하는 이유는 명확하다. 먼저 lifecycle을 안정적으로 수집하고, 그 데이터를 file/network event enrichment에 사용하는 구조를 완성하는 것이 더 중요하기 때문이다.
3편 정리
이번 글에서는 process lifecycle telemetry를 살펴봤다.
execve,execveat,fork,exit를 중심으로 process event를 수집한다.- syscall source와
sched_process_exec를 작은 상태로 연결한다. - userspace process table은 이후 file/network event enrichment의 기반이다.
- process cache는 limit과 eviction policy를 가진다.
다음 글에서는 file telemetry와 persistence-sensitive detection을 다룬다. open, write, rename, unlink 계열 이벤트가 어떻게 수집되고 systemd service나 cron path 변경 탐지로 이어지는지 살펴본다.