Building a Mini EDR with eBPF 3: Reducing False Positives in SUSPICIOUS_CONNECT
목차
1편에서는 execve, openat, connect를 tracepoint로 수집하고, eBPF ring buffer를 통해 유저랜드 룰 엔진과 JSON Lines writer까지 연결했다. 2편에서는 openat enter/exit를 연결해서 /etc/shadow 접근 시도와 성공을 구분했다.
이번 글에서는 네트워크 쪽으로 넘어간다. 새 syscall을 많이 추가하지는 않는다. 이미 수집하고 있던 connect 이벤트를 더 조심스럽게 해석하는 것이 목표다.
1편의 SUSPICIOUS_CONNECT는 의도적으로 단순했다. bash, python3, nc 같은 쉘이나 스크립트 런타임이 public IPv4로 연결하면 의심 이벤트로 남겼다. MVP 단계에서는 괜찮은 출발점이었다. 하지만 실제 로그에서는 정상 자동화와 의심 행위가 비슷하게 보인다.
예를 들어 python3 백업 스크립트가 외부 스토리지 API에 접속할 수 있다. bash 배포 스크립트가 패키지 저장소나 헬스체크 주소에 연결할 수도 있다. 관리자가 nc로 정상 네트워크 점검을 할 수도 있다.
그래서 이번 개선의 핵심은 탐지를 더 크게 부풀리는 것이 아니다. 같은 connect 이벤트를 command line, 목적지 IP 분류, allowlist와 함께 보고 오탐을 줄이는 것이다.
connect 이벤트 하나만으로 공격을 확정할 수는 없다.
탐지 품질을 높이려면 커널에서 모든 판단을 하려 하지 말고,
유저랜드에서 command line, 목적지, allowlist 같은 컨텍스트를 보강해야 한다.기존 룰의 문제
기존 SUSPICIOUS_CONNECT 조건은 단순했다.
event_type == connect
comm in suspicious_process_names
dst_ip is external IPv4
이 방식은 빠르게 구현할 수 있다. eBPF 프로그램은 sys_enter_connect에서 sockaddr_in을 읽고, 유저랜드 룰 엔진은 프로세스 이름과 목적지 주소만 보면 된다.
하지만 한계도 분명하다.
comm은 16바이트 제한이 있다.- 프로세스 이름은 바꿀 수 있다.
- command line을 보지 않으면
/dev/tcp/같은 단서를 놓친다. - private IP 제외만으로는 조직별 정상 목적지를 표현하기 어렵다.
- public IP라고 해서 악성은 아니다.
특히 comm만 보는 룰은 정상 운영 스크립트를 자주 잡을 수 있다. python3라는 이름만으로는 백업 스크립트인지, 원라이너 실행인지, 의심스러운 reverse shell 준비 동작인지 알 수 없다.
이번에는 connect 이벤트 자체는 유지하되, 유저랜드에서 해석에 필요한 컨텍스트를 보강한다.
command line 보강
현재 eBPF 프로그램은 execve에서 argv0 수준의 command line만 수집한다. connect 이벤트에는 command line이 없다. 커널 안에서 긴 argv 전체를 무리하게 읽는 대신, 유저랜드에서 /proc/<pid>/cmdline을 best-effort로 읽기로 했다.
흐름은 다음과 같다.
connect 이벤트 수신
-> /proc/<pid>/cmdline 읽기
-> NUL 구분자를 공백으로 변환
-> 룰 엔진 입력 event.cmdline에 반영
-> JSONL cmdline 필드에 기록
구현은 src/main.c의 ring buffer callback에서 처리했다. 커널에서 받은 이벤트를 바로 쓰지 않고 local copy를 만든 뒤, EVENT_CONNECT일 때만 /proc/<pid>/cmdline을 읽는다.
static void enrich_cmdline_from_proc(struct event *event)
{
char path[64];
FILE *file;
size_t nread;
if (event->event_type != EVENT_CONNECT || !event->pid)
return;
snprintf(path, sizeof(path), "/proc/%u/cmdline", event->pid);
file = fopen(path, "rb");
if (!file)
return;
nread = fread(event->cmdline, 1, sizeof(event->cmdline) - 1, file);
fclose(file);
event->cmdline[nread] = '\0';
for (size_t i = 0; i < nread; i++) {
if (event->cmdline[i] == '\0')
event->cmdline[i] = ' ';
}
while (nread > 0 && event->cmdline[nread - 1] == ' ')
event->cmdline[--nread] = '\0';
}
이 보강은 완벽하지 않다. 이벤트를 받은 순간 프로세스가 이미 종료됐으면 /proc/<pid>/cmdline을 읽을 수 없다. 권한이나 namespace 차이로 실패할 수도 있다. command line에는 토큰이나 경로 같은 민감 정보가 들어갈 수 있다는 점도 조심해야 한다.
그래서 이 프로젝트에서는 command line 보강을 탐지의 필수 조건으로 두지 않았다. 읽을 수 있으면 판단 근거가 늘어나고, 읽지 못하면 기존 이벤트만으로 판단한다. 보강 실패는 탐지 실패가 아니라 컨텍스트 부족이다.
목적지 IP 분류
기존 MVP도 private IP를 제외했다. 하지만 로그에 그 판단 근거가 남지는 않았다. 이번에는 목적지 IPv4를 분류해서 JSONL에 dst_ip_class로 기록한다.
기본 분류는 단순하게 잡았다.
loopback: 127.0.0.0/8
private: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
public: 위 대역에 속하지 않는 IPv4
unknown: 목적지 주소가 없거나 해석할 수 없는 경우
예를 들어 public IP 연결은 이렇게 남는다.
{"event_type":"connect","dst_ip":"203.0.113.10","dst_ip_class":"public"}
private IP 연결은 이렇게 남는다.
{"event_type":"connect","dst_ip":"192.168.0.10","dst_ip_class":"private"}
이 필드의 목적은 공격 여부를 확정하는 것이 아니다. 룰이 왜 매칭됐는지, 또는 왜 suppression 되었는지 로그만 보고 확인하기 쉽게 만드는 것이다. public IP라고 악성은 아니고, private IP라고 안전한 것도 아니다. 다만 이번 글의 범위에서는 외부 연결 휴리스틱의 오탐을 줄이는 데 집중한다.
allowlist와 suppression
오탐을 줄이려면 정상 목적지와 정상 command line을 설정으로 표현할 수 있어야 한다. 그래서 config/rules.json의 suspicious_connect에 세 가지 설정을 추가했다.
"suspicious_connect": {
"enabled": true,
"severity": "high",
"process_names": ["sh", "bash", "dash", "zsh", "python", "python3", "perl", "ruby", "nc", "ncat", "socat"],
"cmdline_tokens": ["/dev/tcp/", "bash -i", "sh -i", "python -c", "python3 -c", "perl -e", "socat ", "ncat "],
"exclude_private_ip": true,
"allow_dst_ips": ["1.1.1.1"],
"allow_cmdline_prefixes": ["python3 /opt/backup/"],
"suppression_mode": "mark"
}
allow_dst_ips는 정상 목적지 IP를 지정한다. 예시에서는 1.1.1.1을 allowlist로 뒀다. allow_cmdline_prefixes는 정상 command line prefix를 지정한다. 예를 들어 python3 /opt/backup/로 시작하는 백업 스크립트는 public IP에 연결하더라도 바로 high severity 탐지로 보지 않는다.
suppression_mode는 allowlist에 걸린 이벤트를 어떻게 처리할지 정한다.
mark: 이벤트를 저장하되 suppressed=true로 남긴다.
drop: 이벤트를 저장하지 않는다.
포트폴리오에서는 mark가 더 낫다고 판단했다. 왜냐하면 탐지 도구를 만드는 과정에서 판단 근거를 보여줄 수 있기 때문이다. 운영 환경에서는 로그량을 줄이기 위해 drop을 선택할 수도 있지만, 지금은 오탐 저감 과정 자체를 로그에 남기는 편이 학습과 설명에 더 적합하다.
suppression된 이벤트는 이런 형태가 된다.
{"event_type":"connect","comm":"python3","cmdline":"python3 /opt/backup/upload.py","dst_ip":"8.8.8.8","dst_ip_class":"public","rule":null,"severity":null,"suppressed":true,"suppress_reason":"allow_cmdline_prefix"}
private IP 제외도 이번에는 같은 suppression 형식으로 표현했다.
{"event_type":"connect","comm":"bash","dst_ip":"192.168.0.10","dst_ip_class":"private","rule":null,"severity":null,"suppressed":true,"suppress_reason":"private_ip"}
이렇게 하면 룰이 매칭되지 않은 이유를 로그에서 바로 볼 수 있다.
개선된 SUSPICIOUS_CONNECT
개선 전 룰은 프로세스명과 public IP 여부만 봤다.
comm in suspicious_process_names
dst_ip_class == public
개선 후에는 allowlist와 command line token을 함께 본다.
event_type == connect
address_family == AF_INET
dst_ip_class == public
not allow_dst_ip
not allow_cmdline_prefix
(
comm in suspicious_process_names
OR cmdline contains suspicious token
)
command line token 후보는 다음처럼 잡았다.
/dev/tcp/bash -ish -ipython -cpython3 -cperl -esocatncat
예를 들어 이런 이벤트는 여전히 SUSPICIOUS_CONNECT로 남는다.
{"event_type":"connect","comm":"bash","cmdline":"bash -c exec 3<>/dev/tcp/203.0.113.10/4444","dst_ip":"203.0.113.10","dst_ip_class":"public","rule":"SUSPICIOUS_CONNECT","severity":"high","suppressed":false,"suppress_reason":null}
반대로 allowlist 목적지로 가는 이벤트는 탐지로 올리지 않고 suppression으로 표시한다.
{"event_type":"connect","comm":"bash","cmdline":"bash -c exec 3<>/dev/tcp/1.1.1.1/80","dst_ip":"1.1.1.1","dst_ip_class":"public","rule":null,"severity":null,"suppressed":true,"suppress_reason":"allow_dst_ip"}
정상 프로세스가 public IP에 연결하는 경우도 룰 매칭 없이 남는다.
{"event_type":"connect","comm":"curl","cmdline":"curl https://example.com/health","dst_ip":"93.184.216.34","dst_ip_class":"public","rule":null,"severity":null,"suppressed":false,"suppress_reason":null}
이 룰도 우회될 수 있다. 문자열 token 기반 탐지는 완벽하지 않다. 정상 자동화 스크립트가 비슷한 문자열을 포함할 수도 있다. 그래서 이 룰은 공격 확정 판정이 아니라 조사 우선순위를 만드는 도구로 봐야 한다.
JSONL 출력 변경
이번 변경으로 connect 이벤트에는 다음 필드가 추가된다.
dst_ip_class
suppressed
suppress_reason
cmdline
cmdline은 기존 필드를 재사용한다. connect 이벤트에서는 유저랜드에서 /proc/<pid>/cmdline을 읽어 채운 값이다.
최종적으로 connect 로그는 다음 정보를 한 줄에 담는다.
{"timestamp":123,"event_type":"connect","pid":1000,"ppid":999,"uid":1000,"gid":1000,"comm":"bash","parent_comm":"bash","cmdline":"bash -c exec 3<>/dev/tcp/203.0.113.10/4444","path":"","flags":0,"dst_ip":"203.0.113.10","dst_port":4444,"address_family":2,"mnt_ns":4026531832,"pid_ns":4026531836,"dst_ip_class":"public","suppressed":false,"suppress_reason":null,"rule":"SUSPICIOUS_CONNECT","severity":"high"}
이제 로그를 읽는 사람은 단순히 SUSPICIOUS_CONNECT가 찍혔다는 사실만 보는 것이 아니라, 목적지 분류와 suppression 여부까지 함께 볼 수 있다.
샘플 로그 기반 회귀 테스트
네트워크 이벤트 수집은 커널과 권한, 외부 네트워크 상태에 영향을 받는다. 룰 엔진은 그보다 자주 바뀐다. 그래서 모든 룰 변경을 실제 eBPF 실행으로만 검증하면 피드백이 느려진다.
이번에는 tests/samples/에 기대 결과 형태의 샘플 JSONL을 추가했다.
tests/samples/connect_public_suspicious.jsonl
tests/samples/connect_private_suppressed.jsonl
tests/samples/connect_allow_dst_ip.jsonl
tests/samples/connect_allow_cmdline_prefix.jsonl
tests/samples/connect_public_normal_process.jsonl
그리고 tests/test_connect_samples.sh에서 각 샘플이 기대한 필드를 갖는지 확인한다.
jq -e 'select(.rule == "SUSPICIOUS_CONNECT" and .suppressed == false and .dst_ip_class == "public")' \
tests/samples/connect_public_suspicious.jsonl >/dev/null
이 테스트는 eBPF 프로그램을 대신하지 않는다. 커널 이벤트 수집 테스트와 룰 결과 형태 검증은 별개다. 다만 룰 정책을 바꿀 때 어떤 출력 형태를 유지해야 하는지 샘플로 남겨두면 회귀 위험을 줄일 수 있다.
IPv6를 이번 범위에서 뺀 이유
이번 글에서는 IPv6를 구현하지 않았다. sockaddr_in6 파싱과 IPv4/IPv6 공통 출력 포맷은 다음 과제로 남겼다.
이유는 단순하다. 3편의 핵심은 주소 체계를 넓히는 것이 아니라, 이미 수집한 connect 이벤트를 더 신중하게 해석하는 것이다. IPv6까지 같이 넣으면 글의 초점이 수집 범위 확장으로 흐를 수 있다.
지금은 IPv4에 대해 command line 보강, IP 분류, allowlist, suppression을 먼저 정리했다.
3편 정리
1편의 SUSPICIOUS_CONNECT는 쉘이나 스크립트 런타임이 public IPv4로 연결하면 의심 이벤트로 남기는 단순한 룰이었다. 단순한 룰은 빨리 만들 수 있지만, 실제 로그에서는 정상 자동화와 의심 행위가 비슷하게 보인다.
이번 글에서는 connect 이벤트를 더 조심스럽게 해석하기 위해 유저랜드에서 /proc/<pid>/cmdline을 읽어 command line을 보강했다. 목적지 IPv4는 dst_ip_class로 분류했고, allow_dst_ips, allow_cmdline_prefixes, suppression_mode를 설정에 추가했다.
그 결과 allowlist에 걸린 이벤트는 rule=null로 두고, suppressed=true, suppress_reason으로 이유를 남길 수 있게 됐다. 반대로 public IP로 향하고 allowlist에 걸리지 않으며, 의심 프로세스명 또는 의심 command line token이 있는 이벤트는 SUSPICIOUS_CONNECT로 기록한다.
3편의 목표는 탐지를 더 크게 부풀리는 것이 아니라, 같은 이벤트를 더 조심스럽게 해석해서 조사할 가치가 있는 로그를 남기는 것이다.
GitHub 레포
이번 글의 코드는 ebpf-guard part-3에서 볼 수 있다.