Rapid7 Analysis: GitLab CI/CD Regex Integer Overflow (CVE-2026-97229)

Overview

On October 12, 2026, GitLab released 19.4.2, 19.3.5, and 19.2.8 to fix CVE-2026-97229, an integer overflow in its CI/CD regular-expression handling. The issue was reported through HackerOne by joaxcar. GitLab assigned it a CVSS score of 10.0 and described possible code execution by an unauthenticated user.

The advisory states that CVE-2026-97229 affects GitLab CE and EE from 19.2 before 19.2.8, 19.3 before 19.3.5, and 19.4 before 19.4.2. GitLab says all deployment types are affected unless stated otherwise. GitLab.com was patched before disclosure, and Dedicated customers do not need to act.

Rapid7 researchers developed an end-to-end PoC that reliably runs arbitrary commands as GitLab's git operating-system account on the official Linux amd64 gitlab/gitlab-ce:19.4.1-ce.0 image with ASLR enabled. GitLab's release notes do not report exploitation in the wild occurring at the time of disclosure.

This analysis covers one end-to-end route. It begins after an attacker has pushed both the malicious CI configuration and a specially named path to a public project; only the final CI lint request is unauthenticated. That prerequisite conflicts with GitLab's PR:N rating, and because the underlying report remains private, we do not yet know how the reported attack avoids it.

Technical analysis

At a high level, the chain crosses from attacker-controlled repository input into Onigmo pcode and finally native code execution.

Figure 1: From repository inputs to command execution.

CI rules pass repository paths to Onigmo

GitLab supports regular-expression matching over repository paths in rules:exists:regexp and rules:changes:regexp. One way to evaluate such an expression during static configuration processing is to place it on a conditional include:

include:
  - local: /benign.yml
    rules:
      - exists:
          regexp: "ATTACKER_PATTERN"

RegexpMatcher expands the expression, compiles it with Ruby's native regular-expression engine, and tests it against paths from the selected Git worktree:

# Exists#regexp_match?()
def regexp_match?(context)
  matcher = regexp_matcher(context)  matcher.validate_pattern_length! # [0] Source-length check

  Gitlab::SafeRequestStore.fetch(regexp_comparison_key(context, matcher.expanded_pattern)) do
    # ... top-level optimisation omitted
    matcher.match?(worktree_paths(context, false)) # [1] Repository paths
  end
end

# RegexpMatcher#match?()
def match?(paths)
  # ... path-count fail-open handling omitted
  pattern = compile_pattern # [2] Compile regexp
  deadline = current_monotonic_time + REGEXP_TOTAL_TIMEOUT_SECONDS

  paths.any? do |path|
    # ... total-budget check omitted
    pattern.match?(path) # [3] Native match
    # ... per-match timeout handling omitted
  end
end

By the time execution reaches [0], GitLab is checking only the length of the expanded source expression. The call at [1] supplies every filename in the selected commit, while compile_pattern at [2] ultimately invokes Regexp.new. Each iteration at [3] therefore crosses from CI configuration into native Onigmo with a repository path as the match subject.

Smuggling a large repeat through the checks

GitLab places several sensible limits around CI regular expressions. The interesting parts of its wrapper are shown below:

REPEAT_BOUND = /(?<!\\)(?:\\\\)*\{\s*(\d+)\s*(?:,\s*(\d+)?\s*)?\}/ # [4] Repeat scanner

def self.compile_regexp(pattern, timeout: nil)
  raise RegexpError, 'uses a subroutine call (\g), which is not allowed' if SUBROUTINE_CALL.match?(pattern) # [5] Subroutine-call check
  reject_large_repeats!(pattern) # [6] Repeat-bound check

  alternation_free = pattern.gsub(ESCAPE_OR_ALTERNATION) { |token| token == '|' ? '\\|' : token }

  probe =
    begin
      Regexp.new(alternation_free)
    rescue RegexpError => e
      # ... error-message rewriting omitted
    end

  check_compiled_size!(probe) # [7] Probe size check
  check_compiled_size!(Regexp.new(pattern, timeout: timeout)) # [8] Final size check
end

The source-length check occurs before this function. The subroutine-call check at [5] and the repeat-bound check at [6] defend against structural growth, but the scanner at [4] recognizes only contiguous digits. Compilation then happens twice: [7] checks an alternation-free probe, and [8] checks the requested expression and attaches the 50-millisecond timeout. Either object is rejected if it exceeds one megabyte.

The PoC hides its quantifier bound from the scanner at [4] via an inline comment: {5(?#)7805}

(?#) is an inline regex comment. Onigmo ignores it and reads the bound as 57,805. GitLab's scanner expects contiguous digits, so it does not see a bound above 1,000.

That bypass admits the large repeat, but the checked expression is still only 986 bytes. The six [[:word:]] classes expand when matching begins against a repository path containing valid four-byte UTF-8 characters. Ruby recompiles the same source for UTF-8, each POSIX word class expands into a large Unicode range table, and GitLab never checks the size

[…]
Content was trimmed to protect the source. Please visit the original article for the full text.

This article has been indexed from Rapid7 Cybersecurity Blog

Read the original article: