CVE-2026-110264: From Onigmo's 16-bit Regex Counter to Pre-Auth SIGSEGV in GitLab via org-ruby
What’s this?
GitLab shipped a critical patch release on October 21, 2026 with one CVE that got everyone’s attention: CVE-2026-110264, CVSS 10.0, unauthenticated remote code execution, “integer overflow issue in regular expression compiler”. I pulled the official Docker images from both sides of the fix, traced the pre-auth attack surface through GitLab CE’s source, and confirmed the overflow produces an out-of-bounds write that crashes Puma workers with SIGSEGV and SIGILL. When the write lands on non-critical memory instead, the worker keeps running with silently corrupted heap.
PoC and verification scripts: github.com/al4n4n/CVE-2026-110264
Where the fix actually lives
The 19.5.0 to 19.5.1 diff of the GitLab application is 172 files, mostly normal patch-release churn, and none of it touches regex compilation. The Omnibus diff is six files of version bumps. So where’s the fix?
It’s a patch to the Ruby that GitLab bundles. Omnibus builds its own Ruby 3.3.12 under /opt/gitlab/embedded, and omnibus-gitlab!9922 adds a cherry-pick of upstream Ruby commit 493d26d (PR #16801, “Reject overflowed repeat/null-check IDs in huge regexps”). The container images get the same thing through CNG!3168.
That commit is 20 lines. Here’s the part that matters, from regcomp.c:
1 | r = add_mem_num(reg, reg->num_null_check); /* NULL CHECK ID */ |
1 | r = add_mem_num(reg, num_repeat); /* OP_REPEAT ID */ |
The 16-bit counter
Onigmo (Ruby’s regex engine, a fork of Oniguruma) compiles a pattern into bytecode. Two kinds of constructs get a numbered id:
- range repeats, anything like
(?:foobar){0,100}, which compile toOP_REPEATwith an id - null checks, groups that can match the empty string inside a loop, like
(?:(?:foo)?|(?:bar)?)*, which get anOP_NULL_CHECK_STARTwith an id
The counters on the regex_t struct (num_repeat, num_null_check) are int. But the id gets written into the bytecode through add_mem_num(), and that writes a MemNumType:
1 | typedef short int MemNumType; |
So the 32,768th repeat gets id -32768. The compiler doesn’t care, it just emits it. The matcher does care: at match time, OP_REPEAT uses that id as an index into the repeat stack (repeat_stk[mem]), and a negative index is a write before the start of the array. CWE-190 integer overflow turning into CWE-787 out-of-bounds write.
The fix doesn’t change the types. It just checks the counter immediately after incrementing it, and the moment the cast to MemNumType comes out non-positive, compilation fails with a new error. Simple, and it’s the right fix because no legitimate pattern needs 32,768 range repeats.
How a request gets there pre-auth
GitLab’s advisory mentions two detection signals: “Onigmo native worker crash” and “Org mode render error burst”. That second one is a big hint. GitLab renders .org files (Emacs Org-mode) through a gem called org-ruby, and the rendering happens on the anonymous blob viewer. No authentication required if the project is public.
The full chain:
- An attacker pushes a crafted
.orgfile to a public project (they just register a free account, GitLab CE has self-registration on by default) - Anyone (or the attacker themselves) views it via the blob viewer:
GET /<project>/-/blob/<ref>/<file>?format=json&viewer=rich - GitLab’s
BlobViewer::MarkupcallsMarkup::RenderingService#other_markup_unsafe - That calls
Gitlab::OtherMarkup.render, which wraps the rendering in a 10-second timeout - Which calls
GitHub::Markup.render, dispatching to org-ruby based on the.orgextension - org-ruby’s
Orgmode::Parser.new(content).to_htmlparses and renders the file
The anonymous GET in step 2 is the unauthenticated trigger. The file content was committed earlier, and GitLab stores and serves it through Gitaly, but the parsing and regex compilation happen inside the Puma worker serving the request.
GitLab has a size limit of 5 MB for rendered blobs, and the crafted .org file is only about 400 KB. Well within bounds.
The regex injection in org-ruby
org-ruby 0.9.12 (the version bundled with GitLab CE 19.4.2) has this flow for custom TODO keywords:
In parser.rb, when it encounters a #+TODO: line, it stores the keywords:
1 | # parser.rb:459-465 |
Note the Regexp.escape. This is used to build a matching regex for headlines:
1 | # parser.rb:31-33 |
So x{0,8} becomes x\{0,8\} in the alternation, which matches the literal string x{0,8} in a headline. Fine so far.
Now here’s the bug. In headline.rb, when a headline’s first word matches a keyword:
1 | # headline.rb:94-101 |
Line 100 uses @keyword, which is the raw matched word from the headline, not the escaped version from the keyword list. The escaped version was only used for matching in the custom_keyword_regexp. Now the unescaped text goes straight into Regexp.new.
So if you put this in an .org file:
1 | #+TODO: x{0,8}x{0,8}x{0,8}... DONE |
The #+TODO: line registers the pattern as a keyword (escaped for matching). The headline’s first word matches it (because \{ matches literal {). Then line 100 does:
1 | Regexp.new("^x{0,8}x{0,8}x{0,8}...\s*") |
With 32,769 repetitions of x{0,8}, that’s 32,769 range-repeat groups fed directly to Onigmo’s compiler. The integer overflow happens there.
Not every quantifier counts
I spent some time figuring out the minimum pattern that actually overflows the counter. Onigmo has an optimization: quantifiers with a small range don’t produce OP_REPEAT nodes.
Testing against the vulnerable Ruby 3.3.12:
| Pattern | OP_REPEAT? | Why |
|---|---|---|
x?, x*, x+ |
No | Compiled to simpler OP_REPEAT_INC variants |
x{0,7} |
No | Range (7-0=7) below the threshold |
x{0,8} |
Yes | Range (8-0=8) hits the OP_REPEAT path |
x{3,11} |
Yes | Range (11-3=8) also hits it |
(?:x){0,3} |
Yes | Groups always get OP_REPEAT regardless of range |
The threshold is a range of 8 or more for bare-character quantifiers. Groups always use OP_REPEAT.
This means x{0,8} is the shortest bare-character quantifier that creates a repeat node: 6 characters. To overflow, you need 32,769 of them: x{0,8} repeated 32,769 times = 196,614 characters, about 192 KB. The .org file with the #+TODO: and headline lines is about 400 KB. Well under both Puma’s body limits and GitLab’s blob rendering limit.
Alternations (a|b|c|...) don’t increment num_repeat at all, no matter how many branches. I tested 40,000 alternations on the vulnerable Ruby and they compile fine.
Reproduction
The repo splits the PoC into two scripts to make the pre-auth nature obvious. setup.py boots a vulnerable GitLab CE in Docker, creates a token, and pushes the crafted .org file. poc.py is the actual exploit: one anonymous GET, zero credentials, pure HTTP.
1 | # Step 1: one-time setup (boots GitLab, pushes the .org file) |
Here’s the output from my lab:
1 | ============================================================ |
That single anonymous GET is the entire exploit. The .org file goes through org-ruby, headline.rb:100 compiles the raw pattern via Regexp.new, and the overflow happens inside the Puma worker.
On the fixed Ruby from GitLab CE 19.4.3, the same pattern is rejected at compile time with RegexpError: too many range repeat. No memory corruption.
What the OOB write actually does
The overflowed repeat id is a negative signed short. At match time, match_at() uses it to index into the repeat stack array: repeat_stk[id]. A negative id means the write lands somewhere before the array, at whatever heap address that offset resolves to.
What happens next depends entirely on heap layout. The Puma worker has about 1 GB of heap: Rails, gems, caches, request objects. That’s a lot of mapped memory. Over 50 exploit requests to a fresh GitLab instance I saw all three outcomes:
SIGSEGV (segmentation fault): the OOB write hits an unmapped or protected memory page. The worker crashes immediately.
1 | headline.rb:100: [BUG] Segmentation fault at 0x0000fffef41c4280 |
SIGILL (illegal instruction): the OOB write overwrites executable memory or a function pointer. The CPU jumps to corrupted bytes and faults on an invalid instruction. From an RCE standpoint this is the interesting one: the same mechanism that produces SIGILL with random data could produce code execution with controlled data.
1 | headline.rb:100: [BUG] Illegal instruction at 0x0000ffff914197d0 |
Silent corruption (HTTP 200): the OOB write lands on valid, non-critical memory. The worker keeps running. No exception, no log entry, no restart. This is the most common outcome and the hardest to detect.
In 50 requests, I got 46 HTTP 200s and 4 worker crashes (3 SIGSEGV, 1 SIGILL). Every crash comes from headline.rb:100, and the crash addresses are different each time because heap layout changes after each worker respawn. You can reproduce this with verify.py in the repo, which sends exploit requests and reads the crash signals from puma_stderr.log.
There’s a fourth outcome that shows up occasionally: the OOB write corrupts the regex bytecode buffer itself. The matcher reads garbage opcodes and raises undefined bytecode (bug) or invalid multibyte character, producing an HTTP 500. These are not “invalid regex” errors. The regex compiled successfully on the vulnerable Ruby. The errors come from executing the compiled pattern after the OOB write has already corrupted memory.
After running poc.py a few times, the crashes show up in the container’s Puma log. Here’s a raw excerpt from puma_stderr.log on my lab instance:
1 | /opt/gitlab/embedded/lib/ruby/gems/3.3.0/gems/org-ruby-0.9.12/lib/org-ruby/headline.rb:100: [BUG] Segmentation fault at 0x0000fffef41c4280 |
The Ruby stack shows the crash at headline.rb:100 (the Regexp.new injection) via sub!. The C stack shows it dying inside Onigmo’s match_at() at regexec.c:3836, which is the repeat stack indexing code where the negative id writes out of bounds.
Check it yourself:
1 | docker exec <container> grep '\[BUG\]' /var/log/gitlab/puma/puma_stderr.log |
Or automate it with verify.py, which sends requests and reads that log for you.
From a defender’s perspective, the silent corruption is worse than the crashes. A crash at least triggers a worker restart and shows up in puma_stderr.log. The silent case leaves no trace.
Ruby’s regression test catches the compile step
The upstream fix commit adds two tests:
1 | def test_too_many_range_repeat |
100,000 repeats, well past 32,767. On a fixed Ruby, Regexp.new raises. On an unfixed Ruby, it compiles silently. That difference is enough to tell a fixed build from an unfixed one without going anywhere near the bad index.
Running it inside the official images
You can also check any image directly without booting GitLab. Run the bundled Ruby in an isolated container:
1 | docker run --rm --network none --memory 2g --pids-limit 64 \ |
No GitLab services start, nothing listens, the container can’t reach the network.
Full table, pulled from Docker Hub on October 22:
| Image | Arch | libruby.so.3.3.12 sha256 |
Fix strings | Patterns | Verdict |
|---|---|---|---|---|---|
gitlab-ce:19.4.2-ce.0 |
arm64 | 4441d273...2e46c1 |
no | compiled | vulnerable |
gitlab-ce:19.4.3-ce.0 |
arm64 | e91c2005...b7a6f6 |
yes | rejected | fixed |
gitlab-ce:19.5.0-ce.0 |
arm64 | e91c2005...b7a6f6 |
yes | rejected | fixed |
gitlab-ce:19.5.1-ce.0 |
arm64 | e91c2005...b7a6f6 |
yes | rejected | fixed |
gitlab-ce:19.4.2-ce.0 |
amd64 | 1e670f2f...aaf4ce |
no | compiled | vulnerable |
gitlab-ce:19.5.0-ce.0 |
amd64 | bc623ba3...05b65 |
yes | rejected | fixed |
GitLab already knew about this bug class
Here’s something interesting I found while reviewing the source. In ee/lib/gitlab/ci/config/entry/rules/rule/clause.rb, GitLab has this:
1 | REPEAT_MAX = 1_000 |
They already had guards against Onigmo overflow in CI rule patterns, with a hardcoded limit of 1,000 repeats and a comment that references a related Ruby fix. But this only protects the CI pipeline regex path, which is post-authentication. The org-ruby rendering path was completely unprotected, and that’s the one reachable pre-auth.
GitLab also wraps most user-supplied regex patterns in UntrustedRegexp, which uses Google’s RE2 engine instead of Onigmo. RE2 is immune to this bug. But org-ruby doesn’t go through UntrustedRegexp. It uses plain Ruby Regexp.new, which goes straight to Onigmo.