보안 가이드 2026.04.23 · 조회 3114

스캐너 19개 파일에서 SSRF를 잡아낸 방법: _safe_http 패치 사례 연구

QA 에이전트가 "redirect 이후 경로는 검사 안 함"을 지적했다. 스캐너 19개 파일, 4개 함수, 운영 중단 0 — SSRF 패치 전 과정.

S
보안 전문 기업 SENTRIX 레드팀
해킹대회 수상 · 보안 실무 10년 · 실전 진단 경험을 바탕으로 작성합니다.
웹사이트를 운영하고 있다면
보안 헤더·인증서·공개 노출부터 무료로 점검하세요. 결과 링크는 이메일로 받습니다. 로그인 이후 권한·업무 로직은 별도 검증이 필요합니다.

다음 화면에서 이메일과 점검 권한을 확인합니다. 회원가입·카드 없이 무료 요약을 받고, 상세 리포트는 선택 구매합니다.

발단: 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가 있으면 즉시 알려준다.

→ 지금 무료로 내 사이트 보안 상태 확인하기

내 사이트도 점검해보세요

URL 입력, 결과 링크는 메일로 받는 무료 점검. 회원가입·카드 불필요.

AI 코딩 보안 체크리스트 받기

AI 코딩 보안 체크리스트(PDF)를 무료로 받아보세요.

배포·설정을 바꾼 뒤에는 같은 주소를 다시 점검하세요. 수정 후 재점검 순서와 일정 저장 · 자동 점검이 필요하면 Continuous의 범위와 체험 조건을 확인하세요.
이런 글을 계속 받아보려면 뉴스레터 구독 · RSS 피드