Skip to content

CVE-2026-54500: Verified Reproduction

CVE-2026-54500: Oj Ruby gem uninitialized stack memory leak via long JSON keys

CVE-2026-54500 is verified against the affected target. This medium reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00208.

REPRO-2026-00208 Jul 2, 2026 CVE entry .txt
Severity
MEDIUM
CVSS
5.3
Confidence
HIGH
Reproduced in
20m 41s
Tool calls
153
Spend
$2.43
01 · Overview

What Is CVE-2026-54500?

CVE-2026-54500 is a medium-severity information disclosure vulnerability (CWE-125 out-of-bounds read, CWE-908 uninitialized resource) in the Oj Ruby JSON gem. Parsing a JSON object with a key 254 bytes or longer in :object mode leaks uninitialized process stack memory. Pruva reproduced it (reproduction REPRO-2026-00208).

02 · Severity & CVSS

CVE-2026-54500 Severity & CVSS Score

CVE-2026-54500 is rated medium severity, with a CVSS base score of 5.3 out of 10.

MEDIUM threat level
5.3 / 10 CVSS base
Weakness CWE-125 (Out-of-bounds Read), CWE-908 (Uninitialized Resource) — Out-of-bounds Read

Medium — meaningful risk under specific conditions. Schedule a fix in the normal cycle.

How to Reproduce CVE-2026-54500

$ pruva-verify REPRO-2026-00208
or curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00208/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-54500

Information disclosure — reproduced
  • reached the target end-to-end
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

JSON object key >= 254 bytes (300 'A' chars) supplied to Oj.load in :object mode

Attack chain
  1. Oj.load(json, mode: :object)
  2. object.c:oj_set_obj_ivar
  3. intern.c:oj_attr_intern
  4. cache.c:cache_intern (bypasses cache, len>=35)
  5. intern.c:form_attr long-key path: rb_intern3(uninitialized buf, len+1) reads stack memory
How the agent worked 366 events · 153 tool calls · 21 min
21 minDuration
153Tool calls
99Reasoning steps
366Events
Agent activity over 21 min
Support
17
Repro
144
Judge
28
Variant
173
0:0020:41

Root Cause and Exploit Chain for CVE-2026-54500

Versions: Oj 0.0.1 – 3.17.2 (fixed in 3.17.3)

Oj (Optimized JSON), a Ruby gem with a C extension, contains an uninitialized stack memory read in ext/oj/intern.c's form_attr() function. When Oj.load parses a JSON object in :object mode whose key is 254 bytes or longer, the long-key code path allocates a heap buffer b, correctly fills it with the attribute name, then frees it — but passes the uninitialized 256-byte stack buffer buf (not b) to rb_intern3(). Ruby therefore interns len + 1 bytes of uninitialized stack memory (and, for keys ≥ 256 bytes, reads out of bounds past buf). The leaked bytes surface to the caller via the produced Symbol or via the EncodingError message raised when the stack garbage is not valid UTF-8, disclosing process stack contents. The fix is a single-character change: rb_intern3(buf, ...)rb_intern3(b, ...).

  • Package/component: ohler55/oj — C extension, ext/oj/intern.c, form_attr()
  • Affected versions: Oj 0.0.1 – 3.17.2 (fixed in 3.17.3)
  • Risk level: Medium
  • Consequences: Information disclosure of process stack memory. An attacker who controls the JSON input (a key ≥ 254 bytes) can cause Oj.load to read and surface uninitialized stack bytes. The leak is observable through the EncodingError exception message (which embeds the invalid bytes) or through the produced Symbol object. The exact bytes and message length vary between process invocations, confirming the source is uninitialized (non-deterministic) memory.

Impact Parity

  • Disclosed/claimed maximum impact: Uninitialized stack memory read / out-of-bounds read, leaking process stack contents via Symbol or EncodingError message.
  • Reproduced impact from this run: Uninitialized stack memory read confirmed. Every vulnerable run raised an EncodingError whose message contained 1262–1423 bytes of non-input (leaked stack) data, with message lengths varying across runs (1276–1432 bytes). The fixed version produced the correct, deterministic attribute name with zero leaked bytes.
  • Parity: full — the disclosed information-disclosure symptom (uninitialized stack memory surfacing via the EncodingError message, with per-run variation) was reproduced exactly, and the negative control on the fixed commit confirmed the fix.
  • Not demonstrated: No code execution was claimed or demonstrated; this is an information-disclosure / memory-read bug, not a code-execution vulnerability.

Root Cause

In ext/oj/intern.c, form_attr(const char *str, size_t len) converts a JSON object key into a Ruby attribute ID (interned symbol). It declares a 256-byte stack buffer buf (uninitialized) and branches on key length:

static VALUE form_attr(const char *str, size_t len) {
    char buf[256];                              // UNINITIALIZED

    if (sizeof(buf) - 2 <= len) {               // long-key path: len >= 254
        char *b = OJ_R_ALLOC_N(char, len + 2);  // heap buffer
        ID    id;
        // ... b is filled correctly with '@' + key + '\0' ...
        id = rb_intern3(buf, len + 1, oj_utf8_encoding);  // BUG: reads `buf`, not `b`
        OJ_R_FREE(b);
        return id;
    }
    // short-key path: buf IS properly filled before use (correct)
    ...
    return (VALUE)rb_intern3(buf, len + 1, oj_utf8_encoding);
}

In the long-key path, b is the correctly-populated heap buffer, but rb_intern3 is called with buf — the uninitialized stack buffer. rb_intern3 reads len + 1 bytes from buf. When len >= 256, this also reads out of bounds past the 256-byte buf. The bytes are interned as a symbol; if they are not valid UTF-8, Ruby raises an EncodingError whose message includes the offending bytes, leaking them to the caller.

This is a duplicate of an earlier fix in ext/oj/usual.c that was missed in intern.c.

Call path: Oj.load(json, mode: :object)object.c:oj_set_obj_ivar()intern.c:oj_attr_intern()cache.c:cache_intern()intern.c:form_attr(). Since CACHE_MAX_KEY is 35, keys ≥ 35 bytes bypass the cache and call form_attr directly every time, so the uninitialized read occurs on every invocation with a long key.

Fix commit: bbde91a679728f94c4492ebc3683f4fa3309049f ("Fix intern.c and fast.c (#1015)") — changes rb_intern3(buf, len + 1, oj_utf8_encoding) to rb_intern3(b, len + 1, oj_utf8_encoding) in the long-key path of form_attr().

Reproduction Steps

  1. Reference: bundle/repro/reproduction_steps.sh (self-contained, idempotent).
  2. What the script does:
    • Installs Ruby + build tools, clones (or reuses) ohler55/oj.
    • Checks out the vulnerable commit 495cc38 (v3.17.2, parent of the fix), builds the C extension via ruby extconf.rb && make.
    • Runs Oj.load('{"^o":"Oj::Bag","AAA...300...AAA":1}', mode: :object) in 6 separate Ruby processes. The ^o:Oj::Bag marker creates a non-Hash object so that oj_set_obj_ivaroj_attr_internform_attr is invoked.
    • Checks out the fixed commit bbde91a, rebuilds, and runs the same probe 6 times as a negative control.
    • Compares results, writes runtime_manifest.json, and exits 0 if confirmed.
  3. Expected evidence:
    • Vulnerable: all runs raise EncodingError; message lengths vary per run (1276–1432 bytes), with 1262–1423 non-A (leaked stack) bytes.
    • Fixed: all runs return an Oj::Bag with a single 301-byte instance variable @AAA... (0x40 + 300×0x41), deterministic across all runs.

Evidence

  • Log: bundle/logs/reproduction_steps.log — full build + probe transcript.
  • Vulnerable outcomes: bundle/logs/vuln_outcomes.txt
  • Fixed outcomes: bundle/logs/fixed_outcomes.txt
  • Message-length variation: bundle/logs/vuln_msg_lengths.txt
  • Probe script: bundle/repro/probe.rb
  • Runtime manifest: bundle/repro/runtime_manifest.json
Key excerpts (from the second verification run)

Vulnerable (commit 495cc38, v3.17.2) — all 6 runs leak:

[vuln run 1] encoding_error   MSG_LEN=1348  NON_A_BYTES=1339
[vuln run 2] encoding_error   MSG_LEN=1349  NON_A_BYTES=1341
[vuln run 3] encoding_error   MSG_LEN=1350  NON_A_BYTES=1343
[vuln run 4] encoding_error   MSG_LEN=1276  NON_A_BYTES=1262
[vuln run 5] encoding_error   MSG_LEN=1432  NON_A_BYTES=1423
[vuln run 6] encoding_error   MSG_LEN=1368  NON_A_BYTES=1343

The EncodingError message begins invalid symbol in encoding UTF-8 :" followed by Ruby \xNN escapes of the leaked stack bytes (e.g. \xB8\xFF, \xD8\xFF, \xC0\xFF) — these are pointers/binary data, not the 0x41 (A) input bytes. The message length varies across runs (1348–1432), which is impossible for deterministic, initialized data and confirms the source is uninitialized stack memory.

Fixed (commit bbde91a) — all 6 runs clean:

[fixed run 1] parsed  IVAR_LEN=301  CORRECT_ATTR=true  FIRST_BYTES=40414141...
[fixed run 2] parsed  IVAR_LEN=301  CORRECT_ATTR=true  FIRST_BYTES=40414141...
... (identical for all 6 runs)

FIRST_BYTES = 40 (@) + 41 (A) repeated — the correct, deterministic attribute name. No EncodingError, no leaked bytes.

Environment
  • Ruby 3.3.8 (x86_64-linux-gnu), GCC 15.2.0, Ubuntu.
  • Oj built from source at vulnerable commit 495cc38 and fixed commit bbde91a.

Recommendations / Next Steps

  • Upgrade to Oj 3.17.3+ which contains the one-character fix.
  • Audit ext/oj/usual.c and any other copies of the form_attr pattern for the same buf/b confusion (this was already a duplicate of a usual.c fix).
  • Add a regression test that parses a JSON object with a ≥ 254-byte key in :object mode and asserts the resulting attribute name matches the input.
  • Consider compiling with -ftrivial-auto-var-init=pattern to make uninitialized reads more visible in CI, and enabling MSan/ASan in the test suite.

Additional Notes

  • Idempotency: The script was run twice consecutively; both runs exited 0 with CONFIRMED=true. The script cleans all build artifacts between vulnerable/fixed builds (git clean -fdx ext/oj lib/oj) and uses a manual extconf.rb + make flow (avoiding rake compile, which loads bundler and can interfere with the git checkout state).
  • Key-length boundary: The bug triggers at len >= 254 (sizeof(buf) - 2 = 254). At len >= 256 the read also goes out of bounds past the 256-byte buf. The reproduction uses a 300-byte key to exercise both the uninitialized read and the OOB read.
  • Cache bypass: Because CACHE_MAX_KEY = 35, the 300-byte key bypasses the attribute cache entirely, so form_attr is called fresh on every invocation — maximizing the observable per-run variation.

CVE-2026-54500 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:000:58
0:00
session startedaccounts/fireworks/routers/glm-5p2-fast · CVE-2026-54500 · REPRO-20
0:03
0:04
web search
0:07
web search
0:08
0:10
0:11
0:13
0:14
0:26
0:26
extract_facts
no facts extracted
0:27
0:27
0:27
supportrepro
0:29
0:29
0:30
0:30
0:32
0:32
0:37
0:37
$ls -la /data/pruva/project-cache/dc167dac-a6d2-43f6-837d-84c9d571596f/ 2>/dev/null && echo "---REPO---" && ls -la /data/pruva/project-cache/dc167dac-a6d2-43f6-837d-84c9d571596f/repo/ 2>/dev/null && echo "---MIRRORS---" && ls -la /data/pruva/project-cache/dc167dac-a6d2-43f6-837d-84c9d571596f/repo-mirrors/ 2>/dev/null
total 0
drwxr-xr-x 1 vscode vscode   24 Jul  2 17:17 .
drwxr-xr-x 1 vscode vscode 1390 Jul  2 17:17 ..
drwxr-xr-x 1 vscode vscode    0 Jul  2 17:17 repo-mirrors
---REPO---
0:37
$which ruby; ruby --version 2>/dev/null; which gem; gem --version 2>/dev/null; which bundle 2>/dev/null; which gcc; gcc --version 2>/dev/null | head -1
/usr/bin/gcc
gcc (Ubuntu 15.2.0-16ubuntu1) 15.2.0
0:42
0:46
web search
0:52
$sudo apt-get update -qq 2>&1 | tail -3; echo "===INSTALL RUBY==="; sudo apt-get install -y ruby ruby-dev build-essential 2>&1 | tail -15
6.9s
===INSTALL RUBY===
Setting up ruby-webrick (1.9.2-1) ...
Setting up libjs-jquery (3.7.1+dfsg+~3.5.33-1build1) ...
Setting up ruby-did-you-mean (2.0.0-1) ...
Setting up ruby-xmlrpc (0.3.3-2build1) ...
Setting up ruby3.3 (3.3.8-2ubuntu3) ...
Setting up libruby:amd64 (1:3.3build1) ...
Setting up ruby (1:3.3build1) ...
0:58
08 · How to Fix

How to Fix CVE-2026-54500

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-54500 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-54500

How does the Oj stack-leak issue surface?

The leaked stack bytes reach the caller either as the produced Ruby Symbol object or via the EncodingError message raised when the garbage bytes are not valid UTF-8, disclosing process stack contents. The exact bytes vary between process invocations, confirming the source is uninitialized memory.

Which versions of Oj are affected by CVE-2026-54500, and where is it fixed?

Oj versions 0.0.1 through 3.17.2 are affected. It is fixed in 3.17.3, via a single-character change: rb_intern3(buf, ...) is corrected to rb_intern3(b, ...).

How severe is CVE-2026-54500?

Medium severity — an information disclosure of process stack memory, observable through the EncodingError message or the resulting Symbol, rather than code execution.

How can I reproduce CVE-2026-54500?

Download the verified script from this page and run it in an isolated environment against Oj <= 3.17.2. Call Oj.load(json, mode: :object) with a JSON object whose key is 254 bytes or longer and observe leaked uninitialized stack bytes in the resulting Symbol or EncodingError message; confirm 3.17.3 no longer leaks.
11 · References

References for CVE-2026-54500

Authoritative sources for CVE-2026-54500 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.