발단: QA 에이전트의 한 줄 지적
CodeScan 스캐너 QA를 돌리던 중 QA 에이전트가 이런 메시지를 남겼다.
data_leak_scanner.py L.142 — requests.get(url, allow_redirects=True).
진입 URL은 is_internal_url()로 막혔지만, redirect 이후 경로는 검사 안 함.
예: 공개 서버가 169.254.169.254 로 Location 헤더를 반환하면 그대로 따라감.
SSRF(Server-Side Request Forgery). 오래된 취약점이지만, 스캐너를 여러 명이 나눠 개발하면서 안전 HTTP 래퍼를 쓰지 않고 requests.get()을 직접 호출한 파일이 19개나 됐다.
왜 입구 검증만으로는 부족한가
대부분의 구현은 이렇게 생겼다.
# BEFORE — 취약한 패턴
def scan_something(url):
if is_internal_url(url): # ← 입구만 막음
return {"score": 100}
resp = requests.get(url, allow_redirects=True, timeout=10)
# ...
문제는 세 가지 경로로 우회된다.
- Redirect 체인: 대상 서버가
Location: http://169.254.169.254/latest/meta-data/를 내보내면 requests는 그대로 따라간다. - Sub-URL 파생: 스캐너가 원본 URL에서 경로를 재조합할 때 내부 주소가 섞인 경우.
- 절대 경로 href: HTML 파싱 후 href=
http://internal-svc/api를 그대로 요청하는 경우.
AWS EC2에서 운영 중인 서버라면 Instance Metadata Service(IMDS)가 169.254.169.254에 항상 열려 있다. IAM 토큰이 여기서 나온다.
_safe_http: 직접 만든 SSRF 방어 래퍼
기존 is_internal_url()은 최초 URL만 검사한다. Redirect를 막으려면 응답의 Location 헤더도 검사해야 한다. 그래서 _safe_http.py를 신설했다.
# security_service/scanner/_safe_http.py (핵심 발췌)
import ipaddress, socket, re
import requests
from urllib.parse import urlparse
_BLOCKED_NETS = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"), # IMDS
ipaddress.ip_network("100.64.0.0/10"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
]
def _check_url(url):
# 내부 주소이면 ValueError 발생
parsed = urlparse(url)
host = parsed.hostname or ""
if re.search(r"(^|\.)localhost$|metadata\.google\.internal", host, re.I):
raise ValueError(f"SSRF blocked: {host}")
try:
ip = ipaddress.ip_address(socket.gethostbyname(host))
for net in _BLOCKED_NETS:
if ip in net:
raise ValueError(f"SSRF blocked (private IP): {ip}")
except socket.gaierror:
pass # DNS 실패 = 외부로 나가지 않음
def safe_get(url, **kwargs):
_check_url(url)
kwargs.setdefault("allow_redirects", False)
kwargs.setdefault("timeout", 10)
resp = requests.get(url, **kwargs)
if resp.is_redirect:
loc = resp.headers.get("Location", "")
if loc and loc.startswith("http"):
_check_url(loc) # redirect 대상도 검사
resp = requests.get(loc, allow_redirects=False, timeout=10)
return resp
def safe_post(url, **kwargs):
_check_url(url)
kwargs.setdefault("allow_redirects", False)
kwargs.setdefault("timeout", 10)
return requests.post(url, **kwargs)
def safe_head(url, **kwargs):
_check_url(url)
kwargs.setdefault("allow_redirects", False)
kwargs.setdefault("timeout", 10)
return requests.head(url, **kwargs)
19파일 일괄 치환 패턴
스캐너마다 직접 requests.get을 호출하던 부분을 safe_get으로 바꿨다.
# BEFORE
import requests
resp = requests.get(url, timeout=10)
# AFTER
from ._safe_http import safe_get
resp = safe_get(url, timeout=10)
치환 대상 21개 스캐너 중 19개 파일에 직접 호출이 있었다. ssl_scanner와 port_scanner는 TCP 소켓 직접 사용이라 HTTP 래퍼 대상 아님.
함정 1: stream=True 케이스 (data_leak_scanner)
data_leak_scanner는 응답 크기가 큰 파일(.sql, .bak)을 스트리밍으로 받는다.
# 스트림 모드 — redirect 처리 전 URL 선제 검사
_check_url(url) # ← 진입 URL 차단
resp = requests.get(url,
allow_redirects=False,
stream=True, timeout=20)
if resp.is_redirect:
loc = resp.headers.get("Location", "")
if loc:
_check_url(loc) # ← redirect URL도 차단
resp = requests.get(loc, allow_redirects=False, stream=True, timeout=20)
safe_get은 내부에서 스트림을 소비하기 때문에, 스트림이 필요한 경우에는 _check_url을 직접 호출하는 방식으로 우회했다.
함정 2: 다중 redirect 쿠키 누적
일부 스캐너는 세션 쿠키를 수동으로 관리한다. allow_redirects=False로 redirect를 끊으면 쿠키 누적이 일어나지 않아 인증이 필요한 endpoint 탐지가 실패하는 경우가 있었다.
해결책: RequestsCookieJar를 직접 부착해서 redirect 간 쿠키를 수동으로 전파했다.
from requests.cookies import RequestsCookieJar
jar = RequestsCookieJar()
resp1 = safe_get(url)
jar.update(resp1.cookies)
# 필요 시 후속 요청에 jar 전달
resp2 = safe_get(next_url, cookies=jar)
함정 3: 시크릿이 응답 본문에 — 평문 이메일 알림
secret_scanner가 API 키를 발견했을 때 evidence(원본 문자열)를 그대로 디스코드/이메일로 보내고 있었다. 받은 사람이 그 키를 그대로 사용하면 2차 유출이다.
# _safe_http.py 에 추가
import re
_SECRET_PATTERNS = [
r"(sk-[A-Za-z0-9]{32,})", # OpenAI
r"(AKIA[0-9A-Z]{16})", # AWS Access Key
r"(ghp_[A-Za-z0-9]{36})", # GitHub PAT
r"([A-Za-z0-9]{32,})", # generic long token
]
_REDACT_RE = re.compile("|".join(_SECRET_PATTERNS))
def _redact_secrets_in_text(text):
# 민감 패턴을 ***로 마스킹
def _mask(m):
val = m.group(0)
if len(val) <= 8:
return val
return val[:4] + "***" + val[-4:]
return _REDACT_RE.sub(_mask, text)
이후 evidence를 외부로 보내기 전에 _redact_secrets_in_text(evidence)를 거쳐 전송한다.
결과: 숫자로 보면
| 항목 | 수치 |
|---|---|
| 패치된 스캐너 파일 | 19개 |
| 신규 추가 함수 | 4개 (safe_get / safe_post / safe_head / _redact_secrets_in_text) |
| manage.py check 결과 | 0 issues |
| 운영 중단 시간 | 0 |
| 스캔 결과 변화 | 없음 (정상 외부 URL만 대상) |
같은 패턴을 적용해야 하는 곳
SSRF는 "외부 URL을 서버가 직접 요청하는 모든 곳"에서 발생한다. 스캐너 외에도 다음을 점검하라.
- Worker/Background Job: Celery, APScheduler 등에서 URL을 받아 HTTP 요청하는 태스크
- Webhook 수신 처리: 외부가 보낸 URL을 서버가 다시 호출하는 경우
- SSR 렌더러: Next.js, Nuxt, Django SSR에서 클라이언트 URL을 서버가 대신 fetch
- 파일 업로드 URL 처리: 이미지 URL을 받아 서버에서 다운로드
- CI/CD 파이프라인: 빌드 스크립트 내 외부 URL fetch
공통 해법은 동일하다. URL을 신뢰하지 말고, 요청 직전에 IP로 resolve해서 내부망인지 확인하라. Redirect는 최종 목적지까지 검사하라.
내 서비스도 SSRF에 노출되어 있을까?
CodeScan은 외부에서 관찰 가능한 SSRF 징후를 자동으로 탐지한다. 서버가 내부 주소로 redirect를 내보내거나, CORS 헤더가 wildcard로 열려 있거나, 인증 없는 API endpoint가 있으면 즉시 알려준다.