Operator On The Wire
← Back to Knowledge Base
OOTW / Chapter III - Web / 04. File Uploads

Example

This example uses the shared Flask lab.

We plug File Uploads in as the fourth web module.

Complete the Chapter III Introduction setup first. The base app.py, schema.sql, seed.sql, and database.db must already exist.

The flow is:

create modules/file_uploads.py
register the blueprint in app.py
restart Flask
visit /file-uploads/basic

The File Uploads module provides four pages:

/file-uploads/basic
/file-uploads/blacklist
/file-uploads/better
/file-uploads/fixed

Plug In The Module

From the lab directory (disregard if you have already done this):

cd ~/ootw-web-lab
source .venv/bin/activate
mkdir -p modules
touch modules/__init__.py

Create modules/file_uploads.py:

cat > modules/file_uploads.py <<'EOF'
import mimetypes
import secrets
from pathlib import Path

from flask import Blueprint, abort, redirect, render_template_string, request, send_file
from werkzeug.utils import secure_filename

from app import get_db


bp = Blueprint("file_uploads", __name__, url_prefix="/file-uploads")

BASE_DIR = Path(__file__).resolve().parents[1]
UPLOAD_ROOT = BASE_DIR / "lab_uploads"
MAX_UPLOAD_BYTES = 128 * 1024

STAGE_OWNER = {
    "basic": 4101,
    "blacklist": 4102,
    "better": 4103,
    "fixed": 4104,
}

BLOCKED_EXTENSIONS = {".php", ".phtml", ".phar", ".jsp", ".asp", ".aspx"}
IMAGE_EXTENSIONS_WITH_SVG = {".png", ".jpg", ".jpeg", ".gif", ".svg"}


PAGE = """
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>{{ title }}</title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; line-height: 1.5; }
        nav a { margin-right: 12px; }
        label { display: block; margin-top: 12px; }
        input { width: 100%; padding: 7px; box-sizing: border-box; }
        button { margin-top: 12px; padding: 7px 10px; }
        table { border-collapse: collapse; width: 100%; margin-top: 18px; }
        th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
        code { background: #eee; padding: 2px 4px; }
    </style>
</head>
<body>
    <h1>{{ title }}</h1>

    <nav>
        <a href="/file-uploads/basic">basic</a>
        <a href="/file-uploads/blacklist">blacklist</a>
        <a href="/file-uploads/better">better</a>
        <a href="/file-uploads/fixed">fixed</a>
        <a href="/file-uploads/audit">audit</a>
    </nav>

    <p>{{ description }}</p>

    <form method="post" enctype="multipart/form-data" action="/file-uploads/{{ stage }}">
        <label for="file">File</label>
        <input id="file" name="file" type="file">
        <button type="submit">Upload</button>
    </form>

    {% if result %}
    <p><strong>{{ result }}</strong></p>
    {% endif %}

    <h2>Uploaded Files</h2>
    <table>
        <tr>
            <th>Original Name</th>
            <th>Stored Name</th>
            <th>Content Type</th>
            <th>Open</th>
        </tr>
        {% for upload in uploads %}
        <tr>
            <td>{{ upload["original_name"] }}</td>
            <td>{{ upload["stored_name"] }}</td>
            <td>{{ upload["content_type"] }}</td>
            <td><a href="/file-uploads/files/{{ stage }}/{{ upload["stored_name"] | urlencode }}">open</a></td>
        </tr>
        {% else %}
        <tr>
            <td colspan="4">No uploads yet.</td>
        </tr>
        {% endfor %}
    </table>
</body>
</html>
"""


AUDIT_PAGE = """
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>File Upload Audit</title>
</head>
<body>
    <h1>File Upload Audit</h1>
    <p>This page shows recent file upload events from the local lab.</p>
    <p><a href="/file-uploads/basic">back to File Uploads lab</a></p>

    {% for event in events %}
    <article>
        <p><strong>{{ event["event_type"] }}</strong> - {{ event["username"] }}</p>
        <pre>{{ event["detail"] }}</pre>
        <small>{{ event["created_at"] }}</small>
    </article>
    {% else %}
    <p>No file upload events yet.</p>
    {% endfor %}
</body>
</html>
"""


def stage_directory(stage):
    path = UPLOAD_ROOT / stage
    path.mkdir(parents=True, exist_ok=True)
    return path


def require_file():
    storage = request.files.get("file")
    if storage is None or storage.filename == "":
        abort(400)
    return storage


def client_filename(storage):
    raw_name = storage.filename.replace("\\", "/")
    name = Path(raw_name).name
    if name in {"", ".", ".."}:
        return "upload.bin"
    return name


def extension(filename):
    return Path(filename.lower()).suffix


def read_upload(storage):
    data = storage.read(MAX_UPLOAD_BYTES + 1)
    if len(data) > MAX_UPLOAD_BYTES:
        abort(413)
    return data


def guessed_content_type(filename, supplied):
    return supplied or mimetypes.guess_type(filename)[0] or "application/octet-stream"


def save_bytes(stage, stored_name, data):
    target = stage_directory(stage) / stored_name
    target.write_bytes(data)
    return target


def record_upload(stage, original_name, stored_name, content_type):
    path = f"lab_uploads/{stage}/{stored_name}"
    db = get_db()
    db.execute(
        """
        INSERT INTO uploads (owner_id, original_name, stored_name, content_type, path)
        VALUES (?, ?, ?, ?, ?)
        """,
        (STAGE_OWNER[stage], original_name, stored_name, content_type, path),
    )
    db.execute(
        """
        INSERT INTO audit_log (event_type, username, detail)
        VALUES (?, ?, ?)
        """,
        (
            f"file-upload-{stage}",
            "operator",
            f"original={original_name} stored={stored_name} content_type={content_type}",
        ),
    )
    db.commit()


def uploads_for(stage):
    db = get_db()
    return db.execute(
        """
        SELECT original_name, stored_name, content_type, path
        FROM uploads
        WHERE owner_id = ?
        ORDER BY id DESC
        """,
        (STAGE_OWNER[stage],),
    ).fetchall()


def render_lab(stage, title, description, result=None):
    return render_template_string(
        PAGE,
        stage=stage,
        title=title,
        description=description,
        result=result,
        uploads=uploads_for(stage),
    )


def detect_raster_image(data):
    if data.startswith(b"\x89PNG\r\n\x1a\n"):
        return "image/png", ".png"
    if data.startswith(b"\xff\xd8\xff"):
        return "image/jpeg", ".jpg"
    if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
        return "image/gif", ".gif"
    return None


@bp.get("/")
def index():
    return redirect("/file-uploads/basic")


@bp.route("/basic", methods=["GET", "POST"])
def basic():
    result = None

    if request.method == "POST":
        storage = require_file()
        original_name = client_filename(storage)
        data = read_upload(storage)
        content_type = guessed_content_type(original_name, storage.content_type)
        stored_name = original_name
        save_bytes("basic", stored_name, data)
        record_upload("basic", original_name, stored_name, content_type)
        result = f"saved {stored_name}"

    return render_lab(
        "basic",
        "File Uploads Basic",
        "The route accepts any uploaded file and serves it back from the application origin.",
        result,
    )


@bp.route("/blacklist", methods=["GET", "POST"])
def blacklist():
    result = None

    if request.method == "POST":
        storage = require_file()
        original_name = client_filename(storage)

        if extension(original_name) in BLOCKED_EXTENSIONS:
            abort(400)

        data = read_upload(storage)
        content_type = guessed_content_type(original_name, storage.content_type)
        stored_name = original_name
        save_bytes("blacklist", stored_name, data)
        record_upload("blacklist", original_name, stored_name, content_type)
        result = f"saved {stored_name}"

    return render_lab(
        "blacklist",
        "File Uploads Weak Blacklist",
        "The route blocks a few server-side extensions, then accepts everything else.",
        result,
    )


@bp.route("/better", methods=["GET", "POST"])
def better():
    result = None

    if request.method == "POST":
        storage = require_file()
        original_name = secure_filename(client_filename(storage))

        if original_name == "":
            abort(400)

        if extension(original_name) not in IMAGE_EXTENSIONS_WITH_SVG:
            abort(400)

        content_type = guessed_content_type(original_name, storage.content_type)
        if not content_type.startswith("image/"):
            abort(400)

        data = read_upload(storage)
        stored_name = original_name
        save_bytes("better", stored_name, data)
        record_upload("better", original_name, stored_name, content_type)
        result = f"saved {stored_name}"

    return render_lab(
        "better",
        "File Uploads Better Image Check",
        "The route allows image extensions and image MIME types, but SVG is active browser content.",
        result,
    )


@bp.route("/fixed", methods=["GET", "POST"])
def fixed():
    result = None

    if request.method == "POST":
        storage = require_file()
        original_name = secure_filename(client_filename(storage))
        data = read_upload(storage)
        detected = detect_raster_image(data)

        if detected is None:
            abort(400)

        content_type, safe_extension = detected
        stored_name = f"{secrets.token_hex(16)}{safe_extension}"
        save_bytes("fixed", stored_name, data)
        record_upload("fixed", original_name, stored_name, content_type)
        result = f"saved {stored_name}"

    return render_lab(
        "fixed",
        "File Uploads Fixed",
        "The route accepts only raster image bytes and generates the stored filename server-side.",
        result,
    )


@bp.get("/files/<stage>/<stored_name>")
def uploaded_file(stage, stored_name):
    if stage not in STAGE_OWNER:
        abort(404)

    db = get_db()
    upload = db.execute(
        """
        SELECT original_name, stored_name, content_type, path
        FROM uploads
        WHERE owner_id = ?
        AND stored_name = ?
        ORDER BY id DESC
        LIMIT 1
        """,
        (STAGE_OWNER[stage], stored_name),
    ).fetchone()

    if upload is None:
        abort(404)

    upload_root = UPLOAD_ROOT.resolve()
    target = (UPLOAD_ROOT / stage / upload["stored_name"]).resolve()

    try:
        target.relative_to(upload_root)
    except ValueError:
        abort(404)

    if not target.is_file():
        abort(404)

    response = send_file(
        target,
        mimetype=upload["content_type"],
        as_attachment=False,
        download_name=upload["original_name"],
    )

    if stage == "fixed":
        response.headers["X-Content-Type-Options"] = "nosniff"

    return response


@bp.get("/audit")
def audit():
    db = get_db()
    events = db.execute(
        """
        SELECT event_type, username, detail, created_at
        FROM audit_log
        WHERE event_type LIKE 'file-upload-%'
        ORDER BY id DESC
        LIMIT 30
        """
    ).fetchall()
    return render_template_string(AUDIT_PAGE, events=events)
EOF

Now register the module in app.py.

Add this near the bottom of app.py, after the route definitions and before the if __name__ == "__main__": block:

from modules.file_uploads import bp as file_uploads_bp
app.register_blueprint(file_uploads_bp)

If the earlier web modules are already plugged in, keep those lines and add File Uploads below them:

from modules.xss import bp as xss_bp
app.register_blueprint(xss_bp)

from modules.idor import bp as idor_bp
app.register_blueprint(idor_bp)

from modules.command_injection import bp as command_injection_bp
app.register_blueprint(command_injection_bp)

from modules.file_uploads import bp as file_uploads_bp
app.register_blueprint(file_uploads_bp)

Run the app:

flask --app app run --host 127.0.0.1 --port 5000

Confirm the module is live:

curl -i "http://127.0.0.1:5000/file-uploads/basic"

Basic Vulnerability

Open:

http://127.0.0.1:5000/file-uploads/basic

This page accepts any uploaded file and serves it back from the same origin as the application.

Create a harmless HTML proof:

cat > upload-proof.html <<'EOF'
<!doctype html>
<html lang="en">
<body>
<h1>upload proof</h1>
<script>
fetch('/health')
  .then(function(response) { return response.text(); })
  .then(function(text) {
    document.body.insertAdjacentHTML('beforeend', '<pre>' + text + '</pre>');
  });
</script>
</body>
</html>
EOF

Upload it:

curl -i -X POST "http://127.0.0.1:5000/file-uploads/basic" \
  -F "file=@upload-proof.html;type=text/html"

Open:

http://127.0.0.1:5000/file-uploads/files/basic/upload-proof.html

The browser renders the uploaded HTML and runs its JavaScript in the application origin.

This proves stored active content through file upload.


Basic Protection - Weak Blacklist

Open:

http://127.0.0.1:5000/file-uploads/blacklist

This page blocks a few server-side extensions:

BLOCKED_EXTENSIONS = {".php", ".phtml", ".phar", ".jsp", ".asp", ".aspx"}

That blocks one family of dangerous uploads.

It does not make the upload feature safe.

Upload the same HTML proof:

curl -i -X POST "http://127.0.0.1:5000/file-uploads/blacklist" \
  -F "file=@upload-proof.html;type=text/html"

Open:

http://127.0.0.1:5000/file-uploads/files/blacklist/upload-proof.html

The blacklist is aimed at server-side execution. The browser still executes active client-side content.

Blocking a few extensions is not a complete upload policy.


Better Protection - Image Allowlist With SVG

Open:

http://127.0.0.1:5000/file-uploads/better

This page moves from a blacklist to an image allowlist:

IMAGE_EXTENSIONS_WITH_SVG = {".png", ".jpg", ".jpeg", ".gif", ".svg"}

It also checks that the content type starts with image/:

if not content_type.startswith("image/"):
    abort(400)

That is better, but SVG is not a normal raster image. SVG is XML that can contain script and event handlers.

Create a harmless SVG proof:

cat > upload-proof.svg <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="700" height="140" onload="fetch('/health').then(function(r){return r.text()}).then(function(t){document.querySelector('text').textContent=t})">
  <rect width="700" height="140" fill="white"/>
  <text x="20" y="70">waiting</text>
</svg>
EOF

Upload it:

curl -i -X POST "http://127.0.0.1:5000/file-uploads/better" \
  -F "file=@upload-proof.svg;type=image/svg+xml"

Open:

http://127.0.0.1:5000/file-uploads/files/better/upload-proof.svg

The file passes the image checks and still runs browser-side script when opened directly.

The protection is better than the blacklist, but the allowlist includes an active format.


Fixed Version

Open:

http://127.0.0.1:5000/file-uploads/fixed

The fixed page does three things:

  • It accepts only raster image magic bytes.
  • It rejects SVG, HTML, and text files.
  • It generates the stored filename server-side.

The server-side type check:

if data.startswith(b"\x89PNG\r\n\x1a\n"):
    return "image/png", ".png"
if data.startswith(b"\xff\xd8\xff"):
    return "image/jpeg", ".jpg"
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
    return "image/gif", ".gif"

Run the SVG proof against the fixed route:

curl -i -X POST "http://127.0.0.1:5000/file-uploads/fixed" \
  -F "file=@upload-proof.svg;type=image/svg+xml"

Expected:

400 Bad Request

Create a tiny PNG:

printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' | base64 -d > proof.png

Upload it:

curl -i -X POST "http://127.0.0.1:5000/file-uploads/fixed" \
  -F "file=@proof.png;type=image/png"

The accepted file receives a random stored filename instead of using the client-provided name.

![[Chapter III - Web/4. File Uploads/Fixed.png]]


Local Impact Proof

Use the vulnerable route to prove active same-origin content:

curl -i -X POST "http://127.0.0.1:5000/file-uploads/basic" \
  -F "file=@upload-proof.html;type=text/html"

Then open:

http://127.0.0.1:5000/file-uploads/files/basic/upload-proof.html

The uploaded file can call same-origin routes such as /health.

Then view:

http://127.0.0.1:5000/file-uploads/audit

The audit page shows the original filename, stored filename, and content type.

This proves the useful impact:

original=upload-proof.html
stored=upload-proof.html
content_type=text/html

The application accepted active browser content and served it from the trusted origin.

Audit


Remediation

Patch file uploads by controlling the full upload lifecycle:

  • Allow only file types the feature actually needs.
  • Validate file content with magic bytes.
  • Generate server-side stored filenames.
  • Store uploads outside executable web roots.
  • Serve active or untrusted files from a separate origin when possible.
  • Serve files as attachments when inline rendering is not required.
  • Block SVG and HTML unless they are explicitly required and isolated.
  • Set X-Content-Type-Options: nosniff.
  • Enforce upload size limits.
  • Re-encode uploaded images before serving them.
  • Apply authorization to upload read, update, delete, and download routes.
  • Log rejected uploads and suspicious content types.

Safe upload handling is a chain. Extension checks alone are not enough.


Detection

Useful signals include:

  • Uploads with active extensions such as .html, .svg, .xml, or scriptable document formats.
  • Mismatches between extension, content type, and magic bytes.
  • User-controlled filenames reused as stored filenames.
  • Repeated rejected uploads across many extensions.
  • Public requests for newly uploaded active content.
  • Uploads followed by same-origin requests from the uploaded file.
  • Uploads with unexpectedly large size.
  • Uploads with parser errors during thumbnailing or conversion.

Useful payload indicators:

<script
onload=
onerror=
image/svg+xml
text/html
<!doctype html>
<svg
javascript:

Detection is not the fix. It shows which upload boundaries are being tested and which file types need tighter control.