Copy & Run

Flask 파일 업로드 검증과 경로 조작 방어하기

원본 파일명을 그대로 저장하는 허술한 업로드가 폴더 밖에 파일을 남기는 것을 연습용 앱에서 확인합니다. 확장자 허용 목록, 크기 제한, 저장 이름 재생성, 경로 확인을 넣은 수정본에서 막히는 것까지 직접 확인합니다.

실습 노트 · 2026-10-16 발행 · 13분 · 2026-10 확인

업로드 기능에는 점검할 곳이 많지만, 이 글은 파일 이름과 크기 문제에 집중합니다. 작은 Flask 앱 두 개를 만들어, 원본 파일명을 그대로 저장하는 앱에서는 업로드 폴더 밖에 파일이 생기는 것을 확인하고, 수정한 앱에서는 같은 요청이 어떻게 처리되는지 확인합니다.

이 글의 취약한 앱은 직접 만든 연습용이며 실제 서비스에 쓰면 안 됩니다. 요청은 자신이 띄운 127.0.0.1 서비스에만 보냅니다. SQL 로그인 우회, 경로를 이용한 파일 읽기, 저장형 XSS는 작은 Flask 서비스를 직접 공격하고 한 줄씩 고치기에서 다루었으므로, 이 글은 업로드에만 집중합니다.

준비물

도구 확인한 버전
Python 3.12.10
Flask 3.1.3
Werkzeug 3.1.9 (Flask와 함께 설치됨)
curl 8.19.0

명령은 Git Bash 기준입니다. PowerShell에서는 curl 대신 curl.exe를 쓰고, ls나 head, grep 같은 명령은 Git Bash에서 실행하는 편이 편합니다. 소요 시간은 20분 안팎입니다.

1. 작업 폴더와 시험용 파일 만들기

mkdir upload-demo && cd upload-demo
python -m venv .venv
source .venv/Scripts/activate
pip install flask
echo hello > a.txt

Linux나 macOS에서는 명령이 조금 다릅니다. 이 글의 python은 모두 python3으로 바꿔 쓰고, 가상환경은 source .venv/bin/activate로 켭니다. 이 두 줄만 다르고 나머지 명령은 같습니다. a.txt는 업로드해 볼 시험 파일입니다.

python3 -m venv .venv
source .venv/bin/activate

2. 허술한 업로드 앱 만들기

vuln_app.py를 만듭니다. 사용자가 보낸 파일 이름 f.filename을 검사 없이 업로드 폴더 경로에 붙여 저장합니다. 확장자와 크기도 제한하지 않습니다.

import os
from flask import Flask, request

app = Flask(__name__)
BASE = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.join(BASE, "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)


@app.route("/upload", methods=["POST"])
def upload():
    f = request.files.get("file")
    if f is None or f.filename == "":
        return "파일이 없습니다.\n", 400
    path = os.path.join(UPLOAD_DIR, f.filename)
    f.save(path)
    return "저장: %s\n" % os.path.relpath(path, BASE)


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=19201)

이 앱은 os.path.join(UPLOAD_DIR, "../escaped.txt")가 uploads/../escaped.txt, 즉 업로드 폴더의 한 단계 위를 가리킨다는 점을 막지 않습니다. 파일 이름은 브라우저 화면에서 고르는 값이 아니라 요청에 담긴 문자열이라서, 요청을 직접 만들면 원하는 값을 넣을 수 있습니다.

3. 상위 경로 문자가 든 업로드 확인하기

앱을 띄웁니다.

python vuln_app.py

새 터미널을 열어(가상환경 활성화는 다시 합니다) 같은 폴더에서 파일 이름을 ../escaped.txt로 지정해 올립니다.

curl -s -F "file=@a.txt;filename=../escaped.txt" http://127.0.0.1:19201/upload
ls
ls uploads
# 기대 출력
저장: escaped.txt
a.txt
escaped.txt
uploads
vuln_app.py

ls uploads는 아무것도 출력하지 않습니다. 포트 19201은 허술한 앱, 19202는 뒤에서 만들 수정한 앱에 붙인 번호이며 다른 값이어도 됩니다. 아래 화면에는 4단계에서 만들 app.py가 이미 목록에 있습니다.

저장 위치가 uploads 안이 아니라 한 단계 위의 escaped.txt로 나오고 uploads 폴더는 비어 있는 모습
저장 위치가 uploads 안이 아니라 한 단계 위의 escaped.txt로 나오고 uploads 폴더는 비어 있는 모습

파일이 업로드 폴더 밖에 만들어졌습니다. 이름을 더 깊게 지정하면 앱이 쓰기 권한을 가진 다른 위치의 파일도 덮어쓸 수 있습니다. 확인이 끝났으면 Ctrl+C로 앱을 끄고 escaped.txt를 지웁니다.

rm escaped.txt

두 앱은 같은 uploads/ 폴더를 씁니다. 허술한 앱은 이 폴더 안에 아무것도 만들지 않았지만, 다른 파일을 올려 보았다면 5단계의 ls uploads 결과에 섞이므로 rm -f uploads/*로 비우고 넘어갑니다.

4. 수정한 앱 만들기

app.py를 만듭니다. 다음 여섯 가지가 들어 있습니다.

  • 크기 제한: MAX_CONTENT_LENGTH로 요청 크기를 1MB로 제한하고, 넘으면 413 응답을 돌려줍니다.
  • 확장자 허용 목록: txt, pdf, png, jpg, jpeg만 받습니다.
  • 저장 이름 재생성: 디스크에는 uuid로 만든 이름만 쓰고, 사용자가 보낸 이름은 files.db에만 보관합니다.
  • secure_filename: 보관하는 원본 이름에서 경로 문자를 제거합니다.
  • 저장 경로 확인: 최종 경로가 업로드 폴더 안인지 realpath로 다시 확인합니다.
  • 내려받기 방식: 파일을 화면에 직접 열지 않고 attachment로 내려주며 X-Content-Type-Options: nosniff를 붙입니다. 업로드 폴더를 웹 서버가 직접 공개하거나 그 안의 파일을 실행하지 않습니다.
import os
import sqlite3
import uuid

from flask import Flask, request, send_from_directory
from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.utils import secure_filename

BASE = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.join(BASE, "uploads")
DB = os.path.join(BASE, "files.db")
ALLOWED = {"txt", "pdf", "png", "jpg", "jpeg"}

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1 * 1000 * 1000  # 1MB
os.makedirs(UPLOAD_DIR, exist_ok=True)


def db():
    conn = sqlite3.connect(DB)
    conn.execute("CREATE TABLE IF NOT EXISTS files "
                 "(id TEXT PRIMARY KEY, original TEXT, stored TEXT)")
    return conn


def extension(name):
    if "." not in name:
        return ""
    return name.rsplit(".", 1)[1].lower()


@app.errorhandler(RequestEntityTooLarge)
def too_large(e):
    return "파일이 너무 큽니다. 1MB 이하만 올릴 수 있습니다.\n", 413


@app.route("/upload", methods=["POST"])
def upload():
    f = request.files.get("file")
    if f is None or f.filename == "":
        return "파일이 없습니다.\n", 400

    original = secure_filename(f.filename) or "unnamed"
    ext = extension(f.filename)
    if ext not in ALLOWED:
        return "허용되지 않는 확장자입니다.\n", 400

    file_id = uuid.uuid4().hex
    stored = file_id + "." + ext
    path = os.path.realpath(os.path.join(UPLOAD_DIR, stored))
    if not path.startswith(os.path.realpath(UPLOAD_DIR) + os.sep):
        return "허용되지 않는 경로입니다.\n", 400
    f.save(path)

    conn = db()
    conn.execute("INSERT INTO files VALUES (?, ?, ?)", (file_id, original, stored))
    conn.commit()
    conn.close()
    return "저장: id=%s 원본=%s\n" % (file_id, original), 201


@app.route("/files/<file_id>")
def download(file_id):
    conn = db()
    row = conn.execute("SELECT original, stored FROM files WHERE id = ?",
                       (file_id,)).fetchone()
    conn.close()
    if row is None:
        return "파일이 없습니다.\n", 404
    resp = send_from_directory(UPLOAD_DIR, row[1], as_attachment=True,
                               download_name=row[0])
    resp.headers["X-Content-Type-Options"] = "nosniff"
    return resp


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=19202)

확장자 허용 목록에는 Flask 공식 문서의 예시에 있는 gif를 넣지 않았습니다. 이 글의 시험에 필요하지 않아 목록을 짧게 둔 것이며, 필요하면 ALLOWED에 추가하면 됩니다. 저장 이름의 확장자는 허용 목록을 통과한 값만 쓰므로 사용자가 정한 문자열이 디스크 경로에 그대로 들어가지 않습니다. 경로 확인은 이 구조에서는 항상 통과하는 안전장치이지만, 나중에 저장 이름 규칙을 바꾸다 실수해도 폴더 밖 쓰기를 한 번 더 막아 줍니다. Flask 공식 문서도 secure_filename으로 이름을 정리하고 MAX_CONTENT_LENGTH로 크기를 제한하라고 안내합니다. 이 글은 거기에 저장 이름 재생성을 더했습니다.

5. 같은 요청이 어떻게 처리되는지 확인하기

수정한 앱을 띄웁니다.

python app.py

새 터미널에서 3단계와 같은 요청을 보냅니다.

curl -s -F "file=@a.txt;filename=../escaped.txt" http://127.0.0.1:19202/upload
ls
ls uploads
# 기대 출력
저장: id=7a9c38a44db24805bf306b2e60e105a9 원본=escaped.txt
a.txt
app.py
files.db
uploads
vuln_app.py
7a9c38a44db24805bf306b2e60e105a9.txt

id 값은 실행할 때마다 달라집니다.

업로드 폴더 밖에는 escaped.txt가 생기지 않고 uploads 안에 uuid 이름의 파일만 생긴 모습
업로드 폴더 밖에는 escaped.txt가 생기지 않고 uploads 안에 uuid 이름의 파일만 생긴 모습

../가 들어 있던 이름은 secure_filename이 escaped.txt로 정리해 files.db에만 보관했고, 디스크에는 uuid 이름의 파일 하나만 생겼습니다. 사용자가 정한 이름이 저장 위치에 영향을 주지 않으므로, 이 구조에서는 파일 이름으로 저장 경로를 바꿀 수 없습니다.

6. 확장자와 크기 제한 확인하기

허용 목록에 없는 확장자와 1MB를 넘는 파일을 올립니다. 큰 파일은 임시로 2MB 파일을 만들어 씁니다. -H "Expect:"는 curl이 큰 파일 앞에 보내는 Expect: 100-continue 헤더를 빼서 최종 응답 코드만 보려는 옵션입니다.

curl -s -w "%{http_code}\n" -F "file=@a.txt;filename=run.py" http://127.0.0.1:19202/upload
head -c 2000000 /dev/zero > big.bin
curl -s -w "%{http_code}\n" -H "Expect:" -F "file=@big.bin;filename=big.txt" http://127.0.0.1:19202/upload
rm big.bin
# 기대 출력
허용되지 않는 확장자입니다.
400
파일이 너무 큽니다. 1MB 이하만 올릴 수 있습니다.
413

run.py 업로드는 400, 2MB 파일은 413으로 거절되는 모습
run.py 업로드는 400, 2MB 파일은 413으로 거절되는 모습

내용이 txt인 파일도 run.py라는 이름이면 거절됩니다. 확장자 확인은 파일 이름만 보는 검사라서, 허용한 확장자로 이름만 바꿔 올린 파일의 내용까지 보증하지는 않습니다. 특히 .png 이름을 단 HTML 파일이 nosniff와 attachment 없이 제공되면, 브라우저가 내용을 보고 HTML로 해석해 안에 든 스크립트가 실행될 수 있습니다. 그래서 4단계의 마지막 항목처럼 올라온 파일을 앱이 실행하거나 화면에 그대로 열어 보여 주지 않는 구성이 함께 필요합니다. 8단계에서 이 두 헤더를 확인합니다.

7. 한글 파일명에서 생기는 일 확인하기

secure_filename은 ASCII 문자, 숫자, ., _, -만 남깁니다. 한글 이름을 올리면 어떻게 되는지 봅니다.

curl -s -F "file=@a.txt;filename=보고서.txt" http://127.0.0.1:19202/upload
python -c "import sqlite3; print(sqlite3.connect('files.db').execute('select original, stored from files').fetchall())"
# 기대 출력
저장: id=33e8411b94154811b583aa956ab6af7e 원본=txt
[('escaped.txt', '7a9c38a4....txt'), ('txt', '33e8411b....txt')]

두 번째 줄은 id 중간을 줄여 적었고, 실제로는 전체 id가 나옵니다. 아래 화면은 같은 요청을 두 번 보낸 결과라 txt 행이 둘입니다.

한글 파일명이 secure_filename을 거치며 원본 이름이 txt만 남은 것이 보이는 모습
한글 파일명이 secure_filename을 거치며 원본 이름이 txt만 남은 것이 보이는 모습

보고서.txt에서 한글이 모두 빠지고 앞의 점까지 정리되어 txt만 남았습니다. 저장 이름은 uuid라서 업로드는 정상이지만, 목록에 보여 줄 원본 이름이 사라집니다. secure_filename 공식 문서도 결과가 빈 문자열이 될 수 있다고 안내합니다. 한글 이름을 보여 주어야 한다면 원본을 별도 열에 그대로 보관하되 화면에는 이스케이프해서 출력하고, 디스크 경로에는 쓰지 않는 방식을 검토합니다. 이 글의 앱은 원본이 비면 unnamed로 두는 처리까지만 합니다.

8. 내려받기 응답 확인하기

저장된 파일은 /files/<id>로만 내려받습니다. 5단계에서 받은 id를 ID= 뒤에 붙여 넣고 응답 헤더를 봅니다. id는 실행마다 다르므로 자신의 값을 씁니다. 뒤의 두 명령은 경로 문자가 든 요청과 없는 id가 404가 되는지 보는 명령입니다. curl은 주소의 ../를 요청 전에 먼저 정리해 버리므로, --path-as-is를 붙이고 /를 %2f로 적어 ..%2fapp.py라는 id가 그대로 전달되게 합니다.

ID=5단계에서_받은_id; curl -s -i http://127.0.0.1:19202/files/$ID | grep -iE "^(HTTP|Content-Disposition|X-Content-Type-Options)"
curl -s --path-as-is -i "http://127.0.0.1:19202/files/..%2fapp.py" | head -1
curl -s -i http://127.0.0.1:19202/files/nope | head -1
# 기대 출력
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=escaped.txt
X-Content-Type-Options: nosniff
HTTP/1.1 404 NOT FOUND
HTTP/1.1 404 NOT FOUND

저장된 id로는 attachment 헤더와 함께 200이 나오고 경로 문자가 든 id와 없는 id는 404가 나오는 모습
저장된 id로는 attachment 헤더와 함께 200이 나오고 경로 문자가 든 id와 없는 id는 404가 나오는 모습

attachment는 브라우저가 파일을 열지 않고 저장하게 하고, nosniff는 브라우저가 내용을 보고 형식을 마음대로 추측하지 못하게 합니다. 허용한 확장자만 받더라도 두 헤더를 붙여 두면 잘못 통과한 파일이 화면에서 실행될 가능성이 줄어듭니다. 두 번째 명령이 404인 것은 ..%2fapp.py가 files.db에 없는 id라서입니다. 이 앱은 id로 먼저 조회하므로 요청의 경로 문자열이 파일 경로로 쓰이지 않습니다.

자주 막히는 곳

증상 원인과 해결
큰 파일 업로드에서 curl: (55) 같은 연결 재설정이 나옵니다 Flask 개발 서버는 크기 제한에 걸리면 413 응답 대신 연결을 끊는 경우가 있다고 공식 문서가 안내합니다. 운영용 WSGI 서버에서는 413이 정상으로 돌아옵니다. 이 글의 환경에서는 개발 서버(python app.py)에서도 413이 나왔습니다
한글 이름을 올렸더니 원본 이름이 txt로 보입니다 secure_filename이 ASCII가 아닌 글자를 제거하기 때문입니다. 7단계에서 확인한 동작이며 오류가 아닙니다
상위 폴더에 escaped.txt가 생깁니다 vuln_app.py(19201번 포트)로 요청을 보낸 경우입니다. 수정한 app.py는 19202번 포트입니다
PowerShell에서 -F 옵션이 오류를 냅니다 PowerShell의 curl은 별칭입니다. curl.exe를 쓰거나 Git Bash에서 실행합니다

이어서 해 볼 것

  • 내용 확인 추가: 업로드된 이미지의 앞부분 바이트(시그니처)를 읽어 확장자와 내용이 같은지 확인하는 검사를 넣어 봅니다. a.txt 내용을 a.png로 올렸을 때 거절되는지 확인합니다.
  • 저장 위치 분리: 업로드 폴더를 앱 폴더 밖의 별도 경로로 옮기고, 실행 권한이 없는 계정으로 앱을 돌려 파일이 실행되지 않는 구성을 만들어 봅니다.
  • 총량 제한: 로그인한 사용자만 올리게 하고 사용자별 총 업로드 용량 제한을 더해 봅니다. MAX_CONTENT_LENGTH는 요청 하나의 크기만 제한합니다.

확인 범위

직접 실행한 부분은 2단계부터 8단계까지입니다. 환경은 Windows 11, Git Bash, Python 3.12.10, Flask 3.1.3, Werkzeug 3.1.9, curl 8.19.0이고, 모든 요청은 127.0.0.1에 curl로 보냈습니다. 도구 동작은 Flask의 Uploading Files 문서와 Werkzeug secure_filename 문서의 설명과 맞춰 확인했습니다. 8단계의 ..%2fapp.py 요청은 --path-as-is를 붙여 실제로 전달되는 것을 확인했습니다.

브라우저 업로드 폼, 운영용 WSGI 서버(gunicorn 등) 뒤에서의 413 처리, Linux와 macOS에서의 동작, 업로드 폴더 실행 권한 제한은 실행하지 못했습니다. 공개 서비스에 적용하려면 안티바이러스 검사, 저장소 분리, 접근 제어 같은 별도 대책이 필요하며 이 글의 범위가 아닙니다.

이 글의 다음 단계

  • Flask에서 소유자 검사로 IDOR 막기로그인만 확인하고 주문의 주인은 확인하지 않는 Flask API가 남의 주문을 돌려주는 것을 pytest 한 건으로 확인합니다. 쿼리에 소유자 조건을 넣고 번호를 받는 엔드포인트에 같은 데코레이터를 붙인 뒤, 사용자와 역할 조합 표로 회귀 테스트를 만듭니다.
  • 파이썬 비밀번호 해시와 하드코딩된 시크릿 정리하기MD5로 저장하고 코드에 비밀번호가 박힌 회원 가입 예제를 Bandit으로 검사한 뒤, argon2-cffi 해시와 환경 변수 분리로 고쳐 다시 통과시킵니다. 해시 문자열 구조와 needs_rehash 재해시도 직접 확인합니다.

시큐어코딩 (Python, Java) 과정의 일부입니다

이 실습은 교육에서 참가자마다 독립된 환경으로 진행합니다. 원하는 일정을 고르면 문의 양식에 미리 채워집니다.

과정 보기

교육 진행 순서와 자주 묻는 질문 보기

같은 카테고리의 글