GitLab: RCE via notebooks and a patch with no CVE
A public exploit runs commands as git on unpatched GitLab 18.11.3 servers. The fix shipped as a bugfix, with no CVE. Analysis and lessons.
- AUTHOR
- Karol Rapacz / CEO of Breachroad · OSCP · PNPT
- PUBLISHED
- 27 July 2026
- READING TIME
- 17 min read
- TOPIC
- Vulnerabilities and CVEs
On 24 July 2026 researchers at depthfirst published working exploit code for GitLab. It runs commands as the git user on any self-managed server on version 18.11.3 that hasn’t taken the update. The attacker requirements are surprisingly low: any authenticated account with push access to any project.
But the most interesting part of this story isn’t the technique — elegant as it is. It’s that GitLab shipped the fix six weeks earlier, on 10 June, without marking it as a security fix. The bump of the Oj library to version 3.17.3 appeared under bug fixes rather than in the security-fix table. No CVE was assigned, no CVSS score given, no mention made of the attack chain.
For teams that prioritise updates based on security bulletins — which is most teams — that means one thing: for six weeks they had no signal that they should hurry.
Anatomy of the attack
The chain is unusual and worth following, because it shows how innocuous-looking features become exploitation primitives.
The origin: a JSON parser in Ruby
The root of the problem is two long-buried memory-safety flaws in Oj — a popular, fast JSON parser for Ruby. Neither on its own yielded code execution. Only chaining them together achieved remote code execution on a default GitLab installation.
That’s a classic pattern of modern exploitation: individual bugs are “just” a crash, and only their composition delivers control.
The carrier: a Jupyter notebook
Here it gets interesting. GitLab renders Jupyter notebook files (.ipynb) in a commit’s diff view — a convenience feature so reviewers see a readable notebook instead of raw JSON. To do that, the server must parse user-supplied JSON. And that’s how attacker data reaches the vulnerable parser.
The sequence runs like this:
- The attacker commits a crafted notebook to any project they can push to.
- They open that commit’s diff view. The server parses the notebook and the response leaks a heap pointer.
- They repeat this many times. Gathering enough leaks lets an automated probe locate the libraries in memory — defeating ASLR, the mechanism that randomises memory layout.
- Two further notebooks fire the payload. Knowing the memory layout, the attacker delivers the actual exploit.
The result is command execution as the git user — the account GitLab runs under.
Why that’s so dangerous
Compromising a GitLab server isn’t “one more server”. It’s usually the central point of trust in an organisation:
- All source code — intellectual property and a map of the application for an attacker.
- CI/CD secrets — environment variables, cloud tokens, deployment keys, registry credentials.
- Deployment pipelines — the ability to inject code that reaches production as a “legitimate” release.
- Access tokens — to integrations, package registries and external systems.
Compromising a version control system is therefore potentially an attack on the supply chain of everything that organisation produces — the mechanism we described in software supply chain attacks and with the npm and PyPI worm.
It’s also worth appreciating how low the bar is. “An authenticated user with push access” means, in most companies, every developer — and in open projects, potentially anyone who registers and gets access to a fork. This isn’t a scenario requiring administrative privilege.
Why a leaked pointer is so valuable to an attacker
It’s worth pausing on the step that looks unremarkable in the chain description: “the response leaks a heap pointer”. That’s what turns a crash into control.
Modern operating systems use ASLR (Address Space Layout Randomization) — each time a process starts, libraries and data structures land at random addresses. An attacker who can overwrite memory but doesn’t know where the code of interest sits has a serious problem: pointing at the wrong address crashes the process rather than yielding control.
Leaking a single pointer breaks that. Knowing one real address and which structure it belongs to lets you compute the location of everything else — because relative offsets inside a library are fixed. Hence the need to repeat the notebook step many times: each diff view supplies another piece of the puzzle until the probe maps the libraries in memory.
This has direct detection value. The leak-gathering phase is inherently noisy: it requires many repetitive requests in a short window, from one account, against the same resource type. Unlike the payload execution itself — a single, quiet event — this stage leaves a clear trace in application logs. It’s the best moment to catch the attack, provided someone looks at frequency patterns rather than just response codes.
The problem that matters more than the bug
If this story ended at “patch GitLab”, it wouldn’t warrant its own post. The valuable part concerns how the fix was communicated.
According to reporting, the Oj bump to 3.17.3 was listed in the 10 June release as an ordinary bug fix. There was no CVE, no CVSS, no description of the vector.
Why that matters operationally
Practically every mature organisation prioritises updates the same way:
- Fixes flagged as security → fast track (days).
- Functional fixes and bugs → standard track (weeks, next maintenance window).
If a critical fix lands in the second category, the process works exactly as designed — and produces a bad outcome. The team didn’t fail; the input failed.
That’s precisely the problem we describe in how to prioritise critical vulnerabilities: patching decisions are only as good as the data behind them. A silent fix is a gap in the input data, not in your process.
Why vendors do this
Let’s not assume bad faith — the reasons are often understandable, though the effect is the same:
- Uncertainty about exploitability. The team sees a memory bug in a dependency, fixes it, but has no proof it’s exploitable in the product’s context. Assigning a CVE feels premature.
- The bug is in a dependency, not the product. The CVE “belongs” to the library, and bumping the version is formally a dependency update.
- A wish to avoid panic before customers have applied the fix.
- Plain oversight in the classification process.
The trouble is that attackers diff releases anyway. Publishing a fix is a signal regardless of which table describes it — with the difference that an offensive researcher reads the diff while an overloaded administrator does not.
What to do — on the defensive side
Immediately
Check your version and update. If you self-host GitLab, establish the version and bring the instance to the current release. Version 18.11.3 without the 10 June update is vulnerable, and the exploit has been public since 24 July — so the rule is “days since publication equals urgency”.
Reduce exposure. A publicly reachable GitLab instance that doesn’t need to be public is unnecessary risk. If it serves only employees, restrict it to the corporate network or VPN.
Review logs retrospectively. The distinctive pattern here is repeatedly opening diff views for commits containing notebooks — that’s how pointer leaks are collected. Look for bursts of requests to diff endpoints from a single account, especially alongside commits of .ipynb files. Also: unusual child processes of the application server and new SSH keys or tokens.
Rotate secrets if you were in the exposure window. This is the most important step after updating. Since CI/CD secrets are the prize, the patch alone invalidates nothing. Rotate deployment tokens, cloud keys, registry credentials and pipeline environment variables (secrets management).
Systemically
Don’t rely solely on the “security fixes” table. Since critical fixes sometimes ship as bugfixes, the only dependable approach is regularly updating critical systems to current releases, regardless of how changes are classified. For GitLab, whose compromise grants access to everything, that’s a justified cost.
Monitor dependencies, not just products. The bug was in the Oj library. Organisations tracking only GitLab bulletins had no chance of noticing; those tracking the dependency ecosystem had a signal. That’s the practical case for maintaining a component inventory.
Restrict CI/CD permissions. Since compromising GitLab means access to secrets, minimise what you keep there. Short-lived tokens issued via OIDC instead of long-lived secrets in variables limits impact regardless of the vulnerability (OIDC and workload identity in CI/CD).
Treat version control like identity systems. The same protection level as a domain controller: MFA, restricted network access, monitoring, short patch windows. In many companies GitLab is protected less well than file servers, despite its compromise being far more costly — we cover the principles in DevSecOps and CI/CD security.
Building resilience to silent fixes
This is the most practical part, because the problem will recur. Below is an approach that limits impact regardless of how a vendor labels a change.
Segment systems by consequence of compromise, not by a “critical” label. Version control, identity systems, CI/CD platforms and management systems belong to a category where every update should take the fast track. Not because each one fixes a flaw, but because the cost of delay there is disproportionately high.
Adopt an “N-1” policy for critical systems. Instead of asking “does this release fix something important”, adopt the rule that an instance may be no more than one release behind current. That removes the need to assess each change individually and immunises you against vendor classification errors.
Track dependencies, not just products. Here the signal was in the Ruby ecosystem, not in GitLab’s announcements. Organisations monitoring advisories for the libraries their systems use had a chance to notice. That argues for a component inventory matched automatically against published advisories.
Read changelogs for critical systems. It sounds archaic, but for a handful of the most important systems it’s feasible. An update to a library that parses user-supplied data is a change worth noticing regardless of which section describes it.
Practise secret rotation as routine. Since you won’t always learn in time that you were vulnerable, treat rotation of CI/CD tokens as a periodic activity rather than an incident response. An environment where rotation is a rare, stressful event is one where nobody will complete it after a breach.
The wider context: memory safety in “safe” languages
It’s worth noting the source of the problem. Ruby manages memory automatically, so intuition suggests use-after-free and buffer overflows don’t apply.
Intuition misleads, because high-performance libraries are often written as native extensions in C. Oj is an example: it’s fast precisely because parsing is done by native code. With the performance comes back the entire class of memory bugs the language was meant to prevent.
This applies to every ecosystem: Python has C extensions, Node.js native modules, Java code via JNI. The memory-safety boundary runs not at the language level but at the level of a specific dependency. Practical takeaway: when inventorying components, it’s worth knowing which contain native code — that’s an elevated-risk area.
Frequently asked questions (FAQ)
We use GitLab.com (SaaS). Are we at risk? The described exploit targets self-managed instances that haven’t updated. In the managed offering the vendor applies updates. Remember the shared responsibility model though: the vendor patches the platform, you own permissions, pipeline secrets and project configuration.
There’s no CVE — does that mean it’s less serious?
No. The absence of a CVE describes the state of a classification process, not real risk. A public exploit running code as git is serious regardless of whether an identifier was assigned. It’s a good illustration that identifiers and scores are a convenient shortcut, not a measure of truth.
How do we detect whether someone already used this?
Look for bursts of requests to commit diff views involving .ipynb files, repeated many times by one account in a short window — that’s the pointer-leak stage. Then: unusual child processes of the application server, new SSH keys, unusual token use and unplanned pipeline runs.
Does disabling notebook rendering solve it? It removes one carrier but not the root cause — the vulnerable library still parses data on other paths. Treat it as a stopgap if you can’t update immediately, not as a solution.
How do we catch silent fixes in general? Three practices: update critical systems to current releases regardless of change classification; track advisories for the dependency ecosystem, not just the product vendor; and maintain a component inventory so you know what applies to you. If you want that structured as a process, we help as part of audits and testing.
Summary
This story has two layers and both are instructive. The technical one is an elegant chain: a crafted Jupyter notebook → a pointer leak via the diff view → ASLR bypass → code execution as git, all from an ordinary account with push access, built on two memory bugs in a native JSON parser.
The process layer matters more. The fix existed six weeks before the exploit was published, but was described as an ordinary bugfix — no CVE, no CVSS, no mention of the vector. Organisations doing exactly what good practice prescribes had no basis to prioritise it.
The conclusion is uncomfortable but practical: a vendor’s classification of a fix is a signal, not a guarantee. For systems whose compromise grants access to all your code and deployment secrets, the only resilient strategy is regular updating to current versions — regardless of which table the vendor used. If you want to know how fast you close such windows and what an attacker would gain from your GitLab — let’s verify it with a test.
Sources and further reading: The Hacker News, GitLab Releases, SecurityWeek, CSA Singapore.


