
Python-PoC-Exploit für CVE-2026-85706, ein unauthentifizierter beliebiger Dateilesezugriff in GitLab CE/EE über eine Workhorse-Pfadkodierungs-Umgehung, mit Writeup und Umgehungsvarianten.
Unauthentifiziertes beliebiges lokales Dateilesen in GitLab CE/EE. Betrifft 18.7–19.1.7, 19.2.0–19.2.5, 19.3.0–19.3.1. Behoben in 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10). CVSS 10.0, Berichten zufolge in freier Wildbahn ausgenutzt. Ursprünglicher Bericht von s3ntago, und dieses Repo ist nur mein Writeup + PoC.
Dieser PoC wird ausschließlich zu Bildungs- und defensiven Forschungszwecken veröffentlicht, um Admins und Forschern zu helfen, die Schwachstelle zu verstehen und darauf zu testen. Führe ihn ausschließlich gegen Systeme aus, die dir gehören oder für die du eine ausdrückliche schriftliche Testgenehmigung hast. Wenn deine Instanz in den betroffenen Bereich fällt, hör auf zu lesen und patche zuerst auf 19.1.8 / 19.2.6 / 19.3.2.
Drei Repository-Endpunkte (POST :id/repository/commits, POST/PUT :id/repository/files/:file_path) sitzen hinter dem requestBodyUploader von Workhorse. Der Rails-Handler liest den On-Disk-Pfad direkt aus dem rohen file.path-Request-Feld und ruft darauf auf, bevor irgendeine Authentifizierung stattfindet. dient nicht als Auth, weil der signierende Round-Tripper von Workhorse jedem Request, den er proxyt, ein gültiges -JWT anhängt, sodass alles, was zum generischen API-Proxy durchfällt, diese Prüfung besteht.
File.openrequire_gitlab_workhorse!Gitlab-Workhorse-Api-RequestDer einzige Grund, warum dies nicht für jeden sofort LFI bedeutet, ist, dass Workhorse den Request eigentlich zuerst umschreiben soll. Aber seine Route-Regex matcht gegen den escaped Pfad (EscapedPath() plus einen path.Clean-Klon, der niemals %XX dekodiert), während Puma %XX vor dem Grape-Routing dekodiert. Also percent-encode irgendein Zeichen eines statischen Segments (%63ommits, %72epository, %66iles), füge einen abschließenden Slash hinzu oder hänge .json an, das die Regex von Workhorse verpasst, und Rails routet trotzdem zum verwundbaren Handler. Diese Encoding-Diskrepanz ist der Ort, an dem der Bypass liegt. (//, /./, %2F, ;-Varianten wie diese funktionieren nicht, weil path.Clean die ersten beiden normalisiert und Puma %2F ablehnt.)
Dann sende einfach gefälschte, unsignierte Upload-Metadaten als Query-Parameter:
POST /api/v4/projects/1/repository/%63ommits?file=&file.path=<ABSOLUTE_PATH>&file.size=1&Content-Type=application/x-www-form-urlencoded
file= leer erfüllt die Validierung requires :file, WorkhorseFile (leer wird zu nil gecoerct). Das Lesen erfolgt vor der Authentifizierung. Die Bytes zurückzubekommen ist der spaßige Teil: Im urlencoded-Zweig führt der Helper Rack::Utils.parse_nested_query(File.read(path)) aus und interpoliert Parser-Fehler in den 400-Body. Jedes %, dem nicht zwei Hex-Ziffern folgen, löst InvalidParameterError: invalid %-encoding (<raw file bytes>) aus, und der Dateiinhalt kommt innerhalb der Fehlermeldung zurück. Der JSON-Zweig (Oj) leakt nichts, weshalb der urlencoded Content-Type hier wichtig ist.
Der Fix (master 0d9ce3e7, Backports 1fe30154 / b43c8b26 / 0ff7b6b2) fügt authenticate! zu allen drei Endpunkten plus dem /authorize-Vor-Schritt hinzu, vertraut nur der vom Middleware erzeugten UploadedFile für Pfad/Größe und hört auf, Parser-Fehler zurückzugeben. Der Bug wurde im Dezember 2025 eingeführt, weshalb der betroffene Bereich bei 18.7 beginnt.
./exploit.py --url http://localhost:8080 --file /etc/hostname
./exploit.py --url http://localhost:8080 --file /opt/gitlab/embedded/service/gitlab-rails/config/gitlab.yml
Das Skript durchläuft alle verifizierten Bypass-Formen (kodierte Segmente, abschließender Slash, .json; commits- + files-Endpunkte, POST und PUT) und klassifiziert jede Antwort, damit du erkennen kannst, wo in der Kette eine Probe gestorben ist. Natürlich nur autorisierte Ziele.
% enthält, dem nicht zwei Hex-Zeichen folgen.(.*) bis zur letzten ) im Body). Gut gegen den Standard-JSON-Fehler, wird aber zu viel erfassen, wenn etwas davor die Antwort in HTML einwickelt. Der richtige Fix ist das Parsen des JSON-Message-Felds.before-Block gibt sonst 404 zurück).