Going depthfirst: Achieving GitLab RCE via Two Ruby Memory Corruption Vulnerabilities
TL;DR: As part of the Open Defense Initiative, the depthfirst system analyzed Oj, a high-performance JSON parser with a substantial native C implementation, and produced a prioritized queue of potentially exploitable findings. The system surfaced 18 prioritized vulnerabilities, including 7 memory-safety bugs. Two of them, an out-of-bounds write and a heap-pointer disclosure, had survived in Oj for nearly five years. Oj is a low-level dependency used by GitLab, and we combined the two bugs to achieve remote code execution on a default GitLab installation. The resulting chain affected GitLab CE and EE versions 15.2.0 through 18.10.7, 18.11.0 through 18.11.4, and 19.0.0 through 19.0.1. GitLab released patches shortly after receiving our reports. The PoC code is available here. This is the kind of problem depthfirst is built to solve: tracing real applications in depth into overlooked code, identifying high-signal critical bugs, and connecting them back to reachable product impact.
When thinking about remote code execution vulnerabilities in GitLab, one of the first patterns that comes to my mind is the old web-to-Redis chain: turn a web primitive into SSRF, use SSRF to speak the Redis protocol, then persuade a trusted process to execute the queued work. A 2018 report on GitLab repository mirroring demonstrated this route through CRLF injection in a crafted git:// URL on self-managed installations where Redis was reachable over TCP. Today, GitLab is hardened behind layer after layer of SSRF defenses. If the old route is sealed off, what other path could still break through?
GitLab is written in Ruby, which is generally considered a memory-safe language. Automatic memory management and runtime bounds checking allow developers to focus primarily on application logic rather than manual allocation, object lifetimes, or array bounds. While browsing the scan results of our Open Defense Initiative, we noticed that many Ruby gem projects contain an ext/ directory full of C, with a lot of memory corruption findings. What looks like ordinary Ruby plumbing is moving String, Array, and Hash objects into native parsers full of pointers, fixed-size buffers, integer conversions, and manually managed lifetimes. This finding surprised us and opened a new line of security research: Could an attacker exploit a vulnerable gem to trigger memory corruption in Ruby applications?
We checked what gem dependency GitLab uses, and Oj stood out. The Oj gem is a high-performance JSON parser for Ruby, implemented substantially as a native C extension and used by GitLab and many other Ruby applications. We scanned Oj’s source code using the depthfirst system. The system analyzed the native implementation as a whole and reduced it to a prioritized queue whose entries retained the source location, surrounding code, and likely bug class. Eventually, it promoted 18 vulnerabilities into the prioritized view, including 7 memory-safety violations. That focused the review on fixed-size buffers, integer-width changes, object lifetimes, and parser state shared across callbacks.
Reviewing that queue against Oj’s source and runtime behavior produced several concrete memory-safety bugs. Two formed the chain in this post: an unchecked nesting stack on Oj::Parser.usual.parse supplied the write primitive, and an unchecked key-length narrowing exposed a heap pointer.
Tracing Oj Back to GitLab
Starting from the affected Ruby entry point, Oj::Parser.usual.parse, we searched GitLab’s source tree for production call sites. One immediately stood out: a small GitLab-managed, in-tree gem called ipynbdiff.
The name is descriptive: .ipynb is the file extension for Jupyter Notebooks, and diff refers to comparing two revisions of a file. To understand why this code invokes a JSON parser, it helps to first look at what GitLab sees when someone commits a notebook.
What Lives Inside an .ipynb File
A Jupyter Notebook is an interactive document commonly used for data analysis, scientific computing, and machine-learning work. To its author, it looks like a page made of cells. One cell may contain explanatory Markdown, another may contain Python or another language, and a code cell may retain the text, tables, plots, or errors produced when it ran.
The file stored in Git is a JSON document. Its four important top-level fields are cells, metadata, nbformat, and nbformat_minor. Every cell has a type, metadata, and source text; code cells can additionally carry an execution count and saved outputs. A minimal notebook looks like this:
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "hello",
"metadata": {},
"outputs": [],
"source": ["print(\"hello\")\n"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 5
}
The official notebook format allows much richer cells and output objects, including images and other MIME-typed data. Cell execution happens in Jupyter; GitLab’s diff renderer works from the JSON already stored in the repository.
That JSON is convenient for software and awkward for code review. A one-line change in a code cell can sit among escaped newlines, execution counters, metadata, and large saved outputs. GitLab therefore offers a cleaner notebook diff: it transforms the machine-readable .ipynb file into a human-readable representation organized around cells, source, and output.
ipynbdiff performs that transformation. It parses each notebook revision, walks the cells array, converts notebook metadata, cell headers, source text, and outputs into ordered text blocks, and then compares the transformed versions. Parsing the repository file is the first step, which is how the Oj call entered this feature.
Following One Commit Diff into Puma
Consider an authenticated project member who can push commits and view them. They commit one version of a notebook, modify it in the next commit, and open the second commit’s diff page. The browser requests GET /<project>/-/commit/<sha>/diffs_stream?offset=0 to load the rendered changes.
In a standard GitLab request path, Workhorse receives the HTTP request and proxies it to the Rails application. Rails runs inside Puma, whose long-lived Ruby worker processes also load native extensions such as Oj. GitLab’s repository layer, backed by Gitaly, supplies the Git objects needed for the diff.
A Git blob is the byte content of a file at one revision. For a modified file, the old blob comes from the parent side of the diff and the new blob comes from the commit being viewed. When the file qualifies for notebook rendering, Gitlab::Diff::Rendered::Notebook::DiffFile#notebook_diff passes those contents to the in-tree gem (pinned GitLab source):
IpynbDiff.diff(
source_diff.old_blob&.data,
source_diff.new_blob&.data,
...
)
In Ruby, &. is the safe-navigation operator. It calls .data when the blob exists and yields nil when that side is absent. An added notebook therefore has only a new side; a deleted notebook has only an old side. Transformer#transform turns the missing side into an empty transformed notebook. A modified notebook supplies both byte strings.
The same project member can control both sides by creating the parent version in one commit and the child version in the next. IpynbDiff.diff transforms from before to, giving a stable old-then-new order. At this point, both non-nil arguments are still raw repository bytes.
The Ruby Line That Entered C
Inside ipynbdiff, every existing notebook side passes through Transformer#transform. Before the gem can walk its cells, it calls validate_notebook. The method performs a minimal parse-and-shape check (pinned transformer source):
def validate_notebook(notebook)
notebook_json = Oj::Parser.usual.parse(notebook)
return notebook_json if notebook_json&.key?('cells')
raise InvalidNotebookError
rescue EncodingError, Oj::ParseError, JSON::ParserError
raise InvalidNotebookError
end
The notebook argument is a Ruby String backed by the repository blob bytes. Oj::Parser.usual returns a process-wide native parser singleton. Each Puma worker process has its own copy, while threads in that worker share it. Calling .parse passes a pointer to the String’s byte buffer into Oj’s C extension, which constructs Ruby objects from the JSON. If parsing succeeds, validate_notebook then checks whether the top-level object contains a cells key.
That completed the reachability proof. A normal authenticated user able to create or push to a project and view the resulting commit diff could commit an .ipynb file and trigger GitLab’s notebook renderer by opening that diff, causing repository-controlled bytes to reach Oj inside the Puma worker.
The investigation ran in reverse: depthfirst found the vulnerable parser below the application, then traced its callers back to a user-controlled product boundary. A real request follows that chain in the opposite direction, from a GitLab commit diff into native C code.
Nearly 4 Years in the Notebook Renderer
The vulnerable parser code entered Oj on August 8, 2021, and first shipped in Oj 3.13.0. GitLab switched notebook validation to Oj::Parser.usual.parse in July 2022, making the vulnerable path reachable in self-managed GitLab beginning with version 15.2.0.
The bugs remained in Oj for 1,753 days before the upstream fixes were merged, following depthfirst’s report through the Open Defense Initiative. The vulnerable notebook path shipped in GitLab releases for nearly four years. GitLab triaged the report quickly and fixed its maintained release lines by upgrading to Oj 3.17.3. The fixes were merged upstream on May 27, published in Oj 3.17.3 on June 4, and shipped in GitLab’s June 10 security releases.
I. Write: From Repeated 0x01 Bytes to a Chosen p->start
The nesting bug began with a narrow primitive. Once depth passed the end of the nesting stack, Oj continued writing one-byte collection selectors into the fields that followed it. The demonstrated payload used only arrays, so nesting depth controlled where a contiguous sweep of 0x01 bytes stopped. It could not place an arbitrary pointer directly into the parser.
The exploitation problem was therefore to find parser state that could make this restricted write useful. buf.head, the parser’s buffer-start pointer, provided that bridge. The sections below follow the transformation in order: the selector overflow, the pointer corruption and allocator handoff, and finally the Ruby Array overlap that reached p->start.
The Primitive: A Forward Sweep of 0x01 Bytes
Oj 3.16.16 kept collection nesting state in a fixed 1,024-byte array inside the parser object:
typedef struct _ojParser {
const char *map;
const char *next_map;
int depth;
unsigned char stack[1024];
struct _num num;
struct _buf key;
struct _buf buf;
struct _funcs funcs[3];
void (*start)(struct _ojParser *p);
/* ... */
} *ojParser;
Opening an array selected the current callback table, incremented depth, and recorded the new collection type:
p->funcs[p->stack[p->depth]].open_array(p);
p->depth++;
p->stack[p->depth] = ARRAY_FUN;
Oj defined the three selectors next to the parser structure (pinned source):
#define TOP_FUN 0
#define ARRAY_FUN 1
#define OBJECT_FUN 2
The valid nesting indices were stack[0] through stack[1023]. Oj performed no depth check before assigning the selector for a newly opened collection. An array stored 0x01; an object stored 0x02. The demonstrated payload used arrays, so every level wrote 0x01 at: P + 0x14 + depth. The traversal sustained itself. On the next [, the byte just written was read back as the valid Array callback selector, allowing the parser to advance another level.
The demonstrated Writer 1 was a forward sweep of 0x01 bytes. Nesting depth chose where that sweep stopped, while every byte between the end of stack and the endpoint was overwritten. It could not choose each byte independently or place a gadget address directly into p->start. The useful move was to spend one legal 0x01 on a pointer that would determine where Writer 2 landed.
The Pivot: One Allowed Byte Repoints buf.head
The target was not p->start; Writer 1 could not encode that address. The target was buf.head, because later code would trust it as an allocation pointer.
Depth 1,024 produced the first out-of-bounds write at P+0x414. From there, every [ advanced the address by one and filled the entire range with 0x01. The sweep crossed padding, numeric state, and the key buffer. The all-array prefix kept using Oj’s separately allocated collection and VALUE stacks; it parsed no object key, and the following number reinitialized the numeric fields it needed.
The useful target was byte 0 of buf.head at P+0x868. It was reached at depth 2,132 (0x868 - 0x14 = 0x854 = 2132), making it the 1,109th byte written beyond stack. Before corruption, buf.head and buf.tail both pointed to the inline buffer at P+0x880, while buf.end was P+0xc7f.
In the profiled 3,584-byte jemalloc class, the parser region started with a zero low byte. The allowed value 0x01 therefore changed the head pointer’s low byte from 0x80 to 0x01 (P+0x880 → P+0x801), while buf.end and buf.tail remained unchanged. One selector byte had moved the logical buffer start backward by 0x7f, exposing an interior address as Oj’s buffer base. That pointer field was the amplifier: subsequent buffer code would pass the forged address to the allocator.
The Allocator Handoff: realloc Caches P+0x801
The allocator handoff had one goal: make the interior address something a normal allocation could later receive. The 4,001-digit number was chosen to force Oj to call realloc on the forged buf.head.
Three allocator sizes matter here. _ojParser itself occupied 0xdf0, or 3,568 bytes. Jemalloc rounded that request to one 0xe00 3,584-byte region. Eight such regions filled a 0x7000 seven-page slab. Releasing one region returned it to jemalloc’s cache; it did not unmap the surrounding slab.
The forged head was still inside the parser region. With buf.head = P+0x801 and buf.end = P+0xc7f, Oj calculated a capacity of 0x47e, or 1,150 bytes. The payload next supplied the 4,001-digit number 1 followed by 4,000 2 digits.
Oj retained the first 18 digits in _num. When the number switched to the large-number path, big_change() set buf.tail = buf.head and reconstructed those 18 digits at the forged address. The parser scanned the remaining 3,983 digits as one span and passed them to a single buf_append_string() call. That call checked capacity before its bulk copy, so it entered the growth path with:
1150 + 3983 + 1150/2 = 5708 bytes
Because buf.head no longer equaled the inline buf.base, Oj called realloc(P+0x801, 5708). In the profiled GitLab jemalloc build, this moved the numeric buffer into the 6,144-byte class and cached the exact old address, P+0x801, at the top of the thread-local 3,584-byte bin.
Jemalloc treated the forged pointer as a 3,584-byte region and copied [P+0x801, P+0x1601) to the new allocation. The source extended 0x801 bytes beyond the parser’s original region into the following slab region. Heap profiling established that this full range was mapped in the target placement. This interior-pointer behavior was specific to the measured allocator and build; it is not a portable realloc guarantee.
The following inner [ advanced depth once more and wrote 0x01 at P+0x869, byte 1 of the now-moved buf.head pointer. The remaining sequence did not dereference that pointer again before arming the callback.
The Overlap: A Ruby Array Reclaims P+0x801
Writer 2 needed a normal 3,584-byte-class allocation whose initialization copied attacker-selected data. Closing an inner JSON array with 446 values provided exactly that allocation. Oj called rb_ary_new_from_values(446, values). Each CRuby VALUE occupied eight bytes, so the external Array body requested 3,568 bytes and landed in the same 3,584-byte class.
Closing the large integer also created Ruby String storage and a BigDecimal before Oj opened the inner Array. In this build, the String buffers used the 4,096-byte class, BigDecimal’s roughly 1.8 KiB Real used the 2,048-byte class, and the moved numeric buffer occupied the 6,144-byte class. Oj’s preallocated 4,096-entry VALUE stack still held the roughly 2,580 live entries, the collection stack had already grown, and all 446 inner elements were immediate values. None of these operations consumed the 3,584-byte tcache entry. Because parsing and Array construction remained under the GVL on one native thread, P+0x801 was still at the top of that bin when CRuby allocated the Array body.
The Ruby Array object came from the GC heap, while its external element body came from jemalloc. That body began at the reclaimed P+0x801 and occupied [P+0x801, P+0x15f1). The write stayed inside the 3,584-byte range already proven mapped by the successful realloc copy.
The Array copy was Writer 2, not the nesting overflow. It supplied the callback bytes. Its destination was fixed, its useful length was 3,568 bytes, and every eight-byte word had to be a valid Ruby VALUE. At body offset 0x587, those constraints were still enough to control p->start: element 176 byte 7 overlapped callback byte 0, and element 177 bytes 0 through 6 overlapped callback bytes 1 through 7.
The chain had therefore amplified a forward sweep of one-byte selectors into an allocator-assisted overlapping-object write with a chosen callback pointer under CRuby encoding constraints.
The Controlled Field: Two Ruby VALUEs Assemble p->start
Two adjacent Ruby VALUEs were enough to assemble the callback pointer. Immediate Floats and Fixnums provided useful bit patterns without requiring separately allocated Ruby objects.
On little-endian x86_64, element 176 byte 7 became p->start byte 0, while element 177 bytes 0 through 6 became callback bytes 1 through 7. For the final libruby target used after ASLR recovery, the immediate Float literal -1e45 had the representation 0x4a335dbf821ae4f6. Its most-significant byte supplied the address’s first byte, 0x4a.
Ruby encodes a small integer as the tagged Fixnum VALUE = (integer << 1) | 1. The target’s second address byte was odd, satisfying the tag bit, and the remaining seven bytes were encoded into element 177 with:
integer = (((target >> 8) - 1) >> 1)
The other Array entries were immediate integer fillers. When rb_ary_new_from_values copied the 446 words into the reclaimed body, elements 176 and 177 assembled the chosen native address at p->start.
A trailing X raised a parse exception after the Array copy armed p->start and before the corrupted p->result callback could run. The exception also skipped the later SymbolMap SAJ parse. On the next invocation, parser_reset cleared per-parse state but did not restore p->start, so the overwritten callback survived.
The write chain could now place an address satisfying CRuby’s VALUE-encoding constraints in p->start. ASLR still hid which library address to use.
II. Read: Turning a Heap Pointer into an ASLR Base
With control of p->start established, the remaining problem was locating libc and libruby under ASLR. An oversized object key made Oj return a fixed 29-byte slice of one Key entry, and GitLab carried that slice into the rendered diff. Eight of those bytes were the pointer to Oj’s duplicated key allocation. The primitive could not choose or dereference another address, but it provided a heap landmark for the callback overwrite developed in Part I.
One Key, Two Layouts
Oj’s usual parser stored each object key in a 32-byte union _key. The source used two anonymous struct views over that storage:
typedef union _key {
struct {
int16_t len;
char buf[30];
};
struct {
int16_t xlen; // same storage as len
char *key;
};
} *Key;
For clarity, these are the inline view and the external view. len and xlen occupied the same two bytes at union offset 0. The inline buffer required only byte alignment and began at offset 2. The external pointer required eight-byte alignment, so the x86_64 ABI inserted six bytes of padding and placed key at offset 8. Read through the inline view, those pointer bytes appeared at buf[6] through buf[13].
The stored len selected the view later code used. A value below 30 selected buf; a longer value selected key. For a long input, Oj duplicated the key on the heap and stored the resulting char * through the external view.
The parser received the real key length as a size_t, then assigned it to the signed 16-bit len field without enforcing a compatible upper bound. The allocation branch still used the original size_t length, so Oj stored the duplicated pointer. Later, the truncated len made the return path read the inline buffer instead. The pointer form had been written, but the inline form was read.
The Pointer Hidden at Byte Six
The useful input length was 0x10000 + 29, or 65,565 bytes. On the tested GCC/Clang x86_64 ABI, narrowing that value to int16_t left 29. Later code saw 29 < 30 and returned 29 bytes through the inline buf. That read began at union offset 2 and covered offsets 2 through 30.
The live external key pointer still occupied union offsets 8 through 15. Relative to a read beginning at offset 2, the pointer therefore appeared six bytes into the result:
pointer = unpack_le(leaked_key_bytes[6:14])
Returned bytes 0 through 5 came from the external view’s alignment padding. Bytes 6 through 13 were the live key pointer. The final 15 returned bytes came from union storage not written by the external view and could contain stale key-entry contents. This was a fixed 29-byte disclosure, not an arbitrary-address read: it exposed bytes already present in that Key entry and never dereferenced an attacker-selected pointer. These offsets came from the tested x86_64 ABI, so the compiler layout was part of the primitive’s boundary.
The same truncated length controlled cleanup. Oj freed kp->key only when kp->len >= 30; with the stored length equal to 29, that free was skipped. The disclosed address therefore still referred to the duplicated 65,565-byte key allocation.
A Pointer Wrapped in Ruby, Then HTML
The notebook transformer included each cell ID in its human-readable output:
%(%% Cell type:#{type} id:#{cell['id']} tags:#{tags&.join(',')})
The notebook encoded cell['id'] as a JSON object with two 65,565-byte keys. Oj materialized that object as a Ruby Hash; interpolation used its Hash#inspect representation, and GitLab then HTML-escaped that text inside the rendered diff.
Recovery removed the diff markup, decoded the HTML entities and the escape forms observed in the response, extracted bytes 6 through 13, and retained only canonical userspace pointer-shaped results.
Two long keys in one notebook produced two distinct, correlated heap samples from one render. Because the truncated cleanup path retained each duplicated key allocation, two keys per render increased coverage while keeping the number of heap-perturbing requests low. Repeating the request sampled the Puma worker population.
A Heap Landmark Inside a Forked Worker Tree
The leaked address pointed to the copied key. It was a heap landmark, while the final gadgets lived in libc and libruby. The useful relationship was the displacement between that landmark and the library mappings:
delta = leaked_key_pointer - libc_base
In the profiled GitLab 18.11.3 Omnibus container, Puma ran in preloaded cluster mode. The master established the libc, libruby, jemalloc, and Oj mappings before forking its workers, so workers in that process tree inherited identical library addresses. Their heaps diverged as they served requests. A replacement worker forked by the same master inherited the same mappings; separate Puma masters and master restarts had independent layouts.
In the profiled GitLab image, the absolute heap-to-libc displacement was roughly 2–3 GiB. That magnitude did not determine the search cost. The uncertainty in delta, created by different worker heap histories, determined how many page candidates remained.
Puma Sets the Search Band
Repeated render requests sampled fresh and mature workers. Across those measurements, the highest valid pointer sample tracked the freshest worker and served as maxP, the empirical anchor for the candidate band. This relationship belonged to the profiled target and worker lifecycle; it was not a jemalloc invariant.
The leak reduced ASLR to a finite set of page-aligned libc candidates. The callback overwrite from Part I supplied the yes-or-no test. Each probe wrote candidate_base + 0x8470e, the profiled libc offset of an eb fe (jmp $) instruction, to p->start and invoked it on the next parse in the same request. Page alignment made the target’s first byte 0x0e and its second byte 0x47 for every candidate. The immediate Float 540000000.0 supplied byte 0, while the odd second byte allowed the Fixnum to encode bytes 1 through 7. A correct candidate entered the self-loop and held the diff request open. Every timeout candidate was probed twice more before acceptance.
The search began around the most likely displacement and expanded across the measured band concurrently. Two-key sampling, likely-first ordering, and the confirmation pass reduced variance. In the profiled two-worker fresh configuration for GitLab 18.11.3, the search usually completed in about 5–10 minutes. Extrapolating the measured probe rate across the widest mature-worker band gave roughly 1–2 hours.
An experimental fast path used safe-return gadgets, usually a one-byte ret or a short register-only sequence ending in ret. When p->start landed on one, control returned to the parser and the diff request completed with HTTP 200. The offsets between these 200-producing sites were precomputed for the exact binaries. After the first 200, a few probes at the expected companion offsets identified the gadget and recovered the mapping base. Unlike the original jmp $ oracle, which had to keep testing candidate bases until the single hanging address was reached, this method could identify the mapping from the first 200 signature and a few follow-up probes.
Once recovered, the base remained valid for that forked worker tree until its Puma master restarted. Part I supplied control of p->start, and Part II supplied the address to place there.
Chaining the Primitives into RCE
In the profiled image, libruby.so began 0x661000 above libc. Recovering the libc base therefore also located the final libruby callback sequence, while system() came from the same libc mapping. The self-loop probe could now be replaced with that target; the corrupting parse and command-bearing trigger still had to reach the same process-local Oj parser in order.
Keeping Both Parses in One Puma Worker
The trigger had one process boundary: the next parse had to use the same native parser object. Oj::Parser.usual returned a process-global parser singleton; threads in one Puma worker shared that object. A single diffs_stream request could transform several notebook files without crossing into another worker.
Two lexically ordered files carried the stages. For a01, the old side was benign and the new side contained the corruptor followed by X. For a02, the old side was a binary trigger blob and the new side was a different benign notebook. The old a02 blob was stored byte-for-byte and passed to Oj as a binary-safe Ruby String. It could therefore contain a NUL-terminated command and little-endian native pointers in the same blob; the .ipynb path still selected the notebook renderer.
The a01 and a02 names placed the files in lexical order, while each modified notebook retained the old-then-new transform order described earlier. The trailing X made a01.new raise InvalidNotebookError after arming the callback. GitLab rescued that exception in the per-file notebook renderer and continued streaming the remaining diff files. With exploit requests delivered serially in the profiled environment, traversal then reached a02; its old side was transformed first, making a02.old the next Oj::Parser.usual.parse in that request and Puma worker.
The Next Parse Calls the Overwritten Pointer
Oj invoked the callback at the beginning of each parse:
parser_reset(p);
p->start(p);
parse(p, ptr);
Encoding 0x0000414141414141 into Array elements 176 and 177 placed the same value in the processor’s instruction pointer on the next notebook parse. GitLab independently reproduced the overwrite and reliable control transfer to the chosen callback address.
From p->start to system()
In the Oj binary shipped with the profiled GitLab build, the compiler kept the raw pointer returned by rb_string_value_ptr in R12 until the callback:
call rb_string_value_ptr
mov r12, rax
...
call qword ptr [rbx+0xd88]
At callback entry, R12 pointed to the raw bytes of a02.old; RBX and RDI held parser P. That build-specific register state made the trigger blob useful as both data and a small table of function pointers.
The verified callback target was G1 = libc_base + 0x661000 + 0x269b4a, or libruby + 0x269b4a:
mov rdi, r12
call qword ptr [r12+0x28]
The blob stored the NUL-terminated command at R12+0x00, G2 = libruby + 0x22d565 at R12+0x28, and system = libc + 0x58750 at R12+0x58. The command and its terminating NUL had to fit before offset 0x28, allowing at most 39 command bytes in this compact layout.
The first gadget moved the command buffer into RDI and called the pointer at offset 0x28. G1 was itself entered by a call, so calling system() directly from G1 would use the wrong 16-byte stack phase. Entering G2 added another eight-byte return address; G2’s call then entered system(R12) with RSP mod 16 = 8, as required by the SysV AMD64 ABI.
The register assignments, mapping delta, and gadget offsets came from the exact libruby and compiler build used by the profiled GitLab image.
The marker command ran as git, the account serving the Puma application. With the library base recovered in Part II, the corruption, callback overwrite, and trigger completed in seconds. CRuby VALUE encoding, jemalloc behavior, gadget offsets, and library deltas were build-specific; the two vulnerable Oj parser bugs spanned the GitLab releases described below.
Impact
A normal authenticated user who could push to a project and view its commit diff could reach this path from their own project. Exploitation required no administrator privileges, CI or runner access, victim interaction, or access to another user’s project.
Successful exploitation executed commands as git, the account running GitLab’s Puma workers. Its effective reach depends on deployment isolation, but it may include repository data, Rails secrets, service credentials, and internal services available to the application. This can lead to source-code disclosure, credential theft, repository modification, persistence, or lateral access.
Affected Versions
The notebook renderer is shared by GitLab Community Edition and Enterprise Edition and is not restricted by subscription tier. A stable self-managed release is affected when it contains the Oj::Parser.usual.parse notebook path and bundles Oj 3.13.0 through 3.17.1.
| Component | Affected versions | First fixed release |
|---|---|---|
| GitLab CE/EE | 15.2.0 through 18.10.7 | 18.10.8 |
| GitLab CE/EE | 18.11.0 through 18.11.4 | 18.11.5 |
| GitLab CE/EE | 19.0.0 through 19.0.1 | 19.0.2 |
| Oj published gems | 3.13.0 through 3.17.1 | 3.17.3 |
GitLab 15.1 and earlier used JSON.parse in this notebook path and are outside this chain. Releases from 15.2 through 18.9 did not receive dedicated backports because they were already outside GitLab’s security-maintained patch trains; those installations must move to a supported fixed release.
The same application ranges apply to Linux packages, Docker images, source installations, Geo, and cloud-native deployments. For Helm, Operator, and custom-image installations, the GitLab version in the Webservice image running Puma is authoritative. Under the default chart mappings, the first fixed charts are 9.10.8, 9.11.6, and 10.0.2 for the three fixed branches above; image overrides take precedence.
GitLab stated in the June 10 patch release that GitLab.com was already patched and Dedicated customers did not need to act. For release candidates, downstream packages, and forks, check the two source conditions above rather than relying only on a version label.
Oj 3.17.3 was the first published gem containing both parser fixes. Self-managed operators should upgrade to a current supported GitLab patch release.
The Broader Oj Review
In addition to the two parser bugs used in this chain, the broader Oj review resulted in nine published advisories across the parser, loader, dumper, and document APIs.
- 9 published Oj advisories
- CVE-2026-54502: stack buffer overflow in
Oj.dumpwith a large indent. - CVE-2026-54896: heap buffer overflow during exception serialization with a large indent.
- CVE-2026-54897: use-after-free in
Oj::Dociterators during reentrant close. - CVE-2026-54898: use-after-free in an SAJ callback after input mutation.
- CVE-2026-54899: use-after-free when toggling the parser’s symbol-key cache.
- CVE-2026-54900: negative-size
memcpyincreate_idattribute handling. - CVE-2026-54901: use-after-free from missing GC marking for
array_classandhash_class. - CVE-2026-54902: use-after-free in the SAJ long-key callback.
- CVE-2026-54903: integer overflow while loading a JSON string larger than 2 GiB.
- CVE-2026-54502: stack buffer overflow in
Timeline
8/8/2021: Oj’s new parser was merged with the unchecked nesting write and unsafe 16-bit key-length handling. Oj 3.13.0 was the first release containing that parser.
7/7/2022: GitLab changed notebook validation from JSON.parse to Oj::Parser.usual.parse; the change shipped in GitLab 15.2.0.
5/21/2026: The issues were reported to Oj.
5/27/2026: Oj merged the nesting-depth and overlong-key fixes after the vulnerable code had remained in the parser for 1,753 days.
6/4/2026: Oj 3.17.3 became the first published release containing the fixes.
6/5/2026: The remotely reachable notebook-diff chain was privately reported to GitLab.
6/8/2026: GitLab confirmed the vulnerability.
6/10/2026: GitLab released 19.0.2, 18.11.5, and 18.10.8 with Oj 3.17.3.
7/17/2026: GitLab resolved the report.