# Patch Analysis — CVE-2026-54500

## Fix under analysis

- **Commit:** `bbde91a679728f94c4492ebc3683f4fa3309049f` ("Fix intern.c and fast.c (#1015)")
- **Version:** Oj 3.17.3 (parent: `495cc38`, v3.17.2)
- **Author:** Peter Ohler, 2026-06-04
- **Files changed:** `ext/oj/intern.c`, `ext/oj/fast.c`, `CHANGELOG.md`, `lib/oj/version.rb`

## What the fix changes (files, functions, logic)

### 1. `ext/oj/intern.c` — the CVE-2026-54500 fix (1 line)

In `form_attr(const char *str, size_t len)`, the long-key branch (triggered when
`sizeof(buf) - 2 <= len`, i.e. `len >= 254`):

```diff
-        id = rb_intern3(buf, len + 1, oj_utf8_encoding);
+        id = rb_intern3(b, len + 1, oj_utf8_encoding);
```

Before the fix, the function allocated a heap buffer `b`, correctly filled it with
`'@' + key + '\0'` (or the `~`-prefix variant), then freed it — but passed the
**uninitialized** 256-byte stack buffer `buf` to `rb_intern3()`. After the fix, the
correctly-populated heap buffer `b` is interned. The short-key branch (len < 254) still
uses `buf` but is correct there because `buf` is filled by `memcpy` before use.

### 2. `ext/oj/fast.c` — a SEPARATE, unrelated fix (depth overflow)

In `doc_each_child()`, the fix adds a `MAX_STACK` depth guard and a matching
`doc->where--` decrement:

```diff
+            if (MAX_STACK <= (doc->where + 1) - doc->where_path) {
+                rb_raise(rb_const_get_at(Oj, rb_intern("DepthError")), "Path too deep. Limit is %d levels.", MAX_STACK);
+            }
             doc->where++;
             ...
+            doc->where--;
```

**This is NOT a variant of CVE-2026-54500.** It is a different root cause (unbounded
`doc->where` pointer increment → out-of-bounds stack-path write/read in the `Oj::Doc`
fast parser), a different sink (`doc_each_child`, not `form_attr`), and a different
impact class (depth/stack overflow, not uninitialized stack memory leak). Per variant
analysis rules, a separate bug fixed in the same commit is not a bypass of the
`intern.c` fix.

### 3. `lib/oj/version.rb` — `3.17.2` → `3.17.3`; `CHANGELOG.md` — entry added.

## What assumptions the fix makes

1. **Single reachable sink:** The fix assumes the only attacker-reachable path to the
   uninitialized-stack read is `intern.c:form_attr()`'s long-key branch. This is
   correct: `oj_attr_intern` (the public entry to `intern.c`'s attr cache) is called
   only from `object.c:oj_set_obj_ivar`, which is only invoked by the `:object` mode
   parser.
2. **No other copy is still buggy:** The fix does not touch `usual.c`, which has an
   identical `form_attr`. This is safe because `usual.c` was already fixed in
   `ec368db` (#1014), an ancestor of v3.17.2 (verified via
   `git merge-base --is-ancestor ec368db 495cc38` → YES, and
   `git show 495cc38:ext/oj/usual.c` already has `rb_intern3(b, ...)`).
3. **Short-key path is correct:** The fix leaves the short-key `rb_intern3(buf, ...)`
   untouched; this is correct because `buf` is filled by `memcpy` + null-terminate
   before that call.

## What code paths / inputs the fix does NOT cover (and why each is safe)

| Path / input | Reaches `intern.c form_attr`? | Status |
|---|---|---|
| `Oj.load :object` with key ≥ 254 | **Yes** | **Covered by fix** (buf→b) |
| `Oj.load :compat` / `:rails` (any key) | No — dispatches to `oj_compat_parse` (compat.c); uses `oj_calc_hash_key` + `json_create` | Safe (never uses `form_attr`) |
| `Oj.load :strict` / `:null` | No — strict.c uses `rb_intern3(parent->key, parent->klen, ...)` from the parsed key | Safe (no stack buffer) |
| `Oj.load :wab` | No — wab.c uses `oj_sym_intern` → `form_sym` (builds Ruby `String` first) | Safe (no stack buffer) |
| `Oj.load :custom` | No — custom.c uses `oj_calc_hash_key` | Safe |
| `Oj::Parser.new(:usual)` + create_id (long key) | No — reaches `usual.c form_attr` (already fixed in `ec368db`) | Safe (pre-fixed) |
| `Oj::Parser.new(:object)` | N/A — **unimplemented** (`// TBD`, parser.c:1263) | N/A |
| `fast.c doc_each_child` depth overflow | No — separate bug, separate sink | Covered by the `fast.c` portion of the same commit (not a CVE-2026-54500 variant) |

## Whether the fix is complete or leaves gaps

**The fix is complete for CVE-2026-54500.** There is exactly one reachable copy of the
vulnerable `form_attr` long-key pattern (`intern.c`), and the one-character change
closes it. The duplicate copy (`usual.c`) was already fixed before the vulnerable
version. No other parse mode or API reaches the `intern.c` sink, and no third copy of
the pattern exists in the codebase (confirmed by enumerating all `rb_intern3` call sites
and all `char buf[256]` + `OJ_R_ALLOC_N(char, len` patterns).

**Empirical confirmation:** An 11-mode × 4-run sweep on both the vulnerable and fixed
versions shows only `:object` mode leaks (only on the vulnerable version); all other
modes are clean on both versions; the fixed version is clean on all modes. See
`bundle/vuln_variant/rca_report.md` and `bundle/logs/vuln_variant_outcomes.txt`.

**Residual (non-security) gaps worth noting for the Coding stage:**
- The `intern.c` and `usual.c` copies of `form_attr` have diverged: `intern.c` handles a
  `~`-prefix key case; `usual.c` does not. Unifying them would prevent future
  copy-paste regressions (this bug class already occurred twice).
- `Oj::Parser.new(:object)` is an unimplemented placeholder and should be implemented or
  removed.

## Behavior before vs. after the fix

| | Before (`495cc38`, v3.17.2) | After (`bbde91a`, v3.17.3) |
|---|---|---|
| `Oj.load('{"^o":"Oj::Bag","<300 'A's>":1}', mode: :object)` | `EncodingError` with 1250–1426 non-input (leaked stack) bytes; `MSG_LEN` varies 1271–1432 per run (non-deterministic) | `Oj::Bag` with `@AAA…` (301 bytes: `0x40` + 300×`0x41`), deterministic across all runs |
| All other modes (compat/rails/strict/null/wab/custom + newer parser) | Correct, deterministic | Correct, deterministic |

## Target threat model

`SECURITY.md` is a generic boilerplate template (supported-versions table + "report via
issue" instructions) with **no explicit threat-model exclusions**. There is no statement
that memory-safety issues from attacker-controlled input are out of scope. The bug is
therefore in-scope: an attacker who controls JSON input (crossing a trust boundary into
a process that calls `Oj.load(..., mode: :object)`) can disclose process stack memory.
