본문으로 건너뛰기

66. SSRF 완벽 가이드: 서버를 역이용하는 공격 기법

SSRF(Server-Side Request Forgery)의 핵심은 URL이 아니라 요청을 대신 보내는 주체의 위치와 권한이다. 브라우저에서 보이지 않는 내부망이 서버에게는 가까울 수 있고, 그 서버의 네트워크 자격이 곧 공격 표면이 된다. 이 문서의 실습은 승인된 랩, CTF, 또는 본인이 소유한 시스템에서만 수행한다. 운영 환경에서는 실제 비밀값 대신 더미 토큰과 전용 콜백 도메인을 사용한다.

먼저 확인할 것: 어떤 요청이 가능한가

URL을 받는 기능이 모두 같은 SSRF는 아니다. HTTP GET만 가능한지, 리다이렉트를 따라가는지, DNS를 언제 해석하는지, 응답 본문을 사용자에게 돌려주는지, 프록시가 file:·gopher: 같은 스킴을 허용하는지를 먼저 기록한다. 이 차이가 영향도를 결정한다.

취약한 예시는 다음처럼 사용자 입력을 곧바로 HTTP 클라이언트에 넘긴다.

from flask import Flask, request
import requests

app = Flask(__name__)

@app.get("/fetch")
def fetch():
url = request.args["url"]
response = requests.get(url, timeout=5, allow_redirects=False)
return response.text, response.status_code

실습에서는 example.test 같은 허가된 호스트와 로컬 Docker 네트워크를 사용한다. 첫 단계는 공개 콜백 서비스로 DNS/HTTP 요청이 발생하는지 확인하는 것이며, 내부 주소를 무작정 스캔하는 것이 아니다. 응답이 보이지 않아도 콜백 로그, DNS 조회, 상태 코드, 시간 초과를 함께 기록하면 blind SSRF를 구분할 수 있다.

영향도를 판단하는 기준

SSRF의 심각도는 “내부 IP에 접속했다”는 문장보다 재현 가능한 경로로 평가한다.

  • 네트워크 도달성: loopback, 사설 주소, 링크 로컬 주소, 내부 DNS 중 어디까지 도달하는가.
  • 프로토콜과 메서드: HTTP(S)만 가능한가, 리다이렉트와 비표준 스킴을 처리하는가.
  • 인증 재사용: 서버의 쿠키, 프록시 자격, 클라우드 메타데이터 자격이 자동으로 붙는가.
  • 응답의 관찰 가능성: 본문·헤더·상태 코드·시간만 보이는지, 쓰기 동작까지 가능한지.
  • 경계의 의미: 단순 배너 노출인지, 실제 민감 정보 접근인지. 테스트에서는 비밀값을 수집하지 말고 존재 여부만 증명한다.

클라우드에서는 IMDS를 특별히 다룬다. AWS EC2는 IMDSv2에서 세션 토큰을 요구하도록 설정할 수 있다. 방어 검증은 샌드박스 인스턴스의 더미 역할로 하고, 자격 증명을 출력하거나 사용하지 않는다.

aws ec2 modify-instance-metadata-options \
--instance-id i-EXAMPLE \
--http-tokens required \
--http-put-response-hop-limit 1

흔한 실패와 우회가 알려 주는 것

문자열 블랙리스트는 URL 파서, DNS, 연결 시점의 해석이 서로 다르면 무너진다. 대소문자나 IP 표기 변형을 모아두는 것이 목적이 아니라, 검증한 주소와 실제 연결한 주소가 같은지 확인하는 것이 목적이다. 리다이렉트 이후를 재검증하지 않거나, DNS를 검증 시점과 연결 시점에 다르게 조회하면 우회가 생긴다.

방어 테스트에서는 내부 주소 대신 랩의 예약된 테스트 호스트를 사용해 다음을 확인한다.

  1. 허용 호스트가 아닌 URL이 거부되는가.
  2. DNS가 사설·loopback·link-local 주소를 반환하면 거부되는가.
  3. 리다이렉트 대상도 같은 정책으로 검증되는가.
  4. IPv4와 IPv6, 포트, 사용자 정보, 비표준 스킴을 정규 파서로 처리하는가.
  5. 연결 풀이나 프록시가 별도의 해석 규칙으로 정책을 우회하지 않는가.

방어 설계

가장 강한 방어는 URL을 자유롭게 받지 않는 것이다. 외부 파트너 목록의 식별자를 받고 서버가 미리 등록된 URL을 선택하게 하면 검증 범위가 작아진다. 꼭 URL을 받아야 한다면 스킴과 호스트를 허용 목록으로 제한하고, DNS 결과의 모든 주소를 연결 직전에 검사하며, 리다이렉트는 끄거나 각 홉을 다시 검증한다.

import ipaddress
import socket
from urllib.parse import urlparse

ALLOWED_HOSTS = {"api.example.test", "images.example.test"}


def public_addresses(hostname):
infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
return {ipaddress.ip_address(info[4][0]) for info in infos}


def checked_url(raw):
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"} or parsed.username or parsed.password:
raise ValueError("unsupported URL")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("host is not allowed")
addresses = public_addresses(parsed.hostname)
if not addresses or any(not addr.is_global for addr in addresses):
raise ValueError("destination is not public")
return parsed.geturl()

이 검사는 HTTP 클라이언트의 프록시 설정과 완전히 같은 경로를 보장하지 않을 수 있다. 따라서 egress 방화벽으로 애플리케이션의 허용 목적지만 열고, 메타데이터 주소와 관리망을 네트워크 레벨에서 차단하며, 응답 크기·시간·헤더를 제한한다. 응답 본문에서 SecretAccessKey를 지우는 후처리는 유출을 늦출 뿐 SSRF 자체의 방어가 아니다.

보고서에 남길 증거

요청 시각, 입력 URL, 최종 목적지, 리다이렉트 수, 응답 상태와 크기, 서버 로그의 상관 ID를 남긴다. 내부 서비스의 화면이나 자격 증명을 복사하지 말고, “테스트 전용 엔드포인트에 도달했고 더미 응답을 받았다”처럼 최소 증거로 영향도를 설명한다. 좋은 SSRF 보고서는 우회 문자열 목록보다 시스템 경계가 왜 무너졌는지와 재현 가능한 완화책을 제시한다.