{"repro_id":"REPRO-2026-00208","version":8,"title":"Oj Ruby gem uninitialized stack memory leak via long JSON keys","repro_type":"security","status":"published","severity":"medium","description":"Oj (Optimized JSON) is a JSON parser and Object marshaller packaged as a Ruby gem. In versions prior to 3.17.3, Oj.load in :object mode reads uninitialized stack memory (and, for long keys, reads out of bounds) when parsing a JSON object whose key is 254 bytes or longer. In ext/oj/intern.c, form_attr() handles the long-key path by allocating a heap buffer, populating it with the attribute name, and then freeing it — but it passes the uninitialized stack buffer buf (not b) to rb_intern3(). rb_intern3 therefore reads len + 1 bytes of uninitialized stack memory. When the key length is >= 256, it also reads out of bounds past the 256-byte buf. The resulting bytes are interned and can reach the caller via the produced Symbol or via the EncodingError message raised on invalid UTF-8, leaking process stack contents. Fixed in version 3.17.3.","root_cause":"# RCA Report — CVE-2026-54500\n\n## Summary\n\nOj (Optimized JSON), a Ruby gem with a C extension, contains an uninitialized stack\nmemory read in `ext/oj/intern.c`'s `form_attr()` function. When `Oj.load` parses a\nJSON object in `:object` mode whose key is 254 bytes or longer, the long-key code\npath allocates a heap buffer `b`, correctly fills it with the attribute name, then\nfrees it — but passes the **uninitialized** 256-byte stack buffer `buf` (not `b`) to\n`rb_intern3()`. Ruby therefore interns `len + 1` bytes of uninitialized stack memory\n(and, for keys ≥ 256 bytes, reads out of bounds past `buf`). The leaked bytes surface\nto the caller via the produced Symbol or via the `EncodingError` message raised when\nthe stack garbage is not valid UTF-8, disclosing process stack contents. The fix is a\nsingle-character change: `rb_intern3(buf, ...)` → `rb_intern3(b, ...)`.\n\n## Impact\n\n- **Package/component:** `ohler55/oj` — C extension, `ext/oj/intern.c`, `form_attr()`\n- **Affected versions:** Oj 0.0.1 – 3.17.2 (fixed in 3.17.3)\n- **Risk level:** Medium\n- **Consequences:** Information disclosure of process stack memory. An attacker who\n  controls the JSON input (a key ≥ 254 bytes) can cause `Oj.load` to read and surface\n  uninitialized stack bytes. The leak is observable through the `EncodingError`\n  exception message (which embeds the invalid bytes) or through the produced Symbol\n  object. The exact bytes and message length vary between process invocations,\n  confirming the source is uninitialized (non-deterministic) memory.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Uninitialized stack memory read / out-of-bounds\n  read, leaking process stack contents via Symbol or EncodingError message.\n- **Reproduced impact from this run:** Uninitialized stack memory read confirmed.\n  Every vulnerable run raised an `EncodingError` whose message contained 1262–1423\n  bytes of non-input (leaked stack) data, with message lengths varying across runs\n  (1276–1432 bytes). The fixed version produced the correct, deterministic attribute\n  name with zero leaked bytes.\n- **Parity:** `full` — the disclosed information-disclosure symptom (uninitialized\n  stack memory surfacing via the EncodingError message, with per-run variation) was\n  reproduced exactly, and the negative control on the fixed commit confirmed the fix.\n- **Not demonstrated:** No code execution was claimed or demonstrated; this is an\n  information-disclosure / memory-read bug, not a code-execution vulnerability.\n\n## Root Cause\n\nIn `ext/oj/intern.c`, `form_attr(const char *str, size_t len)` converts a JSON object\nkey into a Ruby attribute ID (interned symbol). It declares a 256-byte stack buffer\n`buf` (uninitialized) and branches on key length:\n\n```c\nstatic VALUE form_attr(const char *str, size_t len) {\n    char buf[256];                              // UNINITIALIZED\n\n    if (sizeof(buf) - 2 <= len) {               // long-key path: len >= 254\n        char *b = OJ_R_ALLOC_N(char, len + 2);  // heap buffer\n        ID    id;\n        // ... b is filled correctly with '@' + key + '\\0' ...\n        id = rb_intern3(buf, len + 1, oj_utf8_encoding);  // BUG: reads `buf`, not `b`\n        OJ_R_FREE(b);\n        return id;\n    }\n    // short-key path: buf IS properly filled before use (correct)\n    ...\n    return (VALUE)rb_intern3(buf, len + 1, oj_utf8_encoding);\n}\n```\n\nIn the long-key path, `b` is the correctly-populated heap buffer, but `rb_intern3` is\ncalled with `buf` — the uninitialized stack buffer. `rb_intern3` reads `len + 1` bytes\nfrom `buf`. When `len >= 256`, this also reads out of bounds past the 256-byte `buf`.\nThe bytes are interned as a symbol; if they are not valid UTF-8, Ruby raises an\n`EncodingError` whose message includes the offending bytes, leaking them to the caller.\n\nThis is a duplicate of an earlier fix in `ext/oj/usual.c` that was missed in `intern.c`.\n\n**Call path:** `Oj.load(json, mode: :object)` → `object.c:oj_set_obj_ivar()` →\n`intern.c:oj_attr_intern()` → `cache.c:cache_intern()` → `intern.c:form_attr()`.\nSince `CACHE_MAX_KEY` is 35, keys ≥ 35 bytes bypass the cache and call `form_attr`\ndirectly every time, so the uninitialized read occurs on every invocation with a\nlong key.\n\n**Fix commit:** `bbde91a679728f94c4492ebc3683f4fa3309049f` (\"Fix intern.c and fast.c\n(#1015)\") — changes `rb_intern3(buf, len + 1, oj_utf8_encoding)` to\n`rb_intern3(b, len + 1, oj_utf8_encoding)` in the long-key path of `form_attr()`.\n\n## Reproduction Steps\n\n1. **Reference:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent).\n2. **What the script does:**\n   - Installs Ruby + build tools, clones (or reuses) `ohler55/oj`.\n   - Checks out the **vulnerable** commit `495cc38` (v3.17.2, parent of the fix),\n     builds the C extension via `ruby extconf.rb && make`.\n   - Runs `Oj.load('{\"^o\":\"Oj::Bag\",\"AAA...300...AAA\":1}', mode: :object)` in 6\n     separate Ruby processes. The `^o:Oj::Bag` marker creates a non-Hash object so\n     that `oj_set_obj_ivar` → `oj_attr_intern` → `form_attr` is invoked.\n   - Checks out the **fixed** commit `bbde91a`, rebuilds, and runs the same probe\n     6 times as a negative control.\n   - Compares results, writes `runtime_manifest.json`, and exits 0 if confirmed.\n3. **Expected evidence:**\n   - Vulnerable: all runs raise `EncodingError`; message lengths vary per run\n     (1276–1432 bytes), with 1262–1423 non-`A` (leaked stack) bytes.\n   - Fixed: all runs return an `Oj::Bag` with a single 301-byte instance variable\n     `@AAA...` (0x40 + 300×0x41), deterministic across all runs.\n\n## Evidence\n\n- **Log:** `bundle/logs/reproduction_steps.log` — full build + probe transcript.\n- **Vulnerable outcomes:** `bundle/logs/vuln_outcomes.txt`\n- **Fixed outcomes:** `bundle/logs/fixed_outcomes.txt`\n- **Message-length variation:** `bundle/logs/vuln_msg_lengths.txt`\n- **Probe script:** `bundle/repro/probe.rb`\n- **Runtime manifest:** `bundle/repro/runtime_manifest.json`\n\n### Key excerpts (from the second verification run)\n\n**Vulnerable (commit 495cc38, v3.17.2) — all 6 runs leak:**\n```\n[vuln run 1] encoding_error   MSG_LEN=1348  NON_A_BYTES=1339\n[vuln run 2] encoding_error   MSG_LEN=1349  NON_A_BYTES=1341\n[vuln run 3] encoding_error   MSG_LEN=1350  NON_A_BYTES=1343\n[vuln run 4] encoding_error   MSG_LEN=1276  NON_A_BYTES=1262\n[vuln run 5] encoding_error   MSG_LEN=1432  NON_A_BYTES=1423\n[vuln run 6] encoding_error   MSG_LEN=1368  NON_A_BYTES=1343\n```\nThe `EncodingError` message begins `invalid symbol in encoding UTF-8 :\"` followed by\nRuby `\\xNN` escapes of the leaked stack bytes (e.g. `\\xB8\\xFF`, `\\xD8\\xFF`, `\\xC0\\xFF`)\n— these are pointers/binary data, not the 0x41 (`A`) input bytes. The message length\nvaries across runs (1348–1432), which is impossible for deterministic, initialized\ndata and confirms the source is uninitialized stack memory.\n\n**Fixed (commit bbde91a) — all 6 runs clean:**\n```\n[fixed run 1] parsed  IVAR_LEN=301  CORRECT_ATTR=true  FIRST_BYTES=40414141...\n[fixed run 2] parsed  IVAR_LEN=301  CORRECT_ATTR=true  FIRST_BYTES=40414141...\n... (identical for all 6 runs)\n```\n`FIRST_BYTES` = `40` (`@`) + `41` (`A`) repeated — the correct, deterministic\nattribute name. No `EncodingError`, no leaked bytes.\n\n### Environment\n- Ruby 3.3.8 (x86_64-linux-gnu), GCC 15.2.0, Ubuntu.\n- Oj built from source at vulnerable commit `495cc38` and fixed commit `bbde91a`.\n\n## Recommendations / Next Steps\n\n- **Upgrade to Oj 3.17.3+** which contains the one-character fix.\n- **Audit `ext/oj/usual.c` and any other copies** of the `form_attr` pattern for\n  the same `buf`/`b` confusion (this was already a duplicate of a `usual.c` fix).\n- **Add a regression test** that parses a JSON object with a ≥ 254-byte key in\n  `:object` mode and asserts the resulting attribute name matches the input.\n- Consider compiling with `-ftrivial-auto-var-init=pattern` to make uninitialized\n  reads more visible in CI, and enabling MSan/ASan in the test suite.\n\n## Additional Notes\n\n- **Idempotency:** The script was run twice consecutively; both runs exited 0 with\n  `CONFIRMED=true`. The script cleans all build artifacts between vulnerable/fixed\n  builds (`git clean -fdx ext/oj lib/oj`) and uses a manual `extconf.rb + make` flow\n  (avoiding `rake compile`, which loads bundler and can interfere with the git\n  checkout state).\n- **Key-length boundary:** The bug triggers at `len >= 254` (`sizeof(buf) - 2 = 254`).\n  At `len >= 256` the read also goes out of bounds past the 256-byte `buf`. The\n  reproduction uses a 300-byte key to exercise both the uninitialized read and the\n  OOB read.\n- **Cache bypass:** Because `CACHE_MAX_KEY = 35`, the 300-byte key bypasses the\n  attribute cache entirely, so `form_attr` is called fresh on every invocation —\n  maximizing the observable per-run variation.\n","cve_id":"CVE-2026-54500","cwe_id":"CWE-125 (Out-of-bounds Read), CWE-908 (Uninitialized Resource)","source_url":"ohler55/oj","reproduced_at":"2026-07-02T19:44:32.031749+00:00","duration_secs":1241.0,"tool_calls":153,"handoffs":2,"total_cost_usd":2.43174137,"agent_costs":{"judge":0.017600449999999997,"repro":0.7952361599999997,"support":0.05901279,"vuln_variant":1.5598919700000005},"cost_breakdown":{"judge":{"gpt-5.4-mini":0.017600449999999997},"repro":{"accounts/fireworks/routers/glm-5p2-fast":0.7952361599999997},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.05901279},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.5598919700000005}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-02T19:44:32.960789+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":13652,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":8821,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":11122,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":12476,"category":"analysis"},{"path":"bundle/ticket.md","filename":"ticket.md","size":1027,"category":"ticket"},{"path":"bundle/ticket.json","filename":"ticket.json","size":1441,"category":"other"},{"path":"bundle/repro/probe.rb","filename":"probe.rb","size":1413,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":721,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":878,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":17882,"category":"log"},{"path":"bundle/logs/vuln_outcomes.txt","filename":"vuln_outcomes.txt","size":1002,"category":"other"},{"path":"bundle/logs/vuln_msg_lengths.txt","filename":"vuln_msg_lengths.txt","size":30,"category":"other"},{"path":"bundle/logs/vuln_run1.bin","filename":"vuln_run1.bin","size":1348,"category":"other"},{"path":"bundle/logs/vuln_run2.bin","filename":"vuln_run2.bin","size":1349,"category":"other"},{"path":"bundle/logs/vuln_run3.bin","filename":"vuln_run3.bin","size":1350,"category":"other"},{"path":"bundle/logs/vuln_run4.bin","filename":"vuln_run4.bin","size":1276,"category":"other"},{"path":"bundle/logs/vuln_run5.bin","filename":"vuln_run5.bin","size":1432,"category":"other"},{"path":"bundle/logs/vuln_run6.bin","filename":"vuln_run6.bin","size":1368,"category":"other"},{"path":"bundle/logs/vuln_leak_count","filename":"vuln_leak_count","size":2,"category":"other"},{"path":"bundle/logs/vuln_err_count","filename":"vuln_err_count","size":2,"category":"other"},{"path":"bundle/logs/vuln_correct_count","filename":"vuln_correct_count","size":2,"category":"other"},{"path":"bundle/logs/fixed_outcomes.txt","filename":"fixed_outcomes.txt","size":774,"category":"other"},{"path":"bundle/logs/fixed_msg_lengths.txt","filename":"fixed_msg_lengths.txt","size":0,"category":"other"},{"path":"bundle/logs/fixed_run1.bin","filename":"fixed_run1.bin","size":301,"category":"other"},{"path":"bundle/logs/fixed_run2.bin","filename":"fixed_run2.bin","size":301,"category":"other"},{"path":"bundle/logs/fixed_run3.bin","filename":"fixed_run3.bin","size":301,"category":"other"},{"path":"bundle/logs/fixed_run4.bin","filename":"fixed_run4.bin","size":301,"category":"other"},{"path":"bundle/logs/fixed_run5.bin","filename":"fixed_run5.bin","size":301,"category":"other"},{"path":"bundle/logs/fixed_run6.bin","filename":"fixed_run6.bin","size":301,"category":"other"},{"path":"bundle/logs/fixed_leak_count","filename":"fixed_leak_count","size":2,"category":"other"},{"path":"bundle/logs/fixed_err_count","filename":"fixed_err_count","size":2,"category":"other"},{"path":"bundle/logs/fixed_correct_count","filename":"fixed_correct_count","size":2,"category":"other"},{"path":"bundle/logs/vuln_variant_repro.log","filename":"vuln_variant_repro.log","size":13939,"category":"log"},{"path":"bundle/logs/vuln_variant_outcomes.txt","filename":"vuln_variant_outcomes.txt","size":9940,"category":"other"},{"path":"bundle/logs/fixed_variant_outcomes.txt","filename":"fixed_variant_outcomes.txt","size":9408,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":131,"category":"other"},{"path":"bundle/vuln_variant/probe_variant.rb","filename":"probe_variant.rb","size":5239,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":862,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6424,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4135,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":3747,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1511,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":2434,"category":"other"}]}