Behind the GitLab RCE: A depthfirst Journey into the Ruby Ecosystem
TL;DR: The GitLab RCE in our previous post emerged from a much broader investigation into the Ruby ecosystem conducted through the Open Defense Initiative. The depthfirst platform was used to analyze a large number of Ruby projects and discovered over 100 vulnerabilities across 34 projects, some of which had remained undetected for more than 15 years. Collectively, these projects have been downloaded more than 8.6 billion times. Our platform validated the vulnerabilities at scale and we disclosed the findings responsibly. Findings such as these are directly integrated into depthfirst’s Supply Chain module as soon as they are discovered, giving customers immediate visibility into affected dependencies and clear, precise guidance on how to remediate them.
Ruby is used by many well-known web applications, including GitLab, Stripe, Airbnb, Coinbase, Shopify and GitHub Enterprise. In Ruby, developers usually work with strings, arrays, objects, and other built-in features without manually allocating or freeing memory. This avoids many of the common code issues in low-level languages such as C and C++, therefore Ruby is generally seen as a relatively memory safe language for application development.
That perception of safety can obscure a critical attack surface: the underlying “Gems” on which Ruby applications depend. Gems are reusable packages that add libraries, tools, and other functionality to Ruby projects. Those with native C extensions operate outside many of Ruby’s safety guarantees, and vulnerabilities in this overlooked layer can have severe consequences. As we demonstrated in our GitLab RCE research, flaws in a low-level gem can ultimately compromise the application that relies on it, resulting in Remote Code Execution.
Going Deeper into the Ruby Ecosystem
We approach all of our security research in a depthfirst manner (no pun intended): we follow applications into the lower-level components on which they depend. These components often process critical data while receiving less scrutiny, making them an important and frequently overlooked part of an application’s security posture.
As part of the Open Defense Initiative launched earlier this year, we deployed depthfirst’s agentic code scanning platform at scale across the Ruby Gem ecosystem, with a particular focus on packages containing native C extensions. These gems sit at the boundary between Ruby and C, handling complex data outside Ruby’s usual safety guarantees. Vulnerabilities here can be critical yet are often overlooked.
Scanning at Scale with AI-Native Security Agents
Our platform allowed us to scan these gems at scale with very little setup. We pointed the platform at each repository, and our AI security agents handled the analysis from start to finish. The platform provided complete source code coverage across the repositories we analyzed, ensuring that files and functions were not excluded simply because they appeared unrelated to an obvious attack surface. This was especially important for unfamiliar dependencies, where a critical detail could be hidden almost anywhere in the codebase.
The platform did more than surface vulnerability candidates. It analyzed each candidate in context and attempted to validate whether the suspected issue could be triggered, eliminating most false positives before manual review. For every validated finding, it generated a concrete triggering input, a reproduction script, and observable evidence such as AddressSanitizer output. Each of the vulnerabilities we validated was supported by this evidence, giving maintainers a direct way to confirm the behavior and begin remediation.
The validated findings became the foundation for our GitLab RCE research. By tracing vulnerable gems back into the applications that used them, we identified a reachable path through the Oj JSON parser in GitLab and ultimately chained two memory-safety vulnerabilities into remote code execution.
We ultimately reviewed roughly 40 Ruby gem projects. Findings from this work, along with other research from the Open Defense Initiative, are integrated directly into depthfirst’s Supply Chain module. This allows customers to identify applications that rely on affected versions as fast as vulnerabilities are discovered and receive clear recommendations including mergeable pull requests for upgrading to a fixed release.
What We Found Across the RubyGems Ecosystem
As of the publication of this blog, our Ruby research has identified and validated 105 vulnerabilities across 34 projects. Of these, 62 have been acknowledged by maintainers, and the depthfirst team is working with the maintainers to remediate remaining findings as part of the Open Defense Initiative. Together, the vulnerable projects have accumulated more than 8.6 billion downloads on RubyGems.
The affected projects span many parts of the Ruby ecosystem, including database drivers, parsers and serialization libraries, web servers, networking components, cryptographic libraries, image-processing tools, text-processing libraries, performance tools, and bindings to widely used native libraries. This breadth suggests that the issue is not limited to one narrow class of gem, but can appear wherever Ruby code crosses into lower-level native implementations.
To showcase how these vulnerabilities emerge in practice, the following examples examine several distinct mistakes that can occur when native extensions translate Ruby objects and behavior into lower-level C code.
CVE-2026-57235
One notable vulnerability uncovered by our research affected Nokogiri, a widely used Ruby library for parsing XML and HTML. The bug was introduced in Nokogiri 1.3.0 in 2009 and remained in the codebase until it was found and ultimately fixed in version 1.19.4 on June 18, 2026, roughly 17 years later.
The vulnerability was found in Nokogiri::XML::NodeSet#[], which allows Ruby code to select a node by index. Like a standard Ruby array, it supports negative indexing: -1 selects the last node, -2 selects the second-to-last node, and so on.
Internally, Nokogiri received the index as a 64-bit C long. However, when checking whether the index was valid, it first converted the value to a 32-bit int:
static VALUE index_at(VALUE rb_self, long offset) {
xmlNodeSetPtr c_self;
TypedData_Get_Struct(rb_self, xmlNodeSet, &xml_node_set_type, c_self);
if (offset >= c_self->nodeNr ||
abs((int)offset) > c_self->nodeNr) { // VULNERABLE
return Qnil;
}
if (offset < 0) { offset += c_self->nodeNr; }
return noko_xml_node_wrap_node_set_result(c_self->nodeTab[offset], rb_self);
}
This created a mismatch between the value being checked and the value later used. For example, on affected 64-bit platforms, the large negative number -4,294,967,297 became -1 when converted to a 32-bit integer. The bounds check therefore saw an ordinary -1 and accepted it as a request for the last node. But after the check, Nokogiri continued using the original value. Adding the number of nodes left the index deeply negative, so the code read memory far before the start of nodeTab, causing an out-of-bounds read. In other words, the function checked the index after shrinking it to 32 bits, but accessed the array using the original 64-bit index.
The maintainers fixed the issue by validating the original long value directly, without converting it to a smaller integer:
- if (offset >= c_self->nodeNr || abs((int)offset) > c_self->nodeNr) {
+ if (offset >= c_self->nodeNr || offset < -c_self->nodeNr) {
return Qnil;
}
CVE-2026-54902
Another finding appeared in Oj, a high-performance JSON parser implemented as a Ruby C extension. The issue was in Oj::Parser’s SAJ mode, where the key of a nested object must remain available until the matching object or array is closed.
For a nested value such as:
{
"very_long_key": {
"name": "value"
}
}
Oj needs to remember "very_long_key" while parsing the inner object. It passes the key when the object is opened and may need to use the same key again when that object closes.
For long keys, Oj created a normal Ruby String:
static VALUE get_key(ojParser p) {
Saj d = (Saj)p->ctx;
const char *key = buf_str(&p->key);
size_t len = buf_len(&p->key);
volatile VALUE rkey;
if (d->cache_keys) {
rkey = cache_intern(d->str_cache, key, len);
} else {
rkey = rb_utf8_str_new(key, len);
}
return rkey;
}
The problem was not the String creation itself. The problem was how Oj stored the returned VALUE.
A Ruby VALUE is a reference to a Ruby object. Oj saved that reference inside its native C parser state, but the reference was not visible to Ruby’s garbage collector. From the garbage collector’s point of view, nothing was keeping the key String alive. If Ruby’s garbage collector ran while the nested object was still open, it could reclaim the key because it did not know that Oj still needed it. Oj would continue holding the old VALUE, but that value would now refer to freed or reused memory. When the parser later used the saved key again, it accessed this stale reference, causing a use-after-free and potentially crashing the process. In other words, Oj remembered the key in C, but failed to tell Ruby’s garbage collector that the key was still in use.
The bug was fixed by placing keys for currently open objects and arrays onto a dedicated stack. The garbage collector marks every key on this stack as live. When the corresponding object or array closes, Oj removes the key from the stack. This makes the lifetime explicit: each key remains protected for as long as the parser may still use it.
CVE-2026-54905
This one appeared in concurrent-ruby, the concurrency toolkit that ships as a dependency of Rails and many other frameworks, and the mistake is written entirely in Ruby. Concurrent::ReentrantReadWriteLock lets a thread acquire the same lock more than once, so it has to track two things about the current thread: how many read holds it has taken, and whether it holds the write lock. Both were stored in a single integer. The low 15 bits counted read holds, and bit 15 served as the WRITE_LOCK_HELD flag.
That gave the read count 32,768 available values, and nothing prevented it from asking for one more. On the 32,768th reentrant read acquisition, the count carried into bit 15, the same bit that means this thread holds the write lock.
lock = Concurrent::ReentrantReadWriteLock.new
32_768.times { lock.acquire_read_lock }
lock.try_write_lock # => true
From that point, try_write_lock read the integer, saw the write-lock bit set, and concluded that the thread already held the write lock. It returned true without ever setting the global RUNNING_WRITER bit that signals to other threads that a writer is active. So the caller was told it had exclusive access, while no other thread was told to stop. Readers could continue to hold and acquire read locks on data the writer believed it owned alone. Mutual exclusion, the one guarantee the class exists to provide, no longer held true. In other words, the lock ran out of bits before it ran out of readers. The fix separates the two pieces of state, so a read count can no longer grow into the flag that represents the write lock.
CVE-2026-54619
Another finding appeared in sqlite3-ruby, the Ruby binding for SQLite. SQLite identifies a function by both its name and its number of arguments, so functions with the same name but different arguments are treated as separate registrations. For example:
db.define_function("f") { |a| "one argument" }
db.define_function("f") { |a, b| "two arguments" }
SQLite stores these as two distinct functions: one accepting a single argument and another accepting two arguments. sqlite3-ruby, however, retained the corresponding Ruby blocks using only the function name as the lookup key. When the second function was registered, it replaced the Ruby reference to the first block, even though SQLite still retained both native function registrations. As a result, Ruby’s garbage collector could no longer see the first block as reachable and could reclaim it. SQLite would still hold a native pointer to that block and might later invoke it when executing db.execute("select f(1)"). SQLite could then invoke a Ruby block that no longer existed, dereferencing freed memory and potentially crashing the process.
The root cause was a mismatch between SQLite’s function identity model and sqlite3-ruby’s lifetime management. SQLite distinguished functions by both name and arguments, while sqlite3-ruby retained only one Ruby block per function name. The fix changed the database object to retain every registered block rather than storing only one block for each name. This keeps all blocks visible to Ruby’s garbage collector for as long as SQLite may invoke them.
Continuing the Work
Ruby is only one ecosystem and our work there is far from finished. Across ecosystems and programming languages, countless vulnerabilities remain undiscovered in the open-source projects that modern software depends on.
Through the Open Defense Initiative, we are working toward a future where open-source security is continuous, collaborative, and built into the software ecosystem itself. That means not only finding vulnerabilities, but validating every issue and giving maintainers the evidence and remediation guidance they need to fix it quickly.
This work does not stop with the affected project. Because open-source vulnerabilities can propagate across thousands of downstream applications, depthfirst’s Supply Chain module can identify where vulnerable versions are in use, surface the resulting risk, and recommend the right upgrade as soon as a fix becomes available.
Our goal is to help secure the foundation that the world’s software is built on, one ecosystem at a time.