detection-engineering · threat-hunting · vulnerability · gitlab

The GitLab Exploit With No Traversal In It

CVE-2026-85706 went into CISA's Known Exploited Vulnerabilities catalog on 11 September with a remediation deadline of 14 September, which is an unusually short fuse, and it was flagged for forensic triage rather than for patching alone. Patching is the easy half. The hard half is the question every GitLab owner is now being asked, which is whether anyone read your files before you got to it.

That question is answered in logs, so it matters what you search them for. The advisory calls this improper path confinement. Every summary of it we read rendered that as path traversal, and the one-line advice doing the rounds is to hunt the repository commits API for a file path parameter.

We stood the vulnerability up in a lab to write detections against it, and both of those framings sent us the wrong way. The exploit contains no traversal sequence. It does not reliably touch the commits endpoint. Rules built on either idea match nothing, which we know because the first ones we wrote were built that way.

What we built

Two containers, gitlab-ce:19.3.1-ce.0 and gitlab-ce:19.3.2-ce.0. One release apart, one vulnerable and one not, which gives you a true positive and a true negative for free. Both images publish a native arm64 manifest, so this runs at full speed on a laptop instead of crawling under emulation.

We did not run any of the public exploit repositories that had appeared by then. The request shape is documented well enough in public root-cause write-ups to rebuild by hand, and a detection engineer should know exactly what their test traffic does. Every request carried a unique user agent so we could tie each one to its log lines afterwards.

The legitimate half of the corpus is the half that matters for a false positive claim, and it overlaps heavily by design: 155 of those requests hit the two vulnerable endpoints, 77 were real Workhorse-accelerated uploads, and 102 carried percent-encoding in the URL, because legitimate GitLab file API calls encode the slashes in a file path.

It is not a path traversal

The advisory language is "improper path confinement". Everyone rendered that as path traversal, and the detection content followed the label instead of the bug.

GitLab puts a Go reverse proxy called Workhorse in front of Rails. On an upload it intercepts the request, buffers the body to a temp file, and rewrites the parameters so the path the application sees points at that temp file. The application is supposed to never see a client-controlled path.

Workhorse matches its routes against the encoded URL. Rails decodes first and then routes. Give the two of them a URL they disagree about and Workhorse passes it through untouched while Rails still delivers it to the upload handler, which then trusts the path the client supplied.

Here are the spellings that worked in our lab, all of which reached the vulnerable handler:

POST /api/v4/projects/1/repository/%66iles/x   ?file=&file.path=/var/opt/gitlab/gitlab-rails/etc/gitlab.yml&file.size=1
POST /api/v4/projects/1/%72epository/files/x   ?file=&file.path=/nonexistent&file.size=1
POST /api/v4/projects/1/repository/%63ommits   ?file=&file.path=/var/opt/gitlab/gitlab-rails/etc/gitlab.yml&file.size=1
POST /api/v4/projects/1/repository/commits/    ?file=&file.path=/var/opt/gitlab/gitlab-rails/etc/gitlab.yml&file.size=1
POST /api/v4/projects/1/repository/commits.json?file=&file.path=/var/opt/gitlab/gitlab-rails/etc/gitlab.yml&file.size=1

The first three encode one character. The last two encode nothing at all. A trailing slash works. A file extension works. And the segment repository can be encoded just as happily as files or commits can.

Missing from every line is the thing the label tells you to search for. There is no ../. The attacker passes an absolute path, because they are not escaping a directory, they are naming a file. A traversal pattern has nothing to match on.

What we measured against

The corpus is 22 exploitation attempts, 256 legitimate authenticated requests and four controls, and every rule below was scored against it.

Two things about the scoring, because a false positive number is only as good as the labelling behind it. Every request was labelled by what we sent it to be, from a tag we put in its user agent, and never by whether it happened to match a rule. Scoring a rule against ground truth derived from that rule's own condition produces a perfect result and means nothing. The two logs also count differently. Nginx records one line per request, so 22 attacks plus 256 legitimate requests plus four controls gives 282 lines. The Rails log adds an entry whenever a request triggers the upload authorisation preflight, and 80 of them did: 77 legitimate uploads, two controls and one attack. One control was refused at that preflight and never reached the endpoint behind it, so it contributes a preflight line and no base line. That gives 281 base entries plus 80 preflights, or 361 Rails events, of which 333 are legitimate.

The controls are worth naming. Three are unauthenticated requests to the vulnerable endpoints with no bypass and no file path. The fourth is the same attack spelled correctly, so Workhorse intercepted it and rewrote the path as designed. Our rules fire on it. We score it as neither a hit nor a miss, because a client that sends a file path on this route is worth flagging whether or not the proxy neutralises it.

For the network rule we rebuilt each logged request and response as a pcap and replayed it rather than capturing live traffic, so the engine saw byte-identical input.

Rule Attacks detected, of 22 False positives
Sigma on the nginx log 22 0 of 256 requests
Sigma on the Rails API log 22 0 of 333 events
Suricata 22 0 of 256 requests

What the logs contain

Two files carry the evidence, and they disagree in a way that turns out to be useful.

The nginx access log at /var/log/gitlab/nginx/gitlab_access.log records the request line as the client sent it, encoding intact, query string intact. The Rails API log at /var/log/gitlab/gitlab-rails/api_json.log records the raw path and the decoded route it resolved to, plus the parameters as a structured array.

path   : /api/v4/projects/1/repository/%66iles/test
route  : /api/:version/projects/:id/repository/files/:file_path
params : file= , file.path=/etc/passwd , file.size=1
status : 400

That disagreement between path and route is the parser differential, written down by the application itself.

The parameters are the better anchor though. On a legitimate accelerated upload, Workhorse injects the path itself, and it always arrives alongside an upload token parameter:

params : file.path=/opt/gitlab/embedded/service/gitlab-rails/public/uploads/tmp/commits/...
         file.gitlab-workhorse-upload=<signed token>
         file.name=upload , file.size=0

So the discriminator is a client-supplied path parameter with no accompanying upload token. Across 333 legitimate Rails events, 77 of them real accelerated uploads, that test never once fired where it should not have.

Reading the response

A 400 is not a failure. The server tells you exactly how far the read got, and the differences matter for scoping an incident.

Status Body Meaning
401 401 Unauthorized Blocked. You were already patched.
404 404 Project Not Found Reached Rails, but the project id was not resolvable to the caller. Says nothing about the file.
400 local file not present File did not exist. Enumeration.
400 branch is required File existed and was read. Nothing came back.
400 Invalid parameter: invalid %-encoding ( File content returned in the error body.
400 invalid byte sequence in UTF-8 Binary file read. Nothing came back.
500 500 Internal Server Error Read failed. In our lab this was always a permission denial, logged as Errno::EACCES.

Do not skip the 404 row. Three of our 22 attempts came back that way, because the commits endpoint resolves the project before it reaches the file handler, and a private or absent project ends the request early. It looks like a miss and tells you nothing either way.

The branch is required case looks like a failure and is not. We proved what it means by planting files with known contents and watching the error move:

file containing "branch=main"          -> 400 commit_message is required
file containing "commit_message=hello" -> 400 branch is required
empty file                             -> 400 branch is required
file that does not exist               -> 400 local file not present

The contents of the file became the parameters of the request. When the error changes because of what is inside the file, the file was read. Score that as successful access.

There is also a signal in the nginx log that needs no body parsing at all. Our error responses ran 35 to 81 bytes. The four that disclosed a configuration file ran 1708 each. That is four of the five spellings; we only ever asked the fifth for a file that did not exist. A 400 with an oversized body is data leaving the building.

What an attacker gets

Rotation advice generally tells you to rotate gitlab-secrets.json first. In the omnibus image we tested, the exploit could not read that file. Rails runs as the git user and the file is owned by root with mode 0600, so the request comes back 500.

What the git user can read is secrets.yml, mode 0644, and it holds the core Rails key material including the secret key base, the database key base, the one-time-password key base and the Active Record encryption keys.

The read of that file succeeded. Its contents did not come back in the response, because the disclosure channel only reflects bytes when the file contains a percent sign that is not followed by two hex digits, and that file does not. So it is exposed to a determined attacker rather than confirmed stolen, and your rotation decision should be scoped by the responses actually in your logs rather than by someone's assumed worst case.

The file that did disclose in our lab was gitlab.yml, which is configuration rather than secrets. Be precise about how much of it came back, because this is the sentence someone will use to scope an incident. The file is 31,548 bytes. What returned was roughly 1.6 KB of it, starting 223 bytes in, because everything before the first = was swallowed as a parameter name and the fragment ends at the first percent sign the parser choked on. A serious leak, and about five percent of the file.

The rules

Anchor on the parameter, never on the route spelling. A client has no legitimate reason to send this parameter. Workhorse injects it server side, behind nginx, so it never appears in a client request line.

Sigma, against the nginx access log. Detection body only, so add your own id, level and falsepositives before deploying.

logsource:
  category: webserver
  product: gitlab
detection:
  selection_endpoint:
    cs-uri-stem|contains: '/api/v4/projects/'
  selection_param:
    cs-uri-query|contains: 'file.path='
  condition: all of selection_*

Sigma, against the Rails API log at /var/log/gitlab/gitlab-rails/api_json.log, where the parameters arrive structured and can be matched on the key rather than on a substring.

logsource:
  product: gitlab
  service: api_json
detection:
  selection:
    params.key: 'file.path'
  filter_workhorse:
    params.key: 'file.gitlab-workhorse-upload'
    params|contains: '/public/uploads/tmp/'
  condition: selection and not filter_workhorse

The suppression needs both halves. Filtering on the Workhorse token alone is evadable, because the attacker owns the query string and can append a fake token and buy silence. We tried exactly that against the lab: the request still disclosed the same configuration file, and a token-only version of this rule went quiet. Requiring an uploads-directory path as well raises the bar, though it does not bolt the door: the condition matches anywhere in the parameter list rather than binding to the value of file.path. The network rule below catches the request either way, which is the better reason to run both. This probe was sent after the corpus was measured, so it sits outside the 22.

One portability warning. Some Sigma backends render contains as a regular expression, where the dot in file.path is a wildcard and will also match GitLab's legitimate file_path parameter. Check what your backend emits before trusting the false positive figures above.

Suricata. On the wire one rule carries the load. It uses sticky buffers, so it should load in Snort 3 as well, but we only tested it in Suricata.

alert http any any -> $HTTP_SERVERS any (msg:"CVE-2026-85706 GitLab repository upload endpoint with client-supplied file.path"; flow:to_server,established; http.method; content:"POST"; http.uri; content:"/api/v4/projects/"; nocase; content:"file.path="; nocase; distance:0; reference:cve,2026-85706; classtype:web-application-attack; sid:1000001; rev:1;)

That rule must be on one line to load, which is not a stylistic preference.

None of them filters on status, because a 401 means someone attacked a patched host and you still want to know. None enumerates a route, so the Terraform state endpoint patched in the same release should be covered too. We did not exercise that endpoint, so treat it as reasoning rather than as a measurement.

We got it wrong three times on the way here

Our first nginx rule anchored on /repository/, which felt safely generic. It missed the request that encoded the repository segment, one out of 22, and we only found that because we were counting. Anchoring on /api/v4/projects/ fixed it.

Our first Rails rule matched file.path as a substring of a flattened parameter string. That produced a false positive on a perfectly ordinary call, GET /repository/commits?ref_name=main&path=file.path, where the text appears as a parameter value. It only showed up because we had planted a handful of adversarial but legitimate requests in the corpus, including a file literally named file.path. Matching the parameter key instead of the string fixed it.

We got it wrong a third time, more embarrassingly. Our first Suricata rules were written across several lines for readability, which Suricata does not accept without continuations, so the engine rejected every one of them and loaded nothing. A rule file that silently loads zero rules looks exactly like a quiet network.

All three are the same mistake: writing something that looks right and never handing it to the thing that has to run it. We caught all three because we had traffic to measure against and an engine to feed them to.

Triage at fleet scale

The KEV entry does not only ask you to patch. It flags this one for forensic triage, which is a different job with a different shape. Patching is one action per host and you know when it is done. Triage is a question you have to put to every GitLab instance you own, and then answer it with evidence from each host.

So the last piece is a Velociraptor artifact that asks it. Four sources, and each one answers a different part of the question.

The first reads the VERSION file and decides whether the host sits in the affected range. We tested that against fourteen versions on both sides of all three fix boundaries, because an off-by-one there is the kind of bug that quietly tells you a vulnerable host is fine.

The second reads the nginx access log and scores every request that carried a client-supplied file path:

Risk Evidence
CRITICAL 400 with an oversized body. File content went back to the caller.
HIGH 400 and the file was read. Nothing came back.
MEDIUM 400 for a file that did not exist, 404 where the project did not resolve, or 500 for one the process could not open.
INFO 401. The attempt was blocked because the host was already patched.

The third reads the Rails API log, which is the one that tells you which file was asked for, alongside the correlation id you need to pull the rest of that request's story. The fourth aggregates that into a list of every file requested, by source, with the status codes each attempt returned. That last one is the artifact you actually hand to whoever has to decide what gets rotated.

The whole artifact is a few dozen lines of VQL, and rather than reprint it here it has gone to the Velociraptor Artifact Exchange as Linux.Detection.CVE202685706.GitLabFileRead. The pull request is open at the time of writing and carries the full source, the validation notes and the request spellings above.

Each row of its output carries the raw path beside the route GitLab resolved it to, so the mismatch is visible without reading the request.

We ran it on the two hosts themselves rather than on copies of their logs, with the default paths and nothing overridden. On the vulnerable box it read the VERSION file, called the host affected, scored four requests CRITICAL and named gitlab.yml as the file that went out the door. On the patched box every attempt came back INFO, which is the answer you want: somebody tried and the patch held.

GitLab rotates these logs daily and compresses them, so an attack from the day this hit the KEV catalog is already sitting in a .gz rather than in the file you are tailing. The default globs match the rotated copies and Velociraptor reads them without being told to decompress anything. We checked that instead of assuming it, by rotating the lab's logs and confirming the same evidence still came back, because a triage artifact that silently skips last week is worse than no artifact.

If you run self-managed GitLab

Patch to 19.1.8, 19.2.6 or 19.3.2. Then check the logs, because the KEV entry asks for forensic triage on top of the patch.

A non-destructive exposure check is one request. Ask for a file that cannot exist and read the answer. A vulnerable host returns 400 and local file not present because it reached the file handler without authenticating. A patched host returns 401 because authentication now runs first.

Shell. One request, no file read, safe to run against a host you own.

curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
  "https://gitlab.example.com/api/v4/projects/1/repository/%66iles/probe?file=&file.path=/does-not-exist&file.size=1"

Use a project id that exists and is visible on the target. Ours is 1, and against a host where that project is private or absent you get a 404 rather than the 400 or 401 that tells you anything.

Then search both logs for a client-supplied file.path on any /api/v4/projects/ route, and sort what you find by the response table above. Anything at 400 with an oversized body is confirmed disclosure. Anything at 400 saying a branch is required is a successful read. Anything at 401 is an attempt against a host that was already fixed.

Every rule here was loaded by the engine it targets, and measured against the corpus, before it was written down. Do the same to ours before you trust them.