Critical CVE Response

GitLab CVE-2026-97229: Read the Ruby Version, Not the GitLab One

Dark cyberpunk illustration of an industrial machine housing at night with a clean steel plate bolted over most of its face, while a cracked gear glows orange inside the opening the plate does not cover, cyan light tracing the seam between them.

Alpine Linux upgraded its GitLab server twice and was still exploitable after the second one. The project patched on October 13, patched again on October 22, and discovered on October 26 that the flaw was live on the box the whole time. On October 27 it closed the server to everyone outside the project and started a rebuild from scratch.

CISA added CVE-2026-97229 to the Known Exploited Vulnerabilities catalog on October 29 with a due date of November 1. That is today. The entry describes an integer overflow in GitLab's CI/CD regular expression compiler that lets an unauthenticated attacker run code on the server. GitLab scored it 10.0 and shipped the fix in 19.2.8, 19.3.5 and 19.4.2 on October 12.

Relevance verdict. GitLab.com and GitLab Dedicated were patched before disclosure, so hosted customers have nothing to do here. If you run self-managed GitLab from the Omnibus packages or the official container images and you are on a fixed release, you are covered. The instances still exposed are self-compiled builds, custom containers, and distribution packages that link the operating system's Ruby. Alpine was in that group, and so is a fair share of the GitLab servers small teams stood up on a VM years ago and have upgraded in place ever since. There is also a wider audience here that runs no GitLab at all, which the fourth section covers.

The belief that failed at Alpine

The belief is reasonable. The advisory names fixed versions, you install a fixed version, the ticket closes. Alpine's own disclosure is the clearest public counter-example, and it reads better as a sequence than as a summary.

  • October 12. GitLab publishes the fix in 19.2.8, 19.3.5 and 19.4.2.
  • October 13. Alpine upgrades to 19.3.4 rather than 19.3.5, which the project reports as its own mistake. Rapid7 publishes a lab proof of concept the same day.
  • October 20 and 21. A newly registered account submits crafted CI configurations. Worker processes crash eight times, four of those after the attacker got partway through the chain. Users see 502 errors and failing CI jobs.
  • October 22. Alpine upgrades to 19.3.6, the release that also carries the fix for a second overflow in the same engine.
  • October 23. The attacker account is blocked. Ruby publishes its own fixed releases.
  • October 25 and 26. The attacker returns and fails. On the 26th the team finds the instance still vulnerable.
  • October 27 and 28. Access is cut to developers and build servers, the server is rebuilt, every password is reset and GitLab's secret keys are replaced.
  • October 29. GitLab reopens on 19.3.7, running Ruby 3.3.13.

Alpine's explanation is one sentence and it is the reason this post exists. "The fix isn't in GitLab's own code but in a patch to Ruby, which GitLab applies only in its official packages." Their image used standard upstream Ruby, and a GitLab upgrade does not touch a Ruby that GitLab did not install.

The bug belongs to Ruby, and Ruby published the map

The overflow lives in Onigmo, the regular expression engine Ruby bundles. Rapid7's analysis places it in compile_quantifier_node() in regcomp.c. When the compiler expands a quantified body it multiplies the compiled size by the repeat bound and compares the product against a limit using signed arithmetic. A large enough product wraps negative, clears the check, and the compiler goes ahead and emits every copy.

GitLab wrapped that call in three guards and the research walked past all three. The source-length check ran against the pattern before expansion. The repeat-bound scanner only recognised contiguous digits, so a bound split by an inline regex comment read as a smaller number than it was. And Ruby recompiles a pattern for the subject's encoding at match time, expanding POSIX character classes into large tables, with no size check on that recompiled program. Rapid7 reaches the whole thing through GET /api/v4/projects/<project>/ci/lint and runs commands as the git account on the stock gitlab/gitlab-ce:19.4.1-ce.0 image with ASLR on.

Ruby's advisory of October 23 is the document to keep, because it maps four GitLab CVEs onto three Ruby bugs and gives the version floors that decide the question.

  • CVE-2026-89078, a double free when parsing fails after an alternation. Ruby change #13332.
  • CVE-2026-93577 and CVE-2026-97229, the same Ruby bug twice: integer overflow in the size of a compiled pattern. Change #19020, found by Johan Carlsson through GitLab's bug bounty.
  • CVE-2026-110264, overflow of internal IDs in a pattern with many repeats. Change #16801. GitLab shipped it on October 21 in 19.5.1, 19.4.3, 19.3.6, 19.2.9, 19.1.9, 19.0.10 and 18.11.13, with an affected range reaching back to 7.0 and a 10.0 of its own.

Ruby 4.0.9, 3.4.12 and 3.3.13 carry all three. Those releases landed on October 23, eleven days after GitLab's first patch. That eleven-day gap is the hole Alpine fell into, and any instance running its own Ruby had the same hole.

# Ask the instance which Ruby it runs. Three commands, three different answers.
/opt/gitlab/embedded/bin/ruby -v     # Omnibus: GitLab's own, patched with the release
gitlab-rake gitlab:env:info | grep -iE 'Ruby Version|GitLab Version'
ruby -v                              # the OS Ruby, which a self-built install may use

# Patched Ruby floors (ruby-lang.org, 2026-10-23)
#   4.0.9    3.4.12    3.3.13
# Patched GitLab floors for CVE-2026-110264, the later of the two overflows
#   19.5.1   19.4.3   19.3.6   19.2.9   19.1.9   19.0.10   18.11.13

# Both numbers have to clear their floor. One out of two is the state
# Alpine was in between October 22 and October 29.

The crash signature only finds the attempts that failed

Rapid7 documented a usable signature, and GitLab shipped two detections of its own with the October 21 release, named for a native worker crash in Onigmo and for a burst of Org mode render errors. On a self-managed instance the workhorse log sees it first.

# Multi-second CI lint requests that end in a gateway failure
grep -E 'ci/lint' /var/log/gitlab/gitlab-workhorse/current \
  | grep -E '"status":502|badgateway: failed to receive response: EOF'

# A Puma worker that died in the same second as one of those requests
grep -iE 'worker|reaped|booting' /var/log/gitlab/puma/current | tail -50

# Any saved backtrace naming the matcher that compiles the pattern
grep -rl 'regexp_matcher\.rb' /var/log/gitlab/gitlab-rails/ 2>/dev/null

# Accounts registered shortly before the first crash, which is the thread
# Alpine pulled. Widen the window past your log retention if you have it.
gitlab-rails runner 'User.where("created_at > ?", 21.days.ago).
  order(:created_at).each { |u| puts [u.created_at, u.username, u.email].join("  ") }'

Now read that signature for what it actually proves. Eight crashes are eight failures. Alpine's assessment of a successful attempt is the line to carry into your own write-up: "A successful exploit would leave no crash and no trace in the web logs." The exploit needs roughly 2 GiB in the worker and offsets matched to the exact build, which is why Alpine read repeated crashing as an attacker tuning against their packaging rather than as background noise. A quiet log is the ambiguous result here.

Alpine did the honest version of the write-up. Timing probes showed no delays, no unexpected files, keys, hooks or processes turned up, and no private data came back in any request they could still see. They also had no outbound connection logging, their provider kept none either, some background job logs and the old container filesystem were gone, and their retention was fourteen days. With the evidence that would settle it already expired, they treated the server as compromised and rebuilt. That is the correct call, and the logging gap is the cheapest thing on this page to fix before the next one.

Your Ruby estate is wider than your GitLab

This is the part that outlives the KEV date. Ruby's advisory states the condition precisely: an application is affected when it compiles a regular expression from a string an attacker controls, through Regexp.new, Regexp.union, Regexp.compile, or interpolation into a literal. Applications that only compile patterns their developers wrote are fine. GitLab qualified because a CI configuration is user input that becomes a pattern, and a search box, a log parser or a tenant-defined routing rule qualifies the same way.

Two mitigations will come up in the meeting and neither works. Regexp.timeout gives no protection against these bugs, and an input length limit is unreliable because some triggering patterns are short. Ruby has also not requested CVE IDs for these fixes, so a scanner keyed on identifiers reports a clean Ruby while the engine underneath is the vulnerable one. Version floors are the only check that answers.

# Every Ruby interpreter on the host, not only the one on PATH
for r in $(find / -xdev -type f -name 'ruby*' -perm -u+x 2>/dev/null); do
  printf '%s\t%s\n' "$r" "$("$r" -v 2>/dev/null | head -1)"
done | sort -u

# Which of your own applications compile a pattern from input
grep -rnE 'Regexp\.(new|union|compile)' --include='*.rb' \
     /srv /opt /var/www 2>/dev/null | head -40

# Interpolation into a literal is the one people forget
grep -rnE '/[^/ ]*#\{[^}]+\}[^/ ]*/' --include='*.rb' \
     /srv /opt /var/www 2>/dev/null | head -20

Read the Ruby version off every GitLab host today

The November 1 date binds federal civilian agencies and the Alpine timeline is the argument for everyone else. Run gitlab-rake gitlab:env:info on each self-managed instance and write down two numbers, the GitLab release and the Ruby it reports, then check both against the floors above. If the instance is self-built or runs a custom image, the Ruby belongs to you and no GitLab release will raise it.

Then work the workhorse log for the 502 pattern. If you find it, rotate in Alpine's order rather than the obvious one: runner tokens and CI/CD variables first, then webhook, integration and push mirror credentials, then personal access tokens, then user passwords and two-factor enrolment. The server is recoverable in an afternoon. The pipeline credentials it held reach every system your jobs deploy to, and those are the ones that keep working after you rebuild the host.

Red Hound builds and tests incident response playbooks for this exact shape of problem, where the vendor's patch and the actual fix live in different places. The scoping after a build server is touched runs through pipeline secrets rather than through the host, and that is the part most teams size wrong.

Need an incident response plan before the next build server is touched?

We help organizations build and test incident response playbooks, including the pipeline-credential scoping that a compromised build server forces. Book a session with our team.