JOURNAL ENTRY Engineering

PostgreSQL 침해를 겪고 나서야 보였다 — 웹 방어와 Host Runtime Security는 다른 문제였다

DB 침해에서 OS command execution과 resource hijacking까지 이어진 실제 사건은 기존 웹 abuse detection만으로는 보이지 않는 영역을 드러냈다. 예방, 관측, 판단, 집행을 어떻게 다시 나눴는지 정리한다.

보안 시스템을 만들고 있으면 자신도 모르게 "이미 보고 있는 것"을 보안의 전체 범위처럼 생각하기 쉽다.

내 경우 Security Guard는 처음부터 아무것도 없는 상태는 아니었다. HTTP 요청량, 인증 실패, 경로 스캔 같은 application abuse를 관찰하고, source identity를 정규화하고, detection 결과를 ban이나 reputation state로 연결하는 구조가 있었다.

그래서 겉으로 보면 꽤 그럴듯했다.

HTTP / application observation
→ detection
→ decision
→ ban / reputation
→ edge enforcement

그런데 실제 서버에서 PostgreSQL 침해를 하나 겪고 나니 이 구조가 무엇을 잘 보고 있었고, 무엇을 전혀 보고 있지 않았는지가 아주 선명해졌다.

사건은 HTTP가 아니라 DB에서 시작됐다

방치되어 있던 서비스의 PostgreSQL이 침해된 것을 확인했다.

보존된 evidence에서 확인된 핵심 흐름은 다음과 같았다.

PostgreSQL compromise
→ COPY ... FROM PROGRAM
→ OS command execution
→ temporary payload deployment
→ resource hijacking
→ proxy-jacking related artifact

최초 진입 경로까지 확정된 것은 아니었다. 당시 외부 노출 상태와 credential 조건이 후보로 남았지만, 확인되지 않은 것을 확정 사실처럼 쓰고 싶지는 않았다.

반면 한 가지는 분명했다.

공격자가 DB 안에서 끝난 것이 아니라 DB process의 권한을 발판으로 OS command execution까지 넘어갔다.

그 순간 기존 Security Guard가 다루던 세계와 실제 사고가 발생한 세계가 갈라졌다.

기존 Guard가 잘 보던 것
= HTTP / auth / scan / rate abuse

이번 사고에서 필요했던 것
= DB / process / filesystem / resource / network runtime observation

웹 요청을 잘 보는 것과 host runtime을 잘 보는 것은 같은 문제가 아니었다.

"Security Guard가 왜 못 막았나?"라는 질문을 다시 정의했다

처음에는 자연스럽게 이런 질문이 생긴다.

보안 시스템이 있었는데 왜 막지 못했나?

하지만 source와 runtime을 다시 확인하면서 결론은 조금 달랐다.

현재 Guard는 주로 application/edge abuse lane을 가지고 있었고 production runtime도 detect-only 성격이 강했다. PostgreSQL authentication log, DB-originated process execution, temporary executable, sustained CPU consumption 같은 사실을 Guard에 공급하는 producer가 없었다.

즉 문제를 단순히:

Guard detection rule이 부족했다

라고 보면 틀린다.

더 정확한 표현은:

OBSERVATION SURFACE가 연결되지 않았다

였다.

Guard가 판단하려면 먼저 사실이 들어와야 한다.

postgres process가 shell을 만들었다
temporary path의 executable이 실행됐다
특정 process가 장시간 CPU를 점유했다
예상하지 않은 outbound connection이 생겼다

이런 사실 자체가 없는데 detector rule만 늘려서는 아무것도 바뀌지 않는다.

예방책과 detection을 섞지 않기로 했다

이번 사건에서 가장 중요한 교훈 중 하나는 Security Guard를 만능 방패로 생각하면 안 된다는 것이었다.

DB가 외부에 불필요하게 노출되어 있다면 첫 번째 해결책은 detection engine이 아니다.

불필요한 public exposure 제거
strong credential
network / access policy
service boundary 정리

가 먼저다.

Security Guard가 해야 하는 일은 그 다음이다.

PREVENT UNNECESSARY EXPOSURE
        +
OBSERVE ATTACK SIGNALS
        +
DECIDE DETERMINISTICALLY
        +
ENFORCE TEMPORARILY AND RECOVERABLY

예방과 탐지를 섞으면 이상한 기대가 생긴다.

잘못 열린 DB를 "AI가 알아서 악성 공격만 골라 막아주겠지"라고 생각하기 시작하면 architecture가 잘못된 exposure를 정당화하는 방향으로 변한다.

Defense in depth는 첫 번째 방어선을 없애는 기술이 아니다.

그래서 Server Ops와 Security Guard의 책임을 다시 나눴다

침해 사고 이후 가장 먼저 한 일은 Security Guard가 직접 모든 시스템 API를 읽게 만드는 것이 아니었다.

예를 들어 Guard가 직접 /proc, Docker API, journald, PostgreSQL log, network socket, filesystem watcher를 각각 수집하기 시작하면 security decision engine이 host agent까지 삼키게 된다.

대신 기존 제품 구조를 유지했다.

Server Ops
= machine / process / container / network의 factual observation
+ bounded local execution

Security Guard
= 그 사실의 security interpretation
+ detection / correlation / incident
+ containment intent

PaaS
= workload lifecycle / placement
+ runtime/security state projection

이 구분에서 중요한 문장은 하나다.

OPS OWNS FACTS
GUARD OWNS SECURITY MEANING

예를 들어 parent executable, child executable, uid, temporary path execution, resource window, outbound connection은 사실이다.

반면 MALWARE, MINER, COMPROMISED, CONTAIN은 해석과 결정이다.

관측기가 악성 여부까지 판단하기 시작하면 다른 정책이 필요한 순간 사실과 정책을 분리하기 어려워진다.

CPU를 많이 쓴다고 악성은 아니다

실제 공격에서 resource hijacking이 확인됐기 때문에 CPU anomaly를 바로 탐지 규칙으로 넣고 싶어질 수 있다.

하지만 CPU를 많이 쓰는 정상 workload도 많다.

게임 서버, 빌드, 이미지 처리, 모델 추론 같은 작업은 순간적으로든 지속적으로든 CPU를 크게 사용할 수 있다.

HIGH CPU
!= MALICIOUS

Guard가 필요한 것은 단일 threshold보다 correlation이다.

DB process
→ unexpected shell lineage
→ temporary executable
→ unusual outbound connection
→ sustained resource use

같은 여러 fact가 같은 runtime identity를 중심으로 모일 때 의미가 생긴다.

그래서 resource threshold 자체도 Guard가 소유하도록 했다. Ops는 일정 시간 구간의 factual ResourceWindow를 만들 수 있지만 "이 정도면 공격"이라는 의미까지 붙이지 않는다.

차단도 arbitrary shell이 아니라 typed intent로 제한했다

보안 시스템을 강화한다고 Guard에 root shell을 주는 것도 피하고 싶었다.

가장 위험한 형태는 이런 것이다.

Guard
→ arbitrary kill
→ arbitrary container stop
→ arbitrary shell

빠르게 만들 수는 있지만 안전한 contract가 아니다.

대신 Guard는 canonical containment intent만 만든다.

TERMINATE_PROCESS
STOP_WORKLOAD
QUARANTINE_WORKLOAD

그리고 Server Ops가 자기 노드에서 target identity를 다시 확인한 뒤 bounded actuator로 실행한다.

이 구조를 택한 이유는 PID 하나만 보고 process를 죽이면 PID reuse 같은 race에서 전혀 다른 process를 건드릴 수 있기 때문이다.

그래서 destructive action의 target은 단순 PID가 아니라 다음처럼 안정된 identity를 가져야 한다.

node_id
+ boot_id
+ pid
+ process start identity

요청 시점과 실행 시점 사이에 concrete identity가 바뀌었다면 action을 거부한다.

보안 자동화는 "빨리 죽이는 것"보다 정확히 같은 대상을 죽이는 것이 먼저다.

격리는 곧 자동 재시작 금지라는 뜻이기도 했다

보안 containment와 PaaS lifecycle도 연결된다.

예를 들어 Guard가 workload를 quarantine했고 Ops가 실제 runtime을 정지시켰다고 하자.

PaaS가 이를 일반 crash로만 보면 health reconciliation이 곧바로 새 runtime을 띄울 수 있다.

Guard: 위험해서 정지
PaaS: 죽었네? 다시 시작

가 되어버린다.

그래서 PaaS에는 보안 의미를 재해석시키지 않되, 최소한 authoritative security hold를 lifecycle에서 구분하도록 했다.

runtime = stopped
reason = security enforcement
security hold = active

이 상태에서는 일반 crash recovery처럼 무조건 재시작하지 않는다.

Guard는 보안 의미를 소유하고, Ops는 local action을 소유하고, PaaS는 lifecycle consequence를 소유한다.

실제 악성코드를 다시 실행할 필요는 없다

사고를 재현해 acceptance test를 만들 때도 경계를 잡았다.

실제 miner나 공격 payload를 다시 서버에서 실행하는 것은 필요하지 않다.

필요한 것은 공격 chain의 관찰 가능한 의미를 안전하게 재현하는 것이다.

DB-originated benign process
→ shell
→ harmless test file
→ permission change
→ bounded CPU workload
→ controlled outbound test connection

같은 synthetic replay로 충분하다.

목표는 payload 자체가 아니라 다음 파이프라인을 검증하는 것이다.

Ops factual observation
→ Guard detection / correlation
→ one incident
→ containment intent
→ Ops bounded action
→ factual result

그리고 별도로 정상적인 고부하 workload를 돌려 high resource use alone != automatic containment도 확인해야 한다.

실제 사고는 기능 목록보다 boundary를 더 잘 보여준다

이번 사건에서 얻은 가장 큰 결과는 탐지 규칙 몇 개가 아니었다.

처음에는 Security Guard를 확장해야 한다는 생각이 먼저 들었지만, 실제로는 세 시스템의 경계를 더 정확히 잡게 됐다.

PaaS = lifecycle / placement
Server Ops = factual observation + bounded actuation
Security Guard = security interpretation + containment intent

이 구조가 맞으면 새로운 attack signal이 생겨도 역할 자체를 다시 섞을 필요가 없다.

Ops가 새로운 fact를 관찰할 수 있게 하고, Guard가 그 fact를 새로운 rule이나 correlation에 사용하면 된다.

보안 시스템은 "무엇이 악성인가"만 잘 판단한다고 완성되지 않는다.

무엇을 실제로 볼 수 있는가, 누가 그 사실을 소유하는가, 누가 의미를 붙이는가, 누가 실제 mutation을 실행하는가, 실패하거나 불확실할 때 무엇을 하지 않는가까지 함께 정해져야 한다.

실제 침해 사고는 불쾌한 방식으로 그 경계를 보여줬지만, 적어도 이제 다음 개발이 어디를 향해야 하는지는 훨씬 명확해졌다.