제공 서비스
웹 보안 점검 소스코드 분석 (SAST) CRM 보안 진단 다크웹 유출 조회
요금제 스토어 블로그 파트너 마이페이지 무료 보안 점검
보안기술 2026.04.23 · 조회 190

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

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

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

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

SSRF 스캐너 Python Django 보안패치

내 사이트도 점검해보세요

CodeScan으로 보안 취약점을 무료로 점검할 수 있습니다.

무료 스캔 시작하기 →
🛒
추천 상품
웹서비스 런칭 전 보안 세팅
런칭 전에 반드시 해야 하는 보안 설정을 원격으로 직접 해드립니다. HTTPS, 환경변수 분리, 보안 헤더…
220,000원 150,000원

🔒 바이브코딩 보안 체크리스트 받기

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