~/investigacion $ cat caso-004.md

GitLab CE/EE: lectura de archivos sin autenticación en la API de commits

CVSS 10.0 · crítico CVE-2026-85706 GitLab CE/EE Path traversal CWE-22 CISA KEV

Reconstrucción controlada con agentes de IA del path traversal sin autenticación de la API de commits de GitLab, ya divulgada y remediada por el fabricante. El caso reproduce la lectura arbitraria en la versión vulnerable, verifica el cierre en la versión parcheada y publica cuatro reglas de detección con su evidencia de disparo.

Qué se publicó y qué no es este caso

GitLab publicó el 10 de septiembre de 2026 un parche crítico (19.3.2, 19.2.6 y 19.1.8) que cierra un path traversal sin autenticación en la API de commits. La clasificación pública del fabricante es CVSS 3.1 10.0 (Crítico) con vector AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N, y el aviso acredita a s3ntago por reportarlo a través de HackerOne. El 23 de septiembre el mismo aviso documenta el backport a 19.0.9 y 18.11.12.

El caso no se presenta como un descubrimiento: aporta un registro de cierre reproducible, una cadena de impacto acotada y una explicación con evidencia del porqué de la ruta vulnerable. CISA KEV lo lista desde el 2026-09-11 con vencimiento 2026-09-14, y NVD declara los rangos anteriores a cada versión parcheada.

Todo se produjo en un laboratorio sin salida a internet: imágenes stock gitlab/gitlab-ce:19.3.1-ce.0 y 19.3.2-ce.0, un puente Docker --internal y credenciales locales ficticias. Ningún sistema real fue contactado.

La cadena, paso a paso

La entrada es POST /api/v4/projects/:id/repository/commits/. La barra final hace que la ruta no cierre el patrón :z del router y la petición caiga en el catch-all gestionado por Workhorse, que reenvía a Rails sin haber autenticado.

Dentro del helper de subida, file_params_from_request_body_upload lee file.path del cuerpo, y la lectura se comporta como un oráculo de estado. Con file= vacío se pasa el requires :file del cortocircuito; con un file.path inexistente devuelve 400 local file not present, que es la prueba de que el código vulnerable ejecuta antes de autenticar; y pedir un directorio (/etc) fuerza un 500 por EISDIR sin transferir un byte.

La lectura real aparece como un 400 Invalid parameter: invalid %-encoding ( con el contenido del fichero ecoado en el mensaje de error de Grape: 153 bytes de cuerpo en el leak frente a 54 del probe y 30 del 401. Sobre esa primitiva, la demostración de impacto encadena una credencial de automatización ficticia filtrada, la toma de un proyecto y ejecución como root en un runner del laboratorio, acotada y presentada como riesgo aguas abajo de la lectura.

Qué hizo el agente en el laboratorio

Antes de tocar nada, el laboratorio pasa un gate de salud de 14/14: red aislada, md5 del fichero sink y proyecto público presente. La lectura se reprodujo en la rama vulnerable con la PoC del propio repo y, contra la rama 19.3.2, la matriz cierra con 401 uniforme en todas las variantes y cero divulgación.

La detección se escribió solo desde las capturas vivas del caso, no desde el aviso. Los dos templates Nuclei disparan en vivo contra la rama stock, y se publican dos archivos por regla porque Nuclei 3.11 solo carga el primer documento YAML de cada archivo (medido: Templates loaded for current scan: 1). Las dos Sigma se validan con un arnés de replay de predicados: 9/9, con P1 y P2 sobre filas reales de la evidencia y los cinco negativos N1-N5 silenciosos.

Todo el paquete pasa por un claim gate: video-verification/claims.json fija cómo se derivan los 19 números que afirma el paper, y el veredicto al rejugarlo es 19/19 PASS, cerrado el 13 de septiembre de 2026.

Dónde se detuvo el agente

El attempt-log.md del caso guarda lo que no salió. El intento A1 (canal .../commits/authorize) queda marcado INVALID como canal de lectura: el cuerpo de la respuesta de authorize nunca llega al cliente (api.blocker). El intento A2, que forzaba la rama application/x-www-form-urlencoded por content-type de cuerpo, no dio señal de existencia: las dos ramas respondían 401. El barrido A4 mostró que el resto de rutas candidatas devolvían 400 o 404 antes de la lectura.

Hubo una decisión tomada para no publicar una regla rota: la forma 401 EXISTS-read-clean es señal real de la rama vulnerable, pero idéntica al 401 del GitLab parcheado, así que un matcher sobre ella dispararía en stock benigno y se quedó fuera. Y el propio kit declara huecos: sigma-cli no se pudo ejecutar (host PEP 668) y su validación queda DEFERRED; el negativo de Nuclei contra la rama parcheada nunca se corrió; y las variantes con file.path solo en el cuerpo son invisibles para la regla de nginx, porque su log_format no registra el cuerpo.

El hueco más incómodo es de cobertura: la regla asume al menos un proyecto público. Contra una instancia sin proyectos públicos las dos ramas devuelven 404 en Grape y las reglas callan, así que un host vulnerable pero no explotable se lee como limpio. El propio kit recomienda comprobar antes /api/v4/projects?public=true y no fiarse del silencio.

Detección y remediación

Remediación: subir a 19.3.2, 19.2.6 o 19.1.8, o a 19.0.9 y 18.11.12 desde el backport del 23 de septiembre de 2026. GitLab mantiene parcheada la infraestructura gerenciada y publica detecciones para instancias self-managed. Los cinco ficheros de reglas están bajo rules/ y las ejecuciones de triaje bajo detection/.

En el log de borde: POST a /repository/commits/ (o sus formas .json, %2F y la familia /repository/files/) con file.path y sin cabecera de autenticación. En api_json.log: filas de esos POST con params file.path y sin claves user_id ni username (medido: cero filas con username entre las que llevaban file.path). Rails pierde la barra final que sí queda en nginx, así que las dos Sigma se casan por correlation_id.

Para triaje, tres señales medidas: una mezcla de estados (400 local file not present + 401 + 500) desde un mismo remote_ip en minutos es un barrido de oráculo; un POST /repository/commits*/ con 400 y $body_bytes_sent muy por encima de los 120 bytes es lectura confirmada y se escala primero; y si api_error trae invalid %-encoding ( ya hay bytes de fichero en la respuesta.

Resultado

  • Lectura arbitraria sin autenticación reproducida en la imagen stock gitlab/gitlab-ce:19.3.1-ce.0 con el gate de salud de 14/14 checks; la rama 19.3.2 responde 401 uniforme y cero divulgación de ficheros.
  • Los dos templates Nuclei disparan en vivo contra la rama stock (rule-fires/nuclei-live-stock-20260912a.txt); las dos reglas Sigma se validan por replay de predicados: 9/9, los positivos P1 y P2 disparan y los cinco negativos quedan silenciosos.
  • Tres clases de respuesta separan el oráculo pre-auth: 400 local file not present (el código vulnerable ejecuta antes de autenticar), 500 por EISDIR al pedir un directorio (cero bytes transferidos) y el leak real como 400 Invalid parameter: invalid %-encoding (. Cuerpos medidos: 153 B en el leak, 54 B en el probe, 30 B en el 401.
  • El claim gate del caso (video-verification/claims.json, 19 afirmaciones) se rejugó con veredicto 19/19 PASS, cerrado el 2026-09-13.
  • El registro CISA KEV lista CVE-2026-85706 con dateAdded 2026-09-11 y dueDate 2026-09-14.
~/contactorespuesta < 24 h hábiles · es / en

tú@nullsector:~$ mail miguel

[email protected]

Colaboraciones, charlas, prensa o investigación: escríbeme.

¿Necesitas un pentest o un análisis de vulnerabilidades? Pídelo en Xpectra.ai ↗