[CTF] Dreamhack 문제 풀이: 거북이

거북이

문제: 드림핵 워게임 LEVEL 2 거북이

목차

  1. Vuln
  2. Code
  3. Payload
  4. ZIP 파일 구조
  5. 참고

1. Vuln

Path/Directory Traversal: 공격자는 상대경로 혹은 절대경로를 사용하여 원래는 접근할 수 없는 파일 또는 의도하지 않은 경로의 파일을 읽고 수정할 수 있으며, 심지어 새로운 파일을 생성할 수도 있다.

Zip Slip: 압축 파일 내에 포함된 파일 경로를 악의적으로 조작하여, 압축 해제 시 대상 디렉토리 외부에 파일을 생성하거나 기존 파일을 덮어쓰는 취약점이다.

2. Code

from flask import Flask, request, render_template, jsonify, send_from_directory
from pathlib import Path
import io, shutil, zipfile
from werkzeug.exceptions import HTTPException

app = Flask(__name__)

# 전역 초기화
UPLOAD_DIR = Path("./uploads").resolve() # ./uploads를 절대경로화 -> /app/uploads
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
count = 1 # 파일명 카운터

# GET / : 업로드 페이지
@app.route("/", methods=["GET"])
def main():   
    return render_template('upload.html')

# POST /upload : 업로드 처리
@app.route("/upload", methods=["POST"])
def upload():
    global count
    # multipart 폼 데이터에서 file 필드가 없으면 400 JSON 반환.
    if "file" not in request.files:
        return jsonify(ok=False, error="no file"), 400

    # multipart 폼 데이터에서 파일명이 비어있으면 400 JSON 반환.
    f = request.files["file"]
    if f.filename == "":
        return jsonify(ok=False, error="empty filename"), 400

    # ZIP으로 판단되면 “압축 해제 저장”
    # 확장자가 .zip이거나 Content-Type 문자열에 zip이 포함되면 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)) # zipfile.ZipFile로 파싱
        names = []
        # infolist()로 ZIP 내부 엔트리를 순회
        for info in zf.infolist():
            target = UPLOAD_DIR / info.filename
            # 디렉터리면 mkdir
            if info.is_dir():
                target.mkdir(parents=True, exist_ok=True)
            # 파일이면 zf.open(info)로 꺼내서 target에 복사 저장
            else:
                target.parent.mkdir(parents=True, exist_ok=True)
                with zf.open(info) as src, open(target, "wb") as dst:
                    shutil.copyfileobj(src, dst)
            # 저장한 엔트리 이름 목록을 saved로 JSON 응답
            names.append(info.filename)
        return jsonify(ok=True, saved=names)
    
    # ZIP이 아니면 “단일 파일 저장”
    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))    

# GET /test.html : 템플릿 렌더링
@app.route("/test.html", methods=["GET"])
def test_page():
    return render_template("test.html")

# GET /uploads/<path:filename> : 업로드 파일 서빙
"""
1. 
URL: /uploads/<path:filename>
<path:filename> -> filename에 슬래시 포함 허용, 폴더까지 포함해서 파일 요청 가능

2.
send_from_directory(directory, path ...)
directory와 path를 결합해서 실제 파일 경로 만들기
그 경로의 파일을 찾아서 존재하면 파일 내용과 적절한 헤더를 붙여 응답
... 같은 경로로 directory 밖으로 빠지는 시도는 일반적으로 차단

3.
as_attachment=False 브라우저가 파일을 인라인으로 렌더링
"""
@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)

# 모든 에러 400 처리
@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

# flask 실행
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)

코드에서 문제가 되는 지점은 upload() 함수의 zip 파일 처리하는 과정에서

target = UPLOAD_DIR / info.filename

부분이다.

아무런 검증 없이 filename을 가져오기 때문에 ../를 파일명에 붙여서 업로드 폴더를 탈출해 다른 곳에 쓰기가 가능해졌다. 이를 이용해 app.py를 덮어씌워서 flag.txt에 접근할 수 있도록 변경할 수 있다.

3. Payload

  1. serve_uploads 함수 수정

     @app.route("/<path:filename>")
     def serve_uploads(filename):
         return send_from_directory("./", filename, as_attachment=False)
    
  2. 압축 후 ../app.py로 파일명 수정

    압축 전에 파일명 수정을 시도했을 때에는 윈도우에서 변경을 못하게 막혀있었다. 압축 후에 HxD라는 hex editor를 이용해 .zip 내부 파일명을 변경했다.

    404x430

    위에 하나

    404x431

    아래에 하나 있다.

    • 파일명이 두 번 보이는 것은 ZIP 파일 구조 특성 때문이다. Local File Header와 Central Directory에 파일명이 저장이 되어 있다.
  3. 업로드

    404x271

    ../app.py로 업로드 된 모습

  4. flag.txt 불러오기

    404x105

    flag

문제를 풀고 다른 분들의 풀이를 보니 SSTI 취약점도 있었다고 한다. app.py를 수정하는 것은 실제 운영중인 웹사이트일 경우 치명적인 피해를 입힐 것 같다.

4. ZIP 파일 구조

500x295

출처: Tistory: ZIP File Structure Analysis

ZIP 파일 구조

  • Local File Header: 압축 파일에 대한 기본 정보들이 포함된다.
  • File Name: 압축된 파일 이름 형식에 대한 임의의 길이와 바이트 순서를 나타낸다.
  • File Data: 임의의 길이로 구성된 바이트 배열 형태로 압축된 파일 컨텐츠이다.
  • Central Directory: Local File Header의 확장된 데이터 뷰를 제공한다.
  • End of central directory record: 모든 아카이브의 싱글 템플릿으로 제공하며 아카이브의 종료를 작성한다.

참고

파일 업로드/다운로드 취약점

zipfile 모듈

flask 파일 업로드

zip slip

zip 파일 구조

Comments

Newest Posts