Skip to content
Exploit for CVE-2026

Exploit for CVE-2026

Sploitus September 14, 2026

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

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

| **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) |

| [`docs/ANALYSIS.md`](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/`](lab/) | Docker-based vulnerable lab (`gitlab-ce:19.3.1-ce.0`) + bring-up instructions. |

| [`poc/`](poc/) | `detect.sh` (non-destructive existence oracle) and `exploit.sh` (reflected-error file read). Single-target, authorization-gated. |

## Background — GitLab's request pipeline

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](docs/ANALYSIS.md#0-glossary--gitlabs-request-pipeline).

┌────────┐ ┌───────────┐ ┌────────┐ ┌──────────────────────┐

NGINX │────▶│ Workhorse │────▶│ Puma │────▶│ Grape / Rails app │

│(proxy) │ │ (Go) │ │ (Ruby) │ │ (Ruby) │

└────────┘ └───────────┘ └────────┘ └──────────────────────┘

| **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.

1. **Parser differential.** Workhorse matches routes on `r.URL.EscapedPath()` (encoded);

Puma/Grape route on the decoded path. `%66iles` ≠ regex `files` for Workhorse, but

2. **Handoff skipped.** Because the upload route missed, Workhorse never buffers the body

to a temp file, never rewrites `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).

3. **Missing auth + raw-param trust.** The endpoint had no `authenticate!`, and the handler

read `params['file.path']` directly (the attacker's string) instead of the

Workhorse-verified, path-confined `params[:file]` `UploadedFile`.

4. **Exfil via error messages.** Content leaks through `Rack::Utils.parse_nested_query`

error strings (`"invalid %-encoding ()"`) — the response reflects file bytes

The patch adds `authenticate!`, switches to the verified `UploadedFile`, and stops

reflecting `e.message`. See [`docs/ANALYSIS.md`](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

# 3. File-read PoC against a file you're authorized to read on your own lab

../poc/exploit.sh /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.

- Only run these against systems **you own or are explicitly authorized to test.**

- The lab is designed to run on `localhost`. Do not point it at third-party hosts.

- Unauthorized access to computer systems is illegal in most jurisdictions.

If you run GitLab, **upgrade to a fixed version** — that is the only real remediation.

- watchTowr — [Rapid Reaction: GitLab Path Traversal (CVE-2026-85706)](

- Nuclei Templates — [PR #17231](

- Primary source diff: `gitlab-org/gitlab` @ `v19.3.1-ee` vs `v19.3.2-ee`

[MIT](LICENSE) — analysis and PoC code only. GitLab is a trademark of GitLab Inc.; this

repository is not affiliated with or endorsed by GitLab Inc.