
Python PoC exploit for CVE-2026-85706, an unauthenticated arbitrary file read in GitLab CE/EE via Workhorse path-encoding bypass, with writeup and bypass variants.
Unauthenticated arbitrary local file read in GitLab CE/EE. Affects 18.7–19.1.7, 19.2.0–19.2.5, 19.3.0–19.3.1. Fixed in 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10). CVSS 10.0, reportedly exploited in the wild. Original report by s3ntago and this repo is just my writeup + PoC.
This PoC is published for educational and defensive research purposes only, to help admins and researchers understand and test for the vulnerability. Run it exclusively against systems you own or have explicit written authorization to test. If your instance falls in the affected range, stop reading and patch to 19.1.8 / 19.2.6 / 19.3.2 first.
Three repository endpoints (POST :id/repository/commits, POST/PUT :id/repository/files/:file_path) sit behind Workhorse's requestBodyUploader. The Rails handler reads the on-disk path straight from the raw file.path request field and File.opens it before any authentication. does not serve as an auth because Workhorse's signing round-tripper attaches a valid JWT to every request it proxies, so anything that falls through to the generic API proxy passes that check.
require_gitlab_workhorse!Gitlab-Workhorse-Api-RequestThe only reason this isn't instant LFI for everyone is that Workhorse is supposed to rewrite the request first. But its route regex matches against the escaped path (EscapedPath() plus a path.Clean clone that never decodes %XX), while Puma decodes %XX before Grape routing. So percent-encode any character of a static segment (%63ommits, %72epository, %66iles), add a trailing slash, or tack on .json Workhorse's regex misses, Rails still routes to the vulnerable handler. That encoding mismatch is where lies the bypass. (//, /./, %2F, ; variants like this don't work cause path.Clean normalizes the first two and Puma refuses %2F.)
Then just send forged unsigned upload metadata as query params:
POST /api/v4/projects/1/repository/%63ommits?file=&file.path=<ABSOLUTE_PATH>&file.size=1&Content-Type=application/x-www-form-urlencoded
file= blank satisfies the requires :file, WorkhorseFile validation (blank coerces to nil). The read fires pre-auth. Getting the bytes back is the fun part: on the urlencoded branch the helper runs Rack::Utils.parse_nested_query(File.read(path)) and interpolates parser errors into the 400 body. Any % not followed by two hex digits raises InvalidParameterError: invalid %-encoding (<raw file bytes>) file contents comes back inside the error message. The JSON branch (Oj) leaks nothing, which is why the urlencoded content type matters here.
The fix (master 0d9ce3e7, backports 1fe30154 / b43c8b26 / 0ff7b6b2) adds authenticate! to all three endpoints plus the /authorize pre-step, trusts only the middleware-produced UploadedFile for path/size, and stops echoing parser errors. Bug was introduced in Dec 2025, which is why the affected range starts at 18.7.
./exploit.py --url http://localhost:8080 --file /etc/hostname
./exploit.py --url http://localhost:8080 --file /opt/gitlab/embedded/service/gitlab-rails/config/gitlab.yml
The script cycles through all verified bypass forms (encoded segments, trailing slash, .json; commits + files endpoints, POST and PUT) and classifies each response so you can tell where in the chain a probe died. Authorized targets only, obviously.
% not followed by two hex chars.(.*) up to the last ) in the body). Fine against the stock JSON error, will over-capture if something in front wraps the response in HTML. Proper fix is parsing the JSON message field.before block 404s otherwise).