
Root-cause analysis, vulnerable Docker lab, and PoC scripts for CVE-2026-85706, an unauthenticated arbitrary file read in GitLab via parser differential.
CVSS 10.0 · Unauthenticated · Actively exploited (CISA KEV)
A parser differential between GitLab Workhorse (Go reverse proxy) and Puma/Grape
(Ruby) lets an unauthenticated attacker bypass Workhorse's accelerated-upload handoff and
reach three upload endpoints with an attacker-controlled file.path, yielding arbitrary
file read on the GitLab host.
Encoding one character of files as %66iles makes Workhorse's upload route miss (it
matches on the encoded path) while Rails decodes it back and hits the real handler (it
routes on the decoded path). The handler trusts the raw file.path param that Workhorse
was supposed to overwrite — so file.path=/etc/passwd is read from disk with no credentials.
POST /api/v4/projects/1/repository/%66iles/x?file=&file.path=/etc/passwd&file.size=1
^^^^^^ Workhorse misses -> raw file.path survives to Rails
| Version | |
|---|---|
| Affected | CE/EE 18.7 → 19.1.8, 19.2 → 19.2.6, 19.3 → 19.3.2 |
| Fixed | 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10) |
| Path | Contents |
|---|---|
docs/ANALYSIS.md | Full root-cause analysis — parser differential, the two Workhorse JWTs, the raw-file.path trust bug, the patch diff, and the reflected-error exfiltration channel. Analysis done against real source (v19.3.1-ee vs v19.3.2-ee). |
lab/ | Docker-based vulnerable lab (gitlab-ce:19.3.1-ce.0) + bring-up instructions. |
poc/ | detect.sh (non-destructive existence oracle) and exploit.sh (reflected-error file read). Single-target, authorization-gated. |
Not familiar with GitLab internals? Here's what each layer does — in the order a request
passes through them. A deeper glossary with key concepts is in
docs/ANALYSIS.md §0.
Internet
│
▼
┌────────┐ ┌───────────┐ ┌────────┐ ┌──────────────────────┐
│ NGINX │────▶│ Workhorse │────▶│ Puma │────▶│ Grape / Rails app │
│(proxy) │ │ (Go) │ │ (Ruby) │ │ (Ruby) │
└────────┘ └───────────┘ └────────┘ └──────────────────────┘
| Component | What it is |
|---|---|
| NGINX | Outermost reverse proxy. Terminates TLS, serves static files, forwards everything else inward. Not directly involved in this vulnerability. |
| Workhorse | A Go reverse proxy specific to GitLab. Its main job is offloading work Ruby is slow at — especially streaming large uploads. For upload endpoints, Workhorse buffers the body to a temp file, signs a JWT, and rewrites file.path so Ruby never sees raw upload bytes. It also stamps a per-request Gitlab-Workhorse-Api-Request JWT on every request it forwards (proving "this came through the proxy," not "this user is authenticated"). |
| Puma | The Ruby application server that runs the Rails app. Receives requests from Workhorse, runs middleware (Rack), and dispatches to the router. |
| Rack | The Ruby web-server interface layer. Rack middleware handles query-string parsing, session management, and — critically here — verifying Workhorse's upload JWT and building UploadedFile objects. Rack::Utils.parse_nested_query is the function whose error messages leak file content in this exploit. |
| Grape | A REST API framework used by GitLab for all /api/v4/* endpoints. Provides route definitions and before-filters like require_gitlab_workhorse! (proxy check) and authenticate! (user identity check). Runs inside Rails, on Puma, behind Workhorse — so it sees the decoded URL path. |
| Rails | The overall web framework (Ruby on Rails). GitLab is a Rails monolith — models, services, and middleware all run here inside Puma. |
The vulnerability lives in the gap between Workhorse (which matches routes on the encoded path) and Grape/Puma (which route on the decoded path). See below.
r.URL.EscapedPath() (encoded);
Puma/Grape route on the decoded path. %66iles ≠ regex files for Workhorse, but
decodes to files for Rails.file.path to a signed temp path, and never sets the
Gitlab-Workhorse-Multipart-Fields header — but it still proxies the request (with a
valid Gitlab-Workhorse-Api-Request JWT).authenticate!, and the handler
read params['file.path'] directly (the attacker's string) instead of the
Workhorse-verified, path-confined params[:file] UploadedFile.Rack::Utils.parse_nested_query
error strings ("invalid %-encoding (<file bytes>)") — the response reflects file bytes
up to the first invalid %.The patch adds authenticate!, switches to the verified UploadedFile, and stops
reflecting e.message. See docs/ANALYSIS.md §5.
# 1. Stand up the vulnerable lab (see lab/README.md for details)
cd lab && docker compose up -d # wait ~5 min for GitLab to become healthy
# 2. Non-destructive detection
../poc/detect.sh http://localhost:8929
# 3. File-read PoC against a file you're authorized to read on your own lab
../poc/exploit.sh http://localhost:8929 /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
This is published for defensive and educational purposes: understanding, detecting, and patching a disclosed, patched CVE that is on the CISA KEV list. The scripts here are single-target and require you to pass the target explicitly.
localhost. Do not point it at third-party hosts.If you run GitLab, upgrade to a fixed version — that is the only real remediation.
gitlab-org/gitlab @ v19.3.1-ee vs v19.3.2-eeMIT — analysis and PoC code only. GitLab is a trademark of GitLab Inc.; this repository is not affiliated with or endorsed by GitLab Inc.