~/en/research $ cat case-004.md
GitLab CE/EE: unauthenticated file read in the commits API
A controlled reconstruction with AI agents of the unauthenticated path traversal in the GitLab commits API, already disclosed and remediated by the vendor. The case reproduces the arbitrary read on the vulnerable version, verifies the closure on the patched version and publishes four detection rules with evidence of them firing.
What was published and what this case is not
On 10 September 2026 GitLab published a critical patch (19.3.2, 19.2.6 and 19.1.8) closing an unauthenticated path traversal in the commits API. The public classification from the vendor is CVSS 3.1 10.0 (Critical) with vector AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N, and the advisory credits s3ntago for reporting it through HackerOne. On 23 September the same advisory documents the backport to 19.0.9 and 18.11.12.
The case is not presented as a discovery: it contributes a reproducible closure record, a bounded impact chain and an evidence-backed explanation of why the vulnerable route works. CISA KEV has listed it since 2026-09-11 with a 2026-09-14 due date, and NVD declares the ranges prior to each patched version.
Everything was carried out in a lab with no internet egress: stock images gitlab/gitlab-ce:19.3.1-ce.0 and 19.3.2-ce.0, an --internal Docker bridge and fictitious local credentials. No real system was contacted.
The chain, step by step
The entry point is POST /api/v4/projects/:id/repository/commits/. The trailing slash keeps the route from closing the router :z pattern, and the request falls into the catch-all handled by Workhorse, which forwards it to Rails without having authenticated.
Inside the upload helper, file_params_from_request_body_upload reads file.path from the body, and the read behaves like a state oracle. With file= empty the requires :file short-circuit is passed; with a nonexistent file.path it returns 400 local file not present, which is proof that the vulnerable code runs before authenticating; and requesting a directory (/etc) forces a 500 from EISDIR without transferring a single byte.
The actual read surfaces as a 400 Invalid parameter: invalid %-encoding ( with the file content echoed in the Grape error message: 153 body bytes on the leak versus 54 for the probe and 30 for the 401. On that primitive, the impact demonstration chains a leaked fictitious automation credential, the takeover of a project and execution as root on a lab runner, kept bounded and presented as a risk downstream of the read.
What the agent did in the lab
Before touching anything, the lab passes a 14/14 health gate: isolated network, the md5 of the sink file and a public project present. The read was reproduced on the vulnerable branch with the PoC from the repo itself and, against the 19.3.2 branch, the matrix closes with a uniform 401 across all variants and zero disclosure.
Detection was written only from the live captures of the case, not from the advisory. The two Nuclei templates fire live against the stock branch, and two files are published per rule because Nuclei 3.11 loads only the first YAML document in each file (measured: Templates loaded for current scan: 1). The two Sigma rules are validated with a predicate replay harness: 9/9, with P1 and P2 over real rows from the evidence and the five negatives N1-N5 silent.
The whole package goes through a claim gate: video-verification/claims.json sets out how the 19 numbers claimed by the paper are derived, and the verdict on replay is 19/19 PASS, closed on 13 September 2026.
Where the agent stopped
The case attempt-log.md records what did not work out. Attempt A1 (the .../commits/authorize channel) is marked INVALID as a read channel: the authorize response body never reaches the client (api.blocker). Attempt A2, which forced the application/x-www-form-urlencoded branch via body content-type, produced no existence signal: both branches answered 401. The A4 sweep showed that the remaining candidate routes returned 400 or 404 before the read.
A decision was taken not to publish a broken rule: the 401 EXISTS-read-clean shape is a real signal of the vulnerable branch, but identical to the 401 of patched GitLab, so a matcher on it would fire on benign stock and was left out. And the kit itself declares gaps: sigma-cli could not be run (host PEP 668) and its validation stays DEFERRED; the Nuclei negative against the patched branch was never run; and the variants with file.path only in the body are invisible to the nginx rule, because its log_format does not log the body.
The most awkward gap is one of coverage: the rule assumes at least one public project. Against an instance with no public projects both branches return 404 in Grape and the rules stay silent, so a vulnerable but unexploitable host reads as clean. The kit itself recommends checking /api/v4/projects?public=true first and not trusting the silence.
Detection and remediation
Remediation: upgrade to 19.3.2, 19.2.6 or 19.1.8, or to 19.0.9 and 18.11.12 from the 23 September 2026 backport. GitLab keeps the managed infrastructure patched and publishes detections for self-managed instances. The five rule files live under rules/ and the triage runs under detection/.
In the edge log: POST to /repository/commits/ (or its .json, %2F forms and the /repository/files/ family) with file.path and no authentication header. In api_json.log: rows from those POSTs with file.path params and no user_id or username keys (measured: zero rows with username among those carrying file.path). Rails loses the trailing slash that nginx keeps, so the two Sigma rules are paired via correlation_id.
For triage, three measured signals: a mix of statuses (400 local file not present + 401 + 500) from the same remote_ip within minutes is an oracle sweep; a POST /repository/commits*/ with 400 and $body_bytes_sent well above 120 bytes is a confirmed read and gets escalated first; and if api_error carries invalid %-encoding ( there are already file bytes in the response.
Results
- Unauthenticated arbitrary read reproduced on the stock image
gitlab/gitlab-ce:19.3.1-ce.0with the 14/14 health gate; the 19.3.2 branch responds with a uniform 401 and zero file disclosure. - The two Nuclei templates fire live against the stock branch (
rule-fires/nuclei-live-stock-20260912a.txt); the two Sigma rules are validated by predicate replay: 9/9, positives P1 and P2 fire and the five negatives stay silent. - Three response classes distinguish the pre-auth oracle: 400
local file not present(the vulnerable code runs before authenticating), 500 from EISDIR when a directory is requested (zero bytes transferred) and the real leak as a 400Invalid parameter: invalid %-encoding (. Measured bodies: 153 B on the leak, 54 B on the probe, 30 B on the 401. - The case claim gate (
video-verification/claims.json, 19 claims) was replayed with a 19/19 PASS verdict, closed on 2026-09-13. - The CISA KEV catalog lists CVE-2026-85706 with
dateAdded2026-09-11 anddueDate2026-09-14.
you@nullsector:~$ mail miguel
Collaborations, talks, press or research: write to me.
Need a pentest or a vulnerability assessment? Request it at Xpectra.ai ↗