엔드포인트에서 수집한 행위 로그로 공격 시퀀스를 탐지하고, 그 기기의 프로세스를 실제로 종료하는 EDR 백엔드
Endpoint Detection and Response. 개별 기기에서 벌어지는 일을 관찰해 위협을 찾아내고 대응까지 하는 보안 체계다.
| 단계 | 하는 일 | 사용 기술 |
|---|---|---|
| Endpoint | 기기 안의 프로세스·파일·네트워크 활동을 관찰해 서버로 보낸다 | 자체 에이전트 (Go), ETW, EndpointSecurity |
| Detection | 모아진 활동에서 공격을 판정한다 | Kafka, Kafka Streams |
| Response | 판정된 위협에 실제로 대응한다 | 자체 에이전트의 명령 채널 |
- 백신은 파일이 무엇인지 본다. 알려진 악성코드의 지문과 대조한다
- EDR은 파일이 무엇을 했는지 본다. 무엇을 실행했고 어디에 접속했는지, 어떤 순서로 했는지를 본다
백신 이 파일이 악성코드 목록에 있나?
EDR 이 파일이 방금 외부에서 받아온 것이고, 받자마자 실행됐나?
지문이 없는 새로운 공격은 백신을 통과한다. EDR 은 행위를 보기 때문에 개별로는 정상인 행동의 조합을 잡아낼 수 있다.
라이선스는 agent/ 만 GPL-3.0 이고 나머지는 MIT 다. 에이전트가 쓰는 ETW 라이브러리가
GPL-3.0 이라 그렇다. 자세한 것은 agent/README.md 에 있다.
수집은 직접 만든 Go 에이전트가 한다. 코드는 agent/ 에 있고 서버와의 계약은
docs/agent-protocol.md 다.
예전에는 프로세스·파일을 osquery 로, 네트워크를 Zeek 로 받아 서버에서 host 이름으로 묶었다. 두 도구가 각자 잘하는 것은 분명했지만, 나눠 놓으면 정작 필요한 연결이 끊긴다.
| 보는 것 | 알 수 없는 것 | |
|---|---|---|
| 프로세스만 보는 수집기 | 기기 안. 어떤 프로세스가 무엇을 실행하고 어떤 파일을 건드렸나 | 그 프로세스가 어디와 통신했나 |
| 패킷만 보는 수집기 | 기기 밖. 어디로 얼마나 연결했나 | 그 연결을 누가 만들었나 |
한 줄로 줄이면 이렇다.
osquery 는 소켓의 주인을 모른다
Zeek 는 PID 를 모른다
이 시스템에서 심각도가 가장 높은 R2(다운로드 후 실행) 가 정확히 두 관점을 이어야 성립하는
룰이다. 도구가 둘이면 그 연결은 같은 host 에서 비슷한 시각에 일어났다 는 정황으로 추정할 수밖에
없다. 한 에이전트가 프로세스 이벤트와 소켓 이벤트를 같이 받으면 그 둘은 PID 로 이어진다.
추정이 아니라 커널이 알려준 사실이다. 이벤트의 process 필드에 연결을 만든 프로세스 이름이
실려 오는 것이 그래서 가능하다.
Windows 에는 사정이 하나 더 있었다. Zeek 는 Windows 를 공식 지원조차 하지 않는다. 리눅스 장비를 따로 세워 트래픽을 미러링하는 구성이 아니면 Windows 엔드포인트의 네트워크는 애초에 붙일 방법이 없었다. 두 도구를 유지하려면 엔드포인트마다 설치할 것이 둘, 권한 승인이 둘, 플랫폼별 예외가 그 두 배로 늘어난다.
플랫폼마다 커널이 사건을 알려주는 방식이 다르다. 에이전트는 그 차이를 안에서 흡수하고 서버에는 같은 형식으로 보낸다.
| 프로세스 | 네트워크 | 파일 | |
|---|---|---|---|
| Windows | ETW Microsoft-Windows-Kernel-Process (ProcessStart) |
ETW Microsoft-Windows-Kernel-Network (TCP 연결 시도) |
ETW Microsoft-Windows-Kernel-File (파일 생성) |
| macOS | eslogger 의 EndpointSecurity exec |
libproc 소켓 스냅샷 | eslogger 의 create/rename/unlink |
Windows ETW 센서는 실제 Windows 기기에서 검증되지 않았다. 크로스 컴파일과 순수 로직 단위 테스트까지만 통과한 상태다. 실기기에서 확인해야 할 목록은
agent/README.md의Windows 실기기 검증 절차에 있다.
macOS 는 EndpointSecurity API 를 직접 쓰지 않고 애플이 기본 탑재한 eslogger 를 자식 프로세스로
띄워 그 출력을 읽는다. API 를 직접 부르려면 애플의 entitlement 심사를 통과해야 하는데, eslogger
는 그 권한을 이미 가진 서명 바이너리라 심사 없이 같은 이벤트를 받을 수 있다. 대신 애플이 man
page 에 이것은 API 가 아니며 릴리스마다 출력 구조가 바뀔 수 있다 고 못 박아 두었다. 이 한계는
그대로 안고 간다.
공격 도구는 대개 짧게 실행되고 사라진다. 주기적으로 상태를 조회하면 조회와 조회 사이에 뜨고 사라진 프로세스를 그대로 놓친다. 그래서 수집기를 갈아치우면서도 이벤트 구독은 유지했다. 커널이 밀어 주는 것을 받는다.
예외가 하나 있다. macOS 네트워크만 폴링이다. EndpointSecurity API 에는 소켓 연결 이벤트가 없다. 프로세스와 파일 중심으로 설계된 API고, 진짜 연결 이벤트를 구독하려면 NetworkExtension entitlement 가 필요한데 그건 애플의 심사 대상이다. 그래서 열려 있는 TCP 연결을 짧은 주기로 훑고 직전 스냅샷에 없던 것만 새 연결로 본다. 주기 사이에 열렸다 닫힌 연결은 놓친다. 알려진 한계이고 문서에 적어 둔다. 그래도 패킷만 보는 것과 달리 어느 프로세스가 연결했는지는 알 수 있다.
- 에이전트는 조직마다 다른 enroll secret 으로 등록하고 node_key 를 받아 이후 요청에 실어 보낸다
- 조직 구분은 서버가 node_key 로 푼다. 엔드포인트가 보낸 조직 태그는 믿지 않는다
- 에이전트가 서버 인증서를 고정(pinning) 한다. 설정에 적은 그 인증서로 서명된 서버일 때만 붙는다
- 서버가 재시작해 키를 잃으면 401 이 오는데, 에이전트는 그때 스스로 다시 등록하고 한 번 재시도한다
엔드포인트가 보낸 로그는 곧장 판정으로 가지 않고 Kafka 토픽에 쌓인다.
| 얻는 것 | 설명 |
|---|---|
| 완충 | 판정이 느려지거나 재시작해도 수집이 멈추지 않는다 |
| 분기 | 같은 스트림을 판정(detector)과 저장(archiver)이 각자의 속도로 나눠 읽는다 |
| 재처리 | 룰을 고친 뒤 오프셋을 되돌려 과거 이벤트를 다시 판정할 수 있다 |
Kafka 는 메시지를 메모리 큐가 아니라 디스크의 append-only 로그에 쓴다. 전통적인 메시지 큐가 소비되면 지우는 것과 달리, 컨슈머가 읽어가도 보존 기간 동안 그대로 남는다.
여기서 따라오는 것들:
- 컨슈머가 죽어도 그동안 쌓인 메시지가 남아 있다. 다시 떠서 마지막 오프셋부터 이어 읽는다
- 오프셋을 되돌리면 이미 읽은 구간을 다시 읽을 수 있다
- 같은 메시지를 여러 컨슈머 그룹이 각자의 오프셋으로 읽는다
마지막 항목이 이 시스템의 구조를 만든다. events 토픽 하나를 detector 그룹과 archiver 그룹이
각각 읽어서, 판정과 저장이 서로의 속도에 영향을 주지 않는다.
- 순차 쓰기: 로그가 append-only 라 쓰기가 항상 파일 끝에 붙는다
- 순차 읽기: 컨슈머도 오프셋 순서대로 읽는다
- 디스크는 임의의 위치를 찾아가는 랜덤 접근이 느리지, 순차 접근은 그렇지 않다
여기에 두 가지가 더해진다.
- 페이지 캐시: 자체 캐시를 만들지 않고 OS 페이지 캐시를 그대로 쓴다. 방금 쓴 데이터를 바로 읽어가는 컨슈머는 디스크까지 가지 않고 메모리에서 받는다
- zero-copy: 디스크의 데이터를 네트워크로 보낼 때 애플리케이션 메모리로 복사하지 않고 커널 안에서 바로 전달한다
| 토픽 | 파티션 | 복제 계수 | 담는 것 |
|---|---|---|---|
events |
3 | 1 | collector 가 검증한 이벤트 |
alerts |
3 | 1 | 판정 결과 |
파티션을 3으로 둔 이유
- 파티션은 병렬 처리의 단위다. 한 컨슈머 그룹 안에서 파티션 하나는 컨슈머 하나가 맡는다
- 파티션 수보다 컨슈머를 많이 띄워도 남는 쪽은 논다. 즉 파티션 수가 병렬성의 상한이다
- 지금은 서비스마다 인스턴스가 하나라 한 인스턴스가 3개를 모두 읽는다
- 부하가 늘면 코드를 고치지 않고 인스턴스를 3대까지 늘릴 수 있다
파티션 수는 함부로 바꾸지 않는다
- 늘릴 수는 있어도 줄일 수 없다
- 키 기반 파티셔닝에서는 파티션 수가 바뀌면 같은 키가 다른 파티션으로 배정된다
- host 를 키로 쓰는 이 시스템에서는 그 순간 한 기기의 이벤트가 두 파티션으로 갈라져 순서 보장이 깨진다
복제 계수가 1인 이유
- 브로커가 한 대이기 때문이다. 같은 노드에 복제해봐야 그 노드가 죽으면 함께 사라진다
- 운영 환경이라면 브로커 3대에 복제 계수 3이 기본이다. 그래야 한 대가 죽어도 다른 복제본이 리더를 넘겨받아 유실 없이 이어진다
- 이 설정에서 유실에 강한 것은 컨슈머 장애까지다. 브로커 장애는 대비되어 있지 않다
보존 기간은 기본값(7일)을 쓴다.
룰이 한 건만 보고 판단한다면 스트림 처리가 필요 없다. 그런데 공격은 여러 행동의 순서로 드러난다.
파일 다운로드 정상. 매일 일어난다
파일 실행 정상. 매일 일어난다
받아온 그 파일을 실행 의심스럽다
- host 별로 최근 이벤트를 버퍼에 들고 있는다
- 새 이벤트가 올 때마다 그 버퍼와 대조한다
- 윈도우는 5분이다
판정 결과는 MITRE ATT&CK 기법 번호로 매핑한다. 공격 기법을 부르는 공통 언어라서 탐지 내용을 설명할 때 서로 다른 도구끼리도 말이 통한다.
| 룰 | 무엇을 잡나 | 기법 |
|---|---|---|
| R1 | 오피스 문서가 shell 을 띄움 | T1059 |
| R2 | 외부에서 받아온 파일을 실행 | T1105 + T1204 |
| R3 | 임시나 다운로드 경로에서 스크립트 실행 | T1059 |
| R4 | 자동실행 경로에 파일 생성 | T1547 |
Kafka Streams 는 별도 클러스터가 필요한 프레임워크가 아니라 애플리케이션에 붙는 라이브러리다.
- Flink 나 Spark 는 잡 매니저와 워커로 이루어진 클러스터를 따로 운영해야 한다
- Kafka Streams 는 서비스 프로세스 안에서 함께 돈다
- 처리량을 늘리려면 서비스 인스턴스를 늘리면 되고, 그때 파티션이 자동으로 재분배된다
| 개념 | 이 프로젝트의 값 | 무엇인가 |
|---|---|---|
application.id |
detector |
컨슈머 그룹 ID 이자 내부 토픽 이름의 접두어 |
| 리파티션 토픽 | events-by-host |
키를 바꾼 뒤 다시 나누기 위한 내부 토픽 |
| 상태 저장소 | persistentKeyValueStore |
host 별 이벤트 버퍼. RocksDB 기반 |
| 태스크 | 파티션 수만큼 | 파티션 3개면 태스크 3개가 만들어져 스레드에 배정된다 |
리파티션
- 상관분석은 같은 host 의 이벤트가 한 군데 모여야 성립한다
selectKey로 키를 바꿔도 레코드는 원래 파티션에 그대로 있다. 이름표만 바뀐 셈이다- 그래서 내부 토픽으로 한 번 내보냈다가 다시 읽는다. 이때 새 키 기준으로 자리가 정해진다
지금은 events 에 넣는 쪽이 모두 host 를 키로 쓰고 있어서 실제로 재분배가 일어나지는 않는다.
그래도 이 단계를 두는 것은 생산자가 키를 수정하거나 삭제해도 상관분석이 깨지지 않게 하기
위해서다. 대가는 내부 토픽을 한 번 거치는 지연이다.
상태 저장소와 changelog
- 버퍼는 각 인스턴스의 로컬 디스크(RocksDB)에 있다. 메모리에만 두면 재시작할 때 사라진다
- 동시에 Kafka Streams 가 상태 변경을 changelog 토픽에 기록한다
- 인스턴스가 죽으면 파티션을 넘겨받은 쪽이 이 기록을 되짚어 상태를 복원한다
DSL 과 Processor API
| DSL | map, filter 같은 정해진 연산을 조립한다 |
| Processor API | 레코드 하나하나를 직접 다룬다 |
이 시스템은 버퍼를 앞뒤로 훑고, 상관에 쓰일 이벤트만 남기고, 윈도우 밖의 것을 그때그때 지운다. DSL 의 정해진 연산으로는 표현되지 않아서 Processor API 로 직접 작성했다.
Kafka 는 파티션 안에서만 순서를 보장한다. 파티션이 여럿이면 토픽 전체의 순서라는 것은 없다. 그래서 두 가지를 한다.
첫째, 파티션 키를 host 로 둔다
- 같은 키는 같은 파티션으로 가므로 한 기기가 보낸 이벤트는 발행한 순서대로 읽힌다
- 상관분석은 어차피 기기 단위라서 다른 기기와의 순서는 필요하지 않다
- 판정 단계에서도 host 로 다시 키를 잡아 한 기기의 상태를 한 처리기가 도맡는다
둘째, 판정은 도착 순서가 아니라 발생 시각으로 한다
파티션을 나눠도 도착 순서가 발생 순서와 같다는 보장은 없다. 수집기를 하나로 합쳤어도 그렇다.
- 에이전트는 이벤트를 버퍼에 모아 주기마다 배치로 보낸다. 전송이 실패하면 그 배치는 버퍼로 되돌아가 다음 주기에 다시 나간다
- macOS 네트워크는 폴링이다. 연결이 일어난 시점과 에이전트가 그것을 관측하는 시점 사이에 최대 한 주기의 간격이 있다. 반면 exec 이벤트는 커널이 곧바로 밀어 준다
실제로 일어난 순서 [다운로드] → [실행]
서버에 도착한 순서 [실행] → [다운로드] 네트워크 관측이 한 주기 늦다
도착 순서로 다운로드 다음에 실행 을 찾으면 이 조건은 영영 성립하지 않는다. 실행 이벤트가
먼저 도착하기 때문이다.
그래서 시각을 Kafka 에 넣기 전, 엔드포인트에서 찍힌 값으로 쓴다.
| 이벤트 | 시각의 출처 | 의미 |
|---|---|---|
| 프로세스 / 파일 | 커널이 이벤트에 실어 준 시각 | 그 사건이 기기에서 일어난 시각 |
| 네트워크 (Windows) | ETW 이벤트의 TimeCreated |
연결을 시도한 시각 |
| 네트워크 (macOS) | 그 연결을 처음 관측한 스냅샷 시각 | 실제 연결 시각보다 최대 한 주기 늦다 |
- 서버가 받은 시각이 아니다
- 전송이 지연되든 순서가 뒤바뀌든 사건들 사이의 시간 관계는 그대로 남는다
- 버퍼도 이 시각으로 훑는다. 새 이벤트보다 앞선 것뿐 아니라 뒤선 것까지 양쪽으로 본다
Kafka Streams 의 기본 처리 보장은 at-least-once 다. 장애나 리밸런스로 오프셋 커밋 전에 재시작하면 같은 이벤트를 다시 처리한다. 즉 같은 판정이 두 번 나올 수 있다.
exactly-once 를 켜는 선택지가 있지만 쓰지 않았다.
- 트랜잭션 코디네이터가 붙어 처리량이 떨어지고 지연이 늘어난다
- 중복 판정 자체가 문제가 아니라 중복 알림이 문제다. 그쪽에서 막는 편이 싸다
그래서 알림 단계에서 억제한다.
| 값 | |
|---|---|
| 억제 키 | 수신 대상 + host + ruleId |
| 억제 창 | 60초 |
두 가지를 신경 썼다.
- 처리하는 시점의 현재 시각이 아니라 알림에 실린 이벤트 시각으로 판단한다. 현재 시각을 쓰면 같은 입력을 재처리했을 때 결과가 달라진다. 이벤트 시각을 쓰면 몇 번을 다시 돌려도 같은 결과가 나온다
- 발송에 실패하면 쿨다운을 되돌린다. 실패했는데 쿨다운을 소모하면 그 60초 동안 그 알림은 아예 전달되지 않는다. 실패는 억제 대상이 아니다
중복 거르는 방법
쿨다운 기록은 Redis 같은 외부 저장소가 아니라 인스턴스 안의 메모리에 있다. 그런데도 인스턴스를 늘릴 수 있는 이유는 파티셔닝에 있다.
alerts 토픽 파티션 키 = host (판정 단계의 스트림 키가 그대로 실림)
↓
같은 host 알림 = 항상 같은 파티션
↓
컨슈머 그룹 alert 안에서 그 파티션은 한 인스턴스가 맡음
↓
같은 host 알림은 늘 같은 인스턴스가 처리 = 로컬 맵으로 충분
리밸런스 직후나 재시작 직후에는 이전 기록이 없어 한 번 더 갈 수 있다. 알림이 한 번 더 가는 정도라 저장소를 하나 더 두는 것보다 낫다고 봤다.
| 담는 것 | 이유 | |
|---|---|---|
| ClickHouse | 이벤트와 판정 기록 원본 | 컬럼 지향이라 집계할 때 필요한 컬럼만 읽는다. 계속 쌓이고 수정하지 않는 로그 데이터에 맞다 |
| MySQL | 계정, 조직 설정, 알림 처리 상태 | 건건이 수정되고 트랜잭션이 필요하다 |
알림 목록은 ClickHouse 의 판정 기록 위에 MySQL 의 처리 상태를 겹쳐서 보여준다.
탐지까지만 하고 멈추면 사람이 그 기기를 직접 찾아가야 한다. 대응을 하려면 명령을 받아 실행하는 통로가 기기마다 있어야 하는데, 이 통로는 잘못 만들면 그 자체가 백도어가 된다.
엔드포인트는 방화벽 안쪽에 있어서 서버가 먼저 접속할 수 없다. 그래서 에이전트가 하트비트를 보낼 때 대기 중인 명령을 같이 받아 간다. 서버가 기기에 접속하지 않으므로 대상 기기에 포트를 열 필요가 없다.
폴링이면 결과를 즉시 볼 수 없어야 정상인데, 대시보드에서는 조치 버튼을 누르면 결과가 바로 뜬다.
서버가 대신 기다려 주기 때문이다. POST /api/responder/kill 은 명령을 큐에 넣고 결과가 올
때까지 블로킹한다. 하트비트 주기가 짧으면 사람이 느끼는 지연은 몇 초다. 상한을 넘기면 TIMEOUT
으로 답한다.
예전에는 PowerShell 또는 sh 스크립트를 통째로 내려보내 엔드포인트가 그걸 실행했다. 플랫폼마다 문법이 달라 스크립트를 두 벌 관리해야 했고, 무엇보다 모든 엔드포인트에 임의 코드 실행 채널을 하나씩 열어 두는 셈이었다. 수집기까지 우리가 만드는 마당에 그 채널을 남길 이유가 없다.
그래서 명령의 종류를 정해진 것으로 좁혔다. 지금 있는 것은 kill_process 하나이고, 서버는 대상
문자열만 보낸다. 실제 동작은 에이전트 안의 Go 코드가 한다. 모르는 종류의 명령이 오면 실행하지
않고 실패로 보고한다.
실행 경로는 전부 만들되 자동으로 쏘지 않는다. 오탐이 한 번이라도 있으면 멀쩡한 프로세스를 죽이기 때문이다. 대시보드에서 확인 단계를 거쳐야 실행된다.
대상은 조치 시점에 다시 찾는다
- 탐지 시점의 프로세스 번호(pid)를 그대로 쓰면 위험하다
- 그 사이 그 프로세스가 끝나고 같은 번호를 다른 프로세스가 받았을 수 있다
- 그래서 에이전트가 조치하는 그 순간에 실행 중인 프로세스 목록을 다시 훑어 대상을 해석한다
(
agent/internal/command/kill.go) - 대상에 경로 구분자가 있으면 전체 경로가 같아야 하고, 없으면 파일명끼리 비교한다. 부분 일치는 쓰지 않는다. 같은 이름의 정상 프로세스를 지키기 위해서다
안전장치
| 자기 자신 | 죽이지 않는다. 죽으면 수집이 멈추고 스스로 살아날 방법이 없다 |
| PID 1 | 죽이지 않는다. launchd/init 이라 기기가 망가진다 |
| 일부만 종료 | 성공으로 보지 않고 FAILED 로 보고한다. KILLED 를 받으면 서버가 알림을 확인 처리하는데, 대상이 아직 살아 있을 수 있다 |
| 같은 명령 재수신 | 다시 실행하지 않고 저장해 둔 결과만 다시 보고한다. 그 사이 같은 이름으로 뜬 정상 프로세스를 죽이지 않기 위해서다 |
에이전트가 쓰는 상태는 KILLED / NO_MATCH / FAILED 셋뿐이다. TIMEOUT / COOLDOWN /
DISABLED 는 엔드포인트가 판단할 수 없는 값이라 서버가 붙인다.