Like leaving a loaded gun with a hair trigger — anyone who bumps the link fires it with the victim's own hands
CVE-2026-19650 is a cross-site request forgery in GitLab CE/EE's GraphQL multiplex query handler affecting all self-managed versions from 18.2 through 18.11.10, 19.0 through 19.0.7, 19.1 through 19.1.5, and 19.2 through 19.2.3. The IntroducedTracer component stores the parsed query document in a shared instance variable (@original_query_document) that each query in a multiplex batch overwrites. An attacker crafts a multiplex GET request where slot 0 is a benign read-only query (which passes GET-method validation) and slot 1 is a state-changing mutation tagged with @gl_introduced. By the time execution runs, the stored document belongs to the last-parsed query — the mutation — so slot 0's validated-safe context executes slot 1's mutation. The victim only needs to click a link or load an attacker-controlled page while authenticated to their GitLab instance. Successful exploitation lets the attacker create, update, or delete repositories, issues, merge requests, and other GitLab resources using the victim's session and permissions.
GitLab's vendor severity of HIGH / 7.1 is accurate and well-calibrated. The CVSS vector correctly captures UI:R (user interaction required), PR:N (attacker needs no GitLab account), I:H (high integrity impact from arbitrary mutations), C:N (no data exfiltration path from this bug alone), and A:L (limited availability impact via resource deletion). This is not a zero-click flaw — the companion CVE-2026-19478 (CVSS 9.4) is the one that's unauthenticated and zero-interaction. CVE-2026-19650 is a CSRF that rides the same underlying IntroducedTracer race condition but requires social engineering to land. The CSRF friction is already baked into the 7.1 score, and the supply-chain role of GitLab (CI/CD, source code, pipeline definitions) keeps the floor at HIGH. GitLab.com and GitLab Dedicated are already patched — only self-managed deployments remain exposed.
4 steps from start to impact.
Craft multiplex GET payload
/-/graphql?_method=GET containing a multiplex batch. Slot 0 holds a benign query { currentUser { id } } that passes GET-method mutation rejection. Slot 1 holds the desired mutation (e.g., mutation { destroyProject(input: { id: "gid://gitlab/Project/42" }) { ... } }) decorated with @gl_introduced. No special tooling is required — a browser-renderable URL or <img> tag suffices. The poc_cve_2026_19650.py script in the dinosn/gitlab-cve-2026-19478-lab repo demonstrates this swap non-destructively.- Attacker must know (or guess) the target GitLab instance hostname
- Target instance must be running an unpatched self-managed GitLab CE/EE version
- Self-managed GitLab URLs are often internal-only, limiting attacker reachability
- Instance version fingerprinting is needed to confirm exploitability
poc_cve_2026_19650.py safe probe confirms the swap behaviorDeliver the link to an authenticated victim
<img src="..."> on a page the victim is likely to visit. The victim must have an active, authenticated session (session cookie) with the target GitLab instance at the time they load the URL.- Victim must be authenticated to the target GitLab instance
- Victim must click the link or load the attacker-controlled page
- Attacker must reach the victim through a social-engineering channel
- Modern email gateways may strip or warn on unfamiliar URLs
- Security-aware users may not click unsolicited GitLab links
- SameSite cookie defaults in modern browsers can block cross-origin cookie attachment, depending on GitLab's cookie configuration
Mutation executes under victim's session
IntroducedTracer overwrites the stored query document with slot 1's mutation during parsing. Slot 0's execution context now runs the mutation from slot 1 using the victim's CSRF token and session cookie. The mutation executes with whatever permissions the victim holds — a project maintainer can delete repos; an admin can modify instance-level settings.- GitLab instance is running a vulnerable version (pre-18.11.11 / 19.0.8 / 19.1.6 / 19.2.4)
- The GraphQL endpoint accepts the multiplex GET with the query-swap payload
- GitLab's Rails CSRF token mechanism should block cross-origin POST mutations, but GET requests bypass this — the whole point of the bug
- If the victim has a low-privilege Guest role, the blast radius is minimal
cvemapping/cve-labs repo cover this patternSupply-chain or data-integrity impact realized
.gitlab-ci.yml changes (Developer+), merge-request approval manipulation, or issue/snippet/wiki tampering. Against an admin target, instance-level mutations may be reachable — including user creation, runner registration, or integration token generation. In a CI/CD-centric deployment, modifying pipeline YAML is a supply-chain compromise vector: the next pipeline run executes attacker-controlled code.- Victim has sufficient GitLab permissions for the target mutation
- The targeted project/resource exists and is accessible to the victim
- Attacker must know specific project IDs or resource GIDs to target — blind exploitation is noisy and speculative
- Destructive actions (project deletion) trigger GitLab notification emails, raising immediate visibility
IntroducedTracer state per multiplex slot, preventing the query-swap. Per noisgate remediation SLA for HIGH, deploy within 180 days. However, given the companion CVE-2026-19478 is actively exploited and shares the same patch, you should be patching for that reason within days regardless./-/graphql that contain the _method parameter or multiplex batch syntax. Example: if ($request_method = GET) { ... return 403; } on the GraphQL endpoint. This breaks the specific attack vector without a GitLab upgrade. Test carefully — some GitLab UI features use GraphQL GET for read operations. Deploy within the noisgate mitigation SLA of 30 days for HIGH.SameSite=Lax to SameSite=Strict at the reverse proxy or Rails configuration level. This prevents the browser from attaching session cookies on any cross-site navigation, blocking the CSRF delivery entirely. Caution: this will break SSO/OAuth flows that rely on cross-site redirects — test in staging first. Deploy within 30 days.cvemapping/cve-labs for the multiplex swap pattern. Deploy immediately as a detection-only measure.- Standard CSRF tokens — GitLab's Rails CSRF token mechanism protects POST/PUT/DELETE requests but explicitly allows GET requests through, which is the exact method this vulnerability exploits. The bug bypasses the mutation-over-GET rejection, not the CSRF token system.
- Network-level IP allowlisting on the GitLab instance — The CSRF payload executes in the victim's browser, which already has legitimate network access to the GitLab instance. Restricting source IPs at the firewall does not help because the request originates from an authorized client.
- Disabling public project access — The CSRF does not depend on public projects. It executes mutations under the victim's authenticated session against any resource the victim can access, including private projects.
The supporting signals.
| In-the-wild exploitation | Not observed. Not listed on CISA KEV. No GreyNoise tags, no Shadowserver scanning reports, no campaign attribution as of 2026-10-08. The sibling CVE-2026-19478 (CVSS 9.4) *is* being actively exploited — conflation risk is high. |
|---|---|
| Proof-of-concept | Public, safe/detection-only. dinosn/gitlab-cve-2026-19478-lab on GitHub includes poc_cve_2026_19650.py — a non-destructive script that proves the multiplex query-swap on vulnerable instances (slot 0 returns slot 1's data). Derived from public fix commit d2ea4b971a98. No weaponized exploit with destructive mutations has been published. Not indexed on pocindex.io as of 2026-10-08. |
| EPSS | 0.00615 (~93rd percentile). Low probability of exploitation in the next 30 days, consistent with CSRF bugs that require social engineering. |
| CISA KEV status | Not listed. The companion CVE-2026-19478 may be added given active exploitation, but CVE-2026-19650 is not on the catalog. |
| CVSS vector interpretation | CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L — Network-reachable, low complexity, no privileges required, user interaction required. No confidentiality impact; high integrity impact (mutations); low availability impact (deletions). The UI:R is the key differentiator from the companion 9.4 flaw. |
| Affected version ranges | GitLab CE/EE 18.2 – 18.11.10, 19.0.0 – 19.0.7, 19.1.0 – 19.1.5, 19.2.0 – 19.2.3. Introduced in 18.2 with the @gl_introduced directive / IntroducedTracer feature. |
| Fixed versions | 18.11.11, 19.0.8, 19.1.6, 19.2.4. GitLab.com and GitLab Dedicated were patched server-side on 2026-08-17. Only self-managed instances require action. |
| Internet exposure | Censys reports 86,000+ internet-facing GitLab self-managed instances (208K web properties). CyCognito estimates 20,000+ on standard web ports. ZoomEye returned 1.3M matching assets for app="GitLab". Attack surface for CSRF is any instance reachable by the victim's browser — including internal instances accessed via VPN/corporate network. |
| Disclosure date | 2026-08-17 (out-of-band patch release). Technical details embargoed for ~90 days (mid-November 2026). |
| Reporting researcher | kreep and diablosec via GitLab's HackerOne bug bounty program. Bounty: $5,530. |
Sources.
- GitLab Critical Patch Release 19.2.4 (vendor advisory)
- Ox Security — GitLab GraphQL CVEs: CVE-2026-19478 & CVE-2026-19650 (technical deep-dive)
- The Hacker News — Critical GitLab GraphQL Flaw Could Let Unauthenticated Attackers Delete Public Projects
- GBHackers — GitLab Patches Multiple Security Flaws in CE and EE With 19.2.4 Update
- dinosn/gitlab-cve-2026-19478-lab — Reproducible PoC lab (GitHub)
- cvemapping/cve-labs — Detection analytics for CVE-2026-19650 (GitHub)
- CyberNews — Thousands of vulnerable GitLab servers exposed online
- DEV Community — Two GitLab GraphQL Vulnerabilities analysis
Why this verdict
- CSRF friction is real but already priced in. The
UI:Rrequirement means an attacker must socially engineer a GitLab user into clicking a link or loading a page. This is not trivial at scale, but the CVSS 7.1 already accounts for this —UI:Rdrops the score from what would otherwise be ~9.1. No further downgrade is warranted for a friction factor already in the vector. - Self-managed only, but surface is large. GitLab.com and Dedicated are patched, limiting exposure to self-managed instances. However, 86,000+ internet-facing and an unknown-but-larger number of internal-network self-managed instances remain in scope. The CSRF payload works regardless of whether the instance is internet-facing — it executes in the victim's browser context, which already has network access to the GitLab instance.
- No active exploitation keeps this from upgrading. Unlike the companion CVE-2026-19478 (CVSS 9.4, actively exploited), CVE-2026-19650 has no KEV listing, no campaign reports, and an EPSS of 0.00615. The public PoC is detection-only, not weaponized. There is no urgency signal beyond the structural risk.
- Role multiplier: GitLab is canonically a CI/CD and supply-chain component. GitLab self-managed instances serve as source code repositories, CI/CD pipeline engines, container registries, and artifact stores. This is explicitly in the high-value-role catalog (CI/CD + artifact stores). ≥90% of GitLab self-managed installs occupy this role by definition — it IS the CI/CD platform. If the CSRF victim is a Maintainer or Admin, the attacker can modify
.gitlab-ci.ymlpipeline definitions, inject malicious code into repositories, or delete projects — achieving supply-chain-scale blast radius. This floors the verdict at HIGH regardless of CSRF friction. - SameSite cookie behavior is a partial brake but not a full stop. GitLab sets
SameSite=Laxon session cookies by default, which blocks cross-site POST but *allows* cross-site GET requests — which is exactly the vector this CVE exploits. The CSRF protection that should stop mutations over GET is the exact mechanism this bug bypasses. SameSite=Lax does not mitigate this flaw.
Why not higher?
This is not CRITICAL because the attack requires user interaction — a human must click a link or load a page while authenticated. The attacker cannot fire this at scale against arbitrary GitLab instances without a social-engineering delivery mechanism for each target. The companion CVE-2026-19478 is CRITICAL precisely because it needs no interaction. Additionally, there is no active exploitation, no KEV listing, and the EPSS score is in the low single digits. The structural ceiling for a CSRF with UI:R and C:N is firmly in the HIGH band.
Why not lower?
Downgrading below HIGH would require ignoring that GitLab is canonically a supply-chain-critical platform where successful exploitation against a privileged user yields pipeline injection, repository tampering, and project destruction. The CSRF friction (social engineering) is already captured in the 7.1 score via UI:R. The public PoC demonstrates the swap mechanism works reliably — the only gap is weaponization with destructive mutations, which is trivial to add. The 86,000+ internet-facing instances and the browser-context attack model (works against internal instances too) ensure the reachable population is substantial.
Crowdsourced verification payload.
Run this script from any workstation that can reach the target GitLab instance. It sends a safe, non-destructive multiplex GraphQL GET request to detect the IntroducedTracer query-swap behavior. No authentication or elevated privileges required. Invoke with: python3 check_cve_2026_19650.py https://gitlab.example.com
#!/usr/bin/env python3
"""CVE-2026-19650 detection — safe, non-destructive multiplex query-swap check.
Usage: python3 check_cve_2026_19650.py <GITLAB_BASE_URL>
Exit codes: 1 = VULNERABLE, 0 = PATCHED, 2 = UNKNOWN
"""
import sys
import json
import urllib.request
import urllib.parse
import ssl
def main():
if len(sys.argv) < 2:
print("Usage: python3 check_cve_2026_19650.py <GITLAB_BASE_URL>")
sys.exit(2)
base_url = sys.argv[1].rstrip("/")
# Two safe read-only queries with distinct return shapes.
# On vulnerable instances, slot 0 returns slot 1's data (query-swap).
query_a = '{"query": "query { currentUser { __typename } }"}'
query_b = '{"query": "query { metadata { version } }"}'
multiplex_payload = f"[{query_a}, {query_b}]"
graphql_url = f"{base_url}/-/graphql"
params = urllib.parse.urlencode({"_method": "GET", "operations": multiplex_payload})
full_url = f"{graphql_url}?{params}"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(full_url, method="GET")
req.add_header("Accept", "application/json")
req.add_header("User-Agent", "noisgate-cve-2026-19650-check/1.0")
try:
with urllib.request.urlopen(req, timeout=15, context=ctx) as resp:
body = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code == 400 or e.code == 422:
# Multiplex GET rejected entirely — likely patched or hardened
print("PATCHED — server rejected multiplex GET (HTTP {})".format(e.code))
sys.exit(0)
print(f"UNKNOWN — HTTP {e.code}: {e.reason}")
sys.exit(2)
except Exception as e:
print(f"UNKNOWN — connection error: {e}")
sys.exit(2)
if not isinstance(body, list) or len(body) < 2:
print("UNKNOWN — unexpected response shape (not a multiplex array)")
sys.exit(2)
slot0 = body[0].get("data", {})
slot1 = body[1].get("data", {})
# Check for query-swap: slot 0 should have currentUser, slot 1 should have metadata.
# If slot 0 contains metadata keys, the swap occurred.
slot0_has_metadata = "metadata" in slot0 or "version" in slot0
slot0_has_user = "currentUser" in slot0
slot1_has_metadata = "metadata" in slot1
if slot0_has_metadata and not slot0_has_user:
print("VULNERABLE — multiplex query-swap confirmed (slot 0 returned slot 1 data)")
print(f" slot 0 data: {json.dumps(slot0)}")
print(f" slot 1 data: {json.dumps(slot1)}")
sys.exit(1)
elif slot0_has_user and slot1_has_metadata:
print("PATCHED — slots are properly isolated")
print(f" slot 0 data: {json.dumps(slot0)}")
print(f" slot 1 data: {json.dumps(slot1)}")
sys.exit(0)
else:
print("UNKNOWN — could not determine swap behavior")
print(f" slot 0 data: {json.dumps(slot0)}")
print(f" slot 1 data: {json.dumps(slot1)}")
sys.exit(2)
if __name__ == "__main__":
main()