Building a Mini EDR with eBPF 2: Improving File Access Detection
목차
1편에서는 Linux syscall을 tracepoint로 관찰하고, eBPF 프로그램에서 만든 이벤트를 BPF ring buffer로 유저랜드에 넘긴 뒤, 룰 엔진과 JSON Lines writer로 처리하는 MVP 구조를 정리했다. 흐름은 단순했다.
Linux syscall
-> tracepoint
-> eBPF program
-> BPF ring buffer
-> user-space loader
-> rule engine
-> JSONL log file
이번 글에서는 이 구조를 다시 만들지 않는다. 이미 있는 MVP 위에서 파일 접근 탐지를 조금 더 정확하게 만든다. 대상은 openat이다.
MVP에서는 execve, openat, connect 세 종류의 syscall을 봤다. 그중 openat은 /etc/shadow 접근을 탐지하는 데 사용했다. 현재 eBPF 프로그램은 tracepoint/syscalls/sys_enter_openat에 붙어 있고, syscall 진입 시점에 filename과 flags를 읽어 바로 이벤트를 보낸다.
현재 이벤트 구조체도 이 설계에 맞춰져 있다. 공통 프로세스 메타데이터와 함께 path, flags를 담지만, syscall이 끝난 뒤의 반환값은 담지 않는다.
struct event {
unsigned long long timestamp_ns;
unsigned int event_type;
unsigned int pid;
unsigned int ppid;
unsigned int uid;
unsigned int gid;
char comm[TASK_COMM_LEN];
char parent_comm[TASK_COMM_LEN];
char path[EDR_PATH_LEN];
char cmdline[EDR_CMDLINE_LEN];
unsigned long long flags;
unsigned int dst_ip;
unsigned short dst_port;
unsigned int address_family;
unsigned long long mnt_ns;
unsigned long long pid_ns;
};
이 구조는 MVP로는 충분했다. /etc/shadow라는 민감 파일 이름이 syscall 인자로 들어왔다는 사실만으로도 조사할 가치가 있기 때문이다. 하지만 보안 로그에서 시도와 성공은 의미가 다르다. 2편에서는 이 차이를 기록하기 위해 syscall enter와 exit를 연결한다.
왜 enter만으로 부족한가
sys_enter_openat은 이름 그대로 openat syscall에 진입하는 시점이다. 이 시점에서는 사용자가 어떤 경로를 열려고 했는지, 어떤 flags를 넘겼는지 알 수 있다.
예를 들어 일반 사용자가 다음 명령을 실행했다고 하자.
cat /etc/shadow
현재 MVP는 이 동작을 /etc/shadow 접근으로 기록할 수 있다. openat 인자에 /etc/shadow가 들어왔기 때문이다. 그래서 기존 룰 이름도 SHADOW_READ로 두었다.
하지만 이 이벤트가 곧 /etc/shadow 읽기 성공을 의미하지는 않는다. 일반 사용자라면 보통 권한 문제로 파일 열기에 실패한다. 커널은 접근 권한, 파일 존재 여부, LSM 정책 같은 조건을 확인한 뒤에야 성공 또는 실패를 결정한다. 이 판단은 syscall 진입 시점이 아니라 syscall 종료 시점에 나온다.
즉 현재 SHADOW_READ는 엄밀히 말하면 /etc/shadow 읽기 성공 탐지가 아니다. /etc/shadow를 열려고 한 시도 탐지에 가깝다.
이 차이는 로그를 해석할 때 중요하다. 실패한 접근 시도도 의미가 있다. 누군가 민감 파일을 열려고 했다는 사실은 조사할 수 있다. 그러나 실제로 파일을 열어 fd를 얻은 경우는 더 강한 신호다. 같은 /etc/shadow 이벤트라도 실패한 일반 사용자 명령과 성공한 root 권한 명령은 심각도를 다르게 보는 편이 자연스럽다.
그래서 2편의 목표는 openat 탐지를 시도 기반에서 결과 기반으로 한 단계 끌어올리는 것이다. sys_enter_openat에서는 path와 flags를 임시로 저장하고, sys_exit_openat에서 반환값을 확인한 뒤 하나의 결과 이벤트로 만든다. 반환값이 0 이상이면 성공한 fd이고, 음수이면 실패 errno다.
이번 개선이 끝나면 로그는 단순히 누가 /etc/shadow라는 이름을 건드렸는지가 아니라, 그 접근이 실제로 성공했는지까지 말할 수 있어야 한다.
enter와 exit를 연결하는 설계
openat의 결과를 알려면 sys_exit_openat을 봐야 한다. 문제는 exit tracepoint만 보면 filename과 flags를 알 수 없다는 점이다. syscall 종료 시점에는 반환값은 있지만, 진입 시점에 넘어온 파일 경로 인자를 그대로 다시 읽는 구조가 아니다.
그래서 enter와 exit 사이에 작은 상태 저장 공간이 필요하다. enter 시점에는 path와 flags를 저장해두고, exit 시점에는 같은 syscall 흐름을 찾아 반환값과 합친다.
이번 구현에서는 BPF hash map을 하나 추가한다.
struct pending_openat {
char path[EDR_PATH_LEN];
unsigned long long flags;
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, u64);
__type(value, struct pending_openat);
} pending_openat SEC(".maps");
key는 pid_tgid를 사용한다.
u64 pid_tgid = bpf_get_current_pid_tgid();
여기서 상위 32비트는 process id, 하위 32비트는 thread id다. 단순히 pid만 쓰지 않는 이유는 같은 프로세스 안의 여러 thread가 동시에 openat을 호출할 수 있기 때문이다. thread 단위로 enter와 exit를 맞추려면 pid_tgid가 더 안전하다.
흐름은 다음과 같다.
sys_enter_openat
-> filename, flags 읽기
-> pending_openat[pid_tgid]에 저장
sys_exit_openat
-> pending_openat[pid_tgid] 조회
-> retval 읽기
-> path, flags, retval을 합쳐 EVENT_OPENAT 생성
-> ring buffer로 전송
-> pending_openat[pid_tgid] 삭제
그림으로 보면 이렇게 볼 수 있다.
flowchart TD
A[sys_enter_openat] --> B[read path and flags]
B --> C[pending_openat map 저장]
D[sys_exit_openat] --> E[pending_openat map 조회]
E --> F[retval 확인]
F --> G[openat 결과 이벤트 생성]
G --> H[BPF ring buffer]
H --> I[user-space rule engine]
I --> J[JSONL log]
이 방식으로 바꾸면 기존처럼 enter 시점에 바로 이벤트를 보내지 않는다. openat 이벤트는 exit 시점에 한 번만 보낸다. 그래야 하나의 로그 안에 path, flags, retval, success를 함께 담을 수 있다.
물론 상태를 저장하는 순간부터 신경 쓸 것이 늘어난다. enter에서 map에 넣은 값은 exit에서 반드시 지워야 한다. cleanup이 빠지면 오래된 pending 값이 남고, 나중에 다른 이벤트 해석을 방해할 수 있다. 또 exit에서 pending 값을 찾지 못하는 경우도 방어해야 한다. 모든 커널 이벤트 흐름이 내가 기대한 대로만 짝지어진다고 가정하면 안 된다.
그래서 이번 변경의 핵심은 단순히 tracepoint 하나를 더 붙이는 것이 아니다. openat 하나를 결과 이벤트로 만들기 위해 커널 안에서 짧은 생명주기의 상태를 관리하는 것이다.
이벤트 구조체 확장
enter와 exit를 연결해도 이벤트 구조체에 결과를 담을 필드가 없으면 유저랜드로 전달할 수 없다. 그래서 include/events.h의 struct event에 openat 결과를 표현할 필드를 추가한다.
struct event {
unsigned long long timestamp_ns;
unsigned int event_type;
unsigned int pid;
unsigned int ppid;
unsigned int uid;
unsigned int gid;
char comm[TASK_COMM_LEN];
char parent_comm[TASK_COMM_LEN];
char path[EDR_PATH_LEN];
char cmdline[EDR_CMDLINE_LEN];
unsigned long long flags;
unsigned int dst_ip;
unsigned short dst_port;
unsigned int address_family;
unsigned long long mnt_ns;
unsigned long long pid_ns;
long long retval;
int error_code;
unsigned char success;
};
새 필드는 세 개다.
retval: syscall 반환값 원본error_code: 실패했을 때 양수 errno 값success: syscall 성공 여부
Linux syscall은 보통 성공하면 0 이상의 값을 반환하고, 실패하면 음수 errno를 반환한다. openat의 경우 성공 시 반환값은 file descriptor다. 예를 들어 retval이 3이면 파일 열기에 성공했고 fd 3을 받았다는 뜻이다.
반대로 retval이 -13이면 실패다. 13은 EACCES에 해당하므로 권한 문제로 열지 못했다는 의미로 해석할 수 있다. 로그에서는 음수 원본 값을 retval에 그대로 남기고, 사람이 보기 쉬운 양수 errno는 error_code에 따로 둔다.
retval >= 0 -> success=true, error_code=0
retval < 0 -> success=false, error_code=-retval
success를 별도 필드로 둔 이유는 룰 엔진과 로그 조회를 단순하게 만들기 위해서다. 매번 retval의 부호를 해석하지 않아도 JSONL에서 바로 성공 여부를 필터링할 수 있다.
jq -c 'select(.event_type == "openat" and .success == true)' log/events.jsonl
이 필드는 지금은 openat에서만 의미가 있다. 하지만 공통 이벤트 구조체에 넣어두면 JSON writer와 룰 엔진이 하나의 이벤트 형식을 계속 사용할 수 있다. MVP의 단순한 구조를 유지하면서 필요한 결과 정보만 확장하는 방식이다.
JSON Lines 출력 확장
커널에서 retval, error_code, success를 채워도 JSONL에 쓰지 않으면 분석할 수 없다. 그래서 src/json_writer.c에서 openat 이벤트를 출력할 때 세 필드를 추가한다.
execve나 connect에는 이 필드가 아직 의미가 없으므로 모든 이벤트에 무조건 넣지는 않는다. event_type이 EVENT_OPENAT일 때만 다음 값을 출력한다.
{"event_type":"openat","path":"/etc/shadow","flags":0,"retval":-13,"error_code":13,"success":false}
성공한 경우에는 이런 형태가 된다.
{"event_type":"openat","path":"/etc/shadow","flags":0,"retval":3,"error_code":0,"success":true}
이제 JSONL만 봐도 /etc/shadow 접근이 실패한 시도였는지, 실제로 fd를 받은 성공 이벤트였는지 구분할 수 있다. 예를 들어 성공한 openat만 보고 싶으면 다음처럼 조회할 수 있다.
jq -c 'select(.event_type == "openat" and .success == true)' log/events.jsonl
실패한 권한 오류만 보고 싶으면 error_code를 기준으로 볼 수 있다.
jq -c 'select(.event_type == "openat" and .error_code == 13)' log/events.jsonl
이 변경으로 룰 엔진도 더 정확한 판단을 할 준비가 된다. 기존에는 /etc/shadow라는 path만 보고 SHADOW_READ를 붙였지만, 이제는 같은 path라도 success 값에 따라 시도와 성공을 나눌 수 있다.
룰 정책 변경
기존 룰 이름은 SHADOW_READ였다. 하지만 지금까지 정리한 것처럼 이 이름은 현재 동작을 정확히 표현하지 못한다. openat enter만 보던 시점에는 실제 읽기 성공 여부를 몰랐고, exit를 연결한 뒤에도 우리가 직접 보는 것은 read syscall이 아니라 파일 열기 결과다.
그래서 룰을 두 개로 나눈다.
SHADOW_OPEN_ATTEMPT: /etc/shadow openat 실패 시도
SHADOW_OPEN_SUCCESS: /etc/shadow openat 성공
설정 파일도 같은 기준으로 나눈다.
"shadow_open_attempt": {
"enabled": true,
"severity": "medium",
"paths": ["/etc/shadow"]
},
"shadow_open_success": {
"enabled": true,
"severity": "high",
"paths": ["/etc/shadow"]
}
정책은 단순하게 잡았다. /etc/shadow를 열려고 했지만 실패한 경우는 SHADOW_OPEN_ATTEMPT로 기록하고 severity는 medium으로 둔다. 실패한 시도도 조사할 가치는 있지만, 파일 내용을 실제로 얻었다고 보기는 어렵기 때문이다.
반대로 success=true이면 SHADOW_OPEN_SUCCESS로 기록하고 severity는 high로 둔다. 이 경우 syscall 반환값은 file descriptor이므로 민감 파일 열기에 성공한 이벤트다.
룰 엔진의 판단 순서는 성공을 먼저 보고, 그다음 실패 시도를 본다.
event_type == openat
-> path == /etc/shadow
-> success == true -> SHADOW_OPEN_SUCCESS
-> success == false -> SHADOW_OPEN_ATTEMPT
이름을 바꾼 이유는 로그를 읽는 사람이 착각하지 않게 하기 위해서다. SHADOW_READ는 짧고 익숙하지만 실제 구현보다 강한 의미를 준다. 이번 글의 목표가 시도와 성공을 구분하는 것이므로, 룰 이름도 그 차이를 그대로 드러내는 편이 낫다.
테스트 스크립트 보강
룰을 나눴으니 테스트도 실패 시도와 성공 접근을 모두 만들 수 있어야 한다. 기존 scripts/test_shadow_read.sh는 일반 사용자로 /etc/shadow를 읽는 동작만 실행했다. 이제는 두 케이스를 순서대로 트리거한다.
cat /etc/shadow >/dev/null 2>&1 || true
sudo -n cat /etc/shadow >/dev/null 2>&1 || true
첫 번째 명령은 일반 사용자 실패 케이스다. 보통 retval=-13, error_code=13, success=false로 기록된다. 이 이벤트는 SHADOW_OPEN_ATTEMPT와 매칭되어야 한다.
두 번째 명령은 root 권한 성공 케이스다. 테스트 자동화를 위해 sudo -n을 사용한다. -n 옵션은 sudo가 비밀번호를 물어보지 않게 한다. 비대화형 sudo 권한이 없는 환경에서는 실패하고, 스크립트는 성공 케이스를 건너뛴다.
로그 확인은 다음처럼 한다.
jq -c 'select(.path == "/etc/shadow")' log/events.jsonl
기대하는 실패 이벤트는 이런 형태다.
{"event_type":"openat","path":"/etc/shadow","retval":-13,"error_code":13,"success":false,"rule":"SHADOW_OPEN_ATTEMPT","severity":"medium"}
비대화형 sudo가 가능한 환경에서는 성공 이벤트도 남는다.
{"event_type":"openat","path":"/etc/shadow","retval":3,"error_code":0,"success":true,"rule":"SHADOW_OPEN_SUCCESS","severity":"high"}
errno 값은 배포판, 권한 설정, 보안 정책에 따라 달라질 수 있다. 그래서 테스트에서 핵심으로 볼 값은 success, rule, severity다.
실제로 일반 사용자로 테스트하면 다음처럼 실패 시도가 기록된다.
{"timestamp":11318339737214,"event_type":"openat","pid":100982,"ppid":100978,"uid":1000,"gid":1000,"comm":"cat","parent_comm":"bash","cmdline":"","path":"/etc/shadow","flags":0,"retval":-13,"error_code":13,"success":false,"dst_ip":null,"dst_port":null,"address_family":0,"mnt_ns":4026531832,"pid_ns":4026531836,"rule":"SHADOW_OPEN_ATTEMPT","severity":"medium"}
이전 MVP였다면 이 이벤트는 단순히 SHADOW_READ로 기록됐을 것이다. 이제는 retval=-13, success=false가 함께 남기 때문에 실제로 파일을 열지 못한 접근 시도였다는 점이 분명해졌다.
구현하면서 남은 한계
이번 변경으로 openat 탐지는 한 단계 정밀해졌지만, 파일 접근 탐지가 완성된 것은 아니다. 오히려 enter와 exit를 연결하면서 남은 문제가 더 잘 보인다.
첫째, 아직 보는 syscall은 openat뿐이다. 파일을 여는 경로는 커널과 libc 사용 방식에 따라 다양할 수 있다. open, openat2, creat 같은 다른 syscall은 아직 다루지 않는다. 이번 글의 목표는 파일 접근 전체를 포괄하는 것이 아니라, MVP의 openat 한계를 좁게 개선하는 것이다.
둘째, 경로 해석은 여전히 단순하다. 지금은 syscall 인자로 들어온 filename 문자열을 그대로 기록한다. /etc/shadow처럼 절대 경로가 들어오면 해석하기 쉽지만, 상대 경로와 dirfd 조합은 다르다. openat은 이름 그대로 directory file descriptor를 기준으로 상대 경로를 열 수 있다. 이 경우 문자열만 보고 최종 절대 경로를 복원하기는 어렵다.
셋째, enter와 exit 연결에는 상태 관리 비용이 있다. pending_openat map에 저장한 값은 exit에서 지워야 한다. ring buffer reserve에 실패해도 cleanup은 해야 한다. exit 이벤트에서 pending 값을 찾지 못하는 경우도 방어해야 한다. 단순히 tracepoint 하나를 추가하는 것보다 훨씬 조심스럽다.
넷째, 파일 내용은 보지 않는다. /etc/shadow를 열었는지 여부만 기록하고, 읽힌 내용이나 파일 데이터는 수집하지 않는다. 보안 도구를 만든다고 해서 민감 데이터를 로그에 남기는 것이 항상 좋은 선택은 아니다. 이번 프로젝트에서는 행위 메타데이터만 남기고, 민감 파일 내용은 다루지 않는다.
다섯째, success=true가 곧 공격 성공을 뜻하지는 않는다. root가 정상적인 관리 작업으로 /etc/shadow를 열 수도 있다. 이 로그는 공격 확정이 아니라 조사 우선순위를 높이는 신호다. 그래서 룰 이름도 SHADOW_OPEN_SUCCESS로 두었지, 공격을 단정하는 이름으로 두지 않았다.
2편 정리
1편의 MVP는 파일 접근 시도를 기록할 수 있었다. 하지만 sys_enter_openat만 봤기 때문에 실제 접근 성공 여부는 알 수 없었다. 그래서 /etc/shadow 이벤트를 봐도 그것이 실패한 접근 시도인지, 실제로 fd를 받은 성공 이벤트인지 구분하지 못했다.
이번 글에서는 이 문제를 해결하기 위해 sys_enter_openat과 sys_exit_openat을 연결했다. enter 시점에는 path와 flags를 pending_openat BPF hash map에 저장하고, exit 시점에는 같은 pid_tgid로 pending 값을 찾아 retval과 합쳤다. 그 결과 openat 이벤트는 path, flags, retval, error_code, success를 함께 가진 결과 이벤트가 됐다.
유저랜드에서는 JSONL 출력에 retval, error_code, success를 추가했다. 룰 정책도 기존 SHADOW_READ 하나에서 SHADOW_OPEN_ATTEMPT와 SHADOW_OPEN_SUCCESS 두 개로 나눴다. 실패한 접근 시도는 medium, 실제 성공 접근은 high로 기록한다.
이번 개선으로 미니 EDR은 단순히 누가 민감 파일 이름을 건드렸는지가 아니라, 그 접근이 실제로 성공했는지까지 말할 수 있게 됐다.
다음 글에서는 네트워크 쪽 오탐을 줄이는 방향으로 넘어간다. 현재 SUSPICIOUS_CONNECT는 프로세스 이름과 외부 IPv4 여부만 보고 판단한다. 3편에서는 command line과 allowlist 컨텍스트를 유저랜드에서 보강해, 같은 connect 이벤트라도 더 조심스럽게 해석하는 방향을 다룬다.
GitHub 레포
이번 글의 코드는 ebpf-guard part-2에서 볼 수 있다.