
GitLab's Two 9.9 Regex RCE Flaws: A CI/CD Maintainer's Patch and Verify Playbook
Introduction
GitLab patched two critical regex-engine flaws — both scored 9.9, both allowing arbitrary code execution — and this post is the maintainer's playbook for the weeks that follow: what the public record actually confirms, which components run the vulnerable code path, how to patch and then verify the running revision instead of the version string, and why a credential-rotation event is the right follow-up on any self-managed instance that was reachable while the bug was open.
On 12 and 13 October, three outlets — TechJuice, CyberSecurityNews, and BleepingComputer — ran the same story. My feed stamps the TechJuice piece at 2026-10-13T04:44:06Z and the BleepingComputer one at 2026-10-12T13:09:09Z, so this was a roughly 36-hour cluster of coverage rather than a single disclosure post.
The tier concerns me more than the CVE count. A code-execution bug on the Rails/Sidekiq tier is not "a bug in a web app." That process holds the database credentials, the db_key_base and secret_key_base material, every encrypted CI/CD variable it can decrypt, every project and group access token, runner authentication tokens, deploy keys, and network reach to Gitaly, Redis, Postgres, and often your runners. Regex is the doorway. The room behind it is your entire CI/CD secret inventory.
So my position, and the spine of this post: patching is the easy half. If your instance was reachable by anyone untrusted while a 9.9 RCE sat open, the correct response is a credential-rotation event, not a version bump and a shrug.
What the GitLab 9.9 regex RCE advisories confirm — and what they leave open
Sourcing contract, up front: the only sources worth verifying against are GitLab's own security release post and the GitLab advisory database. The three articles above are headline-level. They confirm a fix exists and roughly how bad it is; they do not give you a version range to pin to. Everything in this section comes from those snippets, which is why the section is deliberately thin. I would rather write a short confirmed list than pad it with confident-sounding guesses.
Confirmed from the reporting
- GitLab patched two vulnerabilities, not one.
- Both are rated 9.9 — the top band on the CVSS v3 scale.
- The affected component is described as a regex engine.
- Impact is arbitrary code execution.
- Self-managed instances and CI/CD pipeline security are in scope for the reporting.
- The reporting clustered around 12–13 October.
Notably absent: any claim about whether GitLab.com was exposed. The snippets do not establish it either way, and I am not going to assume SaaS customers are safe because the headline talks about self-managed installs.
Unknown at the time of writing
- CVE identifiers — none of the three snippets names one.
- Exact affected version ranges and the fixed version for each supported line.
- Whether the two flaws share one root cause or are two independent bugs that landed in the same release.
- Whether exploitation was observed in the wild.
- Whether GitLab.com was affected, patched ahead of the public release, or never vulnerable.
- Whether the fix was backported to older supported lines or only the current one.
I have written this post so it stays useful once those facts land. Where I would normally print a version number, you will see a <fixed-version> placeholder. Substitute the advisory's numbers. If you are reading this after a backport table has been added, use that table, not my examples.
Do not copy affected version ranges out of a news article. Take them from GitLab's advisory page. A range that is wrong in the safe direction gives you a false sense of security; wrong in the other direction costs you a weekend maintenance window you did not need.
Why a regex engine becomes an RCE surface in GitLab
Everything in this section is inference. The public details do not say which engine, which language, or which request path. I am reasoning from how GitLab is built, and flagging that plainly.
The first problem is that "regex engine" in a GitLab install is ambiguous. Pattern matching happens in at least four places:
- Ruby, which ships Onigmo as the backing engine for
Regexp. - Go services — Gitaly and gitlab-shell — where the regex semantics are RE2's, and where there is no backtracking at all.
- The frontend, where JavaScript's engine runs patterns the server sent it.
- Advanced search, where Elasticsearch runs regexp queries against indexed content.
Which one the advisory means changes the exposure model completely. A RE2 bug is almost certainly not memory-safety, because RE2 does not have the catastrophic backtracking surface. An Onigmo bug plausibly is. Figuring out which engine is meant is the first job after the advisory lands, before any commit-diff reading.
Mechanism 1 — memory safety in a native regex engine
The classic path: a C/C++ engine — Onigmo/Oniguruma-class, or a PCRE-family library — sits behind a Ruby or Go binding. A crafted pattern is compiled, the engine overflows somewhere in its parsing or matching state machine, and whoever supplied the pattern controls the process. The interesting question is not the overflow; it is which request paths let an unauthenticated or low-privilege user supply a pattern. That is the difference between a 9.9 that requires a login and one that does not.
Mechanism 2 — regex provenance across a trust boundary
Here the engine is fine and the input validation is not. A pattern is authored by one party and compiled or reused by a more privileged one: a project webhook pattern, a push rule, a search or regexp filter, an MR approval rule, a runner configuration. If a lower-privileged actor can write the pattern and a higher-privileged process evaluates it, you have a trust-boundary bug. My hypothesis — untested, and I would want the patch diff before committing to it — is that this mechanism best explains a 9.9 reachable from ordinary authenticated use rather than from a pre-auth request path.
Mechanism 3 — regex as a step toward a sink
The pattern is interpolated into an eval-shaped or command-shaped sink downstream: a Ruby string operation, a shell invocation inside a repository importer or a migration task. Matching becomes execution because the match result flows into a sink that does not expect user data. Of the three, this is the least likely for a 9.9 — that class of bug usually gets caught earlier and scored lower. When the patch diff is readable, it will say which one this is. A bounds check points at mechanism 1, a validation change at mechanism 2, and a quoting or argument-array change at mechanism 3.
Version inventory: which GitLab components run the vulnerable regex path
"Upgrade GitLab" is not one action. It is a bundle of actions across Rails/Puma, Sidekiq, Gitaly, Workhorse, and gitlab-shell, and on a zero-downtime topology the order matters. Before you touch anything, work out which of those components carry the pattern-matching role in your deployment, because that sets your restart scope.
Commands that answer the GitLab version question
Run these on a representative node of each deployment type you operate. Output is truncated, with placeholders where the advisory's numbers belong.
# Omnibus / Linux package install
sudo gitlab-rake gitlab:env:info | head -12
## GitLab information
## Version: <version>-ee
## Revision: <revision>
## Directory: /opt/gitlab/embedded/service/gitlab-rails
cat /opt/gitlab/embedded/service/gitlab-rails/VERSION
## <version>-ee
## API-reported version — works for every deployment type
curl -sS --header "PRIVATE-TOKEN: $TOKEN" https://gitlab.example.com/api/v4/version
## {"version":"<version>-ee","revision":"<revision>"}
## Helm / chart installs: read the image, not the chart version
kubectl -n gitlab get deploy -l app=webservice -o jsonpath='{.items[*].spec.template.spec.containers[*].image}'
## registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:<tag>
## Container installs: the digest is the identity, the tag is a label
docker inspect --format '{{index .RepoDigests 0}}' <image>
## <image>@sha256:<digest>Two notes on reading that output. First, the API-reported version can be ahead of the on-disk VERSION file if a reconfigure half-finished — trust the file when they disagree. Second, on chart installs the webservice image is not the only one; Gitaly and Sidekiq have their own. A version check that reads only gitlab-webservice-ee is incomplete.
Restart scope per GitLab component
| Component | Regex role it plausibly plays | Restart expected? |
|---|---|---|
| Rails / Puma | Request-path matching in Ruby | Yes — the process holds old code until restarted |
| Sidekiq | Background workers evaluating patterns | Yes — restart even if the web tier was already cycled |
| Gitaly | Go-side matching (RE2 semantics) | Yes — upgrade before Rails on zero-downtime topologies |
| gitlab-shell | Go-side matching on SSH paths | Yes |
| Workhorse | Routing and proxying; pattern matching is limited | Likely restart with the package, confirm |
| Elasticsearch indexer | Advanced-search regexp queries | Only if advanced search is enabled |
Treat that column as expected, then confirm against the advisory. The row I would not skip reading about is Gitaly: on a zero-downtime upgrade the documented ordering exists precisely because Rails can start talking to a newer Gitaly before you have finished, and the reverse can break Gitaly calls.
GitLab patch-and-verify playbook: Omnibus, containers, and Helm
Omnibus / Linux package upgrade
# 1. Pin the fixed version from the advisory — do not install "newest"
apt-get update
apt-get install -y gitlab-ee=<fixed-version>
## 2. Reconfigure, then confirm the running revision
gitlab-ctl reconfigure
cat /opt/gitlab/embedded/service/gitlab-rails/VERSION
## <fixed-version>-ee
gitlab-ctl status
## run: gitaly: (pid 1234) 12s; run: gitlab-workhorse: (pid 1240) 11s;
## run: puma: (pid 1251) 11s; run: sidekiq: (pid 1260) 10s; ...The pin matters. Install the newest available package and an unattended upgrade can run later the same day, landing you on a build from a different release line — sometimes an older one, if your repository configuration points at a stale mirror. Pin it, and the version you verified is the version that stays.
Containers, Helm, and source installs
For containers, pull by digest and check that digest against GitLab's published image instead of pulling a tag and hoping. A tag is mutable; the digest is not, and the digest is what goes in your audit record.
For Helm, set the image tag explicitly. The chart's Gitaly and Sidekiq workloads may run as separate pods with separate images, so setting only gitlab.webservice.image.tag leaves parts of the installation on the old build.
For source installs, check out the fixed tag and confirm the bundled gems and native extensions were rebuilt. This is the step people skip. The compiled regex engine on disk is a binary artifact; updating Ruby source without rebuilding native extensions leaves the old compiled object in place, and your version file will happily report the new tag.
Verify the fix, not the version string
A version bump proves that a file changed. That is all it proves. Four checks that mean something:
- Verify package or image integrity.
dpkg -V gitlab-eeorrpm -V gitlab-eefor packages; compare the pulled digest against the published one for containers. - Confirm the running revision file matches the advisory's fixed revision, not just the version string. The revision is the commit; the version is the marketing label.
- Confirm the worker and sidecar processes were actually cycled. A
gitlab-ctl statusuptime in the seconds is what you want. An uptime of days on Sidekiq after an upgrade means the old code is still resident. - If the advisory links a commit, diff it against your checkout. Read the guard that was added. It tells you which mechanism from the section above you are dealing with, and whether a compensating control exists.
Capture all of this into a dated file — command output, digests, revision, process uptimes, and the advisory URL. It is the evidence that closes the maintenance window, and the artifact you will want in six months when someone asks whether you were patched during the exposure window.
After you patch, treat it as a CI/CD credential-rotation event
This is the part I would argue hardest about with a colleague who wants to close the ticket. On a self-managed instance, code execution on the Rails tier can read every CI/CD variable the application can decrypt, every project and group access token, runner authentication tokens, deploy keys, and any secret a pipeline can reach. "We upgraded within the window" is not a sufficient answer for an instance that was internet-reachable while a 9.9 was open. You cannot prove absence of access from the outside, so you rotate.
Rotate in blast-radius order
- Runner authentication and registration tokens. These come first because they let an attacker execute code on your runners next — the blast radius moves outward immediately.
- Project and group access tokens, and personal access tokens with CI scope. These read source and trigger pipelines.
- CI/CD variables holding long-lived cloud credentials. AWS keys, GCP service account JSON, registry passwords, deployment API tokens.
- Deploy keys. SSH keys with push access to production repositories.
- Instance configuration secrets — container registry credentials, external service credentials, anything in
gitlab-secrets.jsonworth reading.
One correction to a common assumption: masked variables are masked in the UI, not in the database. Masking is a display and log-redaction feature. A Rails-tier compromise reads the decrypted value.
Look for indicators in CI/CD artifacts
| Check | Where to look | What a hit means |
|---|---|---|
| Unexpected pipeline edits | Diff .gitlab-ci.yml and included templates across active projects against history | Someone with token access added a job or changed rules |
| Pipelines in the exposure window | CI/CD → Pipelines, filtered by date; job logs and artifacts | Execution you cannot attribute to a developer |
| New credentials | Audit events for token, key, and impersonation creation | Persistence beyond the patch |
| Pattern-accepting endpoints | Rails and API access logs | Probing or exploitation attempts |
The fourth row is the one that needs the advisory: you cannot grep for requests to a vulnerable endpoint until you know which endpoint it is. Note it now, run the grep when the advisory names the path.
Hardening that shrinks the next GitLab blast radius
Keep this list short and tie each item to the failure this incident demonstrates.
- Swap long-lived cloud credentials in pipelines for OIDC
id_tokens. If the pipeline exchanges a short-lived identity token for cloud access, a Rails-tier read yields nothing durable. This is the highest-value change on the list. - Split runners into trust tiers. A runner that builds untrusted merge requests should not have network reach to production.
- Enforce protected branches and environments with deployment approvals. A stolen token should not be able to both push and deploy in one step.
- Set expiry on every access token. Rotation hurts largely because tokens never expire and therefore never get rotated.
- Restrict egress from GitLab nodes. Post-exploitation wants the internet and your internal secret store. Cut both paths.
Conclusion
The regex fix itself is routine: pin, install, reconfigure, confirm the running revision. The 9.9 is what is not routine. A code-execution flaw on the GitLab application tier is a full disclosure of your CI/CD secret inventory, so patch, verify the running revision rather than the version string, assume exposure if the instance was reachable during the window, and rotate credentials in blast-radius order. The habit worth building is boring: subscribe to GitLab's security release feed, keep a record of the running revision on each node, and reconcile the two on a schedule. Reacting to headlines is how you end up doing this at 2 a.m. with the wrong version number.
Further Reading
- GitLab releases and security release announcements — vendor primary source for CVE IDs, fixed versions, and backport tables.
- GitLab Advisory Database (community mirror) — mirrored advisory entries you can pull programmatically.
- GitLab upgrade documentation — restart and ordering rules, including zero-downtime topologies.
- GitLab CI/CD variables — what is encrypted, what masking does and does not do.
- GitLab OIDC
id_tokens— the short-lived credential pattern referenced in the hardening section. - CVE.org and NVD — the canonical records once identifiers are published.


