Hello,

I am a member of the Debian Ruby Team, and this is my first time attempting an 
LTS update. I have claimed ruby-oj in dla-needed.txt, per the guide. Bookworm 
has 3.14.2-1, which is still affected by the memory-safety bugs fixed upstream 
in ruby-oj 3.17.2 and 3.17.3. I backported the same two patches from the 
trixie-security upload, which is currently pending, but has been approved by 
the Security Team. The debdiff against 3.14.2-1 is attached; I have not 
uploaded it.

The patches are CVE-2026-oj-security-ec368db.patch (the 3.17.2 batch, 
CVE-2026-54502 and CVE-2026-54896 through CVE-2026-54903) and 
CVE-2026-oj-security-bbde91a.patch (CVE-2026-54500 and CVE-2026-54592). The 
first one also includes the saj2.c key-stack mark. Upstream's 3.17.2 commit 
describes that fix but never actually changes saj2.c, so I folded it in, same 
as Trixie.

I also ran all 11 upstream proof of concepts against the archive package and 
against this build. The archive package hits every one of them, this package 
does not, and ordinary load/dump still works.

Salsa CI has passed: 
https://salsa.debian.org/ruby-team/ruby-oj/-/pipelines/1177811

You can find built debs here: 
https://people.debian.org/~tsimonq2/ruby-oj-3.14.2-1+deb12u1/

Feedback is welcome. I plan on uploading this on 2026-09-30, very early UTC. If 
I should just go ahead, let me know.

Best regards,
Simon Quigley
[email protected]
diff --git a/debian/changelog b/debian/changelog
index 6080c33..f1d5b97 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,20 @@
+ruby-oj (3.14.2-1+deb12u1) bookworm-security; urgency=high
+
+  * Fix multiple memory-safety issues in the Oj C extension:
+    - CVE-2026-54502 / CVE-2026-54896: clamp extreme :indent (stack/heap
+      overflow)
+    - CVE-2026-54897: Oj::Doc iterator use-after-free on reentrant close
+    - CVE-2026-54898: parser use-after-free when callbacks mutate input
+    - CVE-2026-54899: symbol_keys cache use-after-free
+    - CVE-2026-54900: create_id / form_attr negative-size memcpy
+    - CVE-2026-54901: unmarked hash_class / array_class GC use-after-free
+    - CVE-2026-54902: SAJ long-key use-after-free
+    - CVE-2026-54903: integer overflow / heap corruption on >2GB strings
+    - CVE-2026-54500: uninitialized / OOB read on long keys in intern.c
+    - CVE-2026-54592: stack overflow in Oj::Doc#each_child on deep nesting
+
+ -- Simon Quigley <[email protected]>  Sat, 26 Sep 2026 18:24:15 -0500
+
 ruby-oj (3.14.2-1) unstable; urgency=medium
 
   * Team upload
diff --git a/debian/patches/CVE-2026-oj-security-bbde91a.patch 
b/debian/patches/CVE-2026-oj-security-bbde91a.patch
new file mode 100644
index 0000000..5e82b81
--- /dev/null
+++ b/debian/patches/CVE-2026-oj-security-bbde91a.patch
@@ -0,0 +1,40 @@
+Description: backport of upstream intern.c / fast.c security fixes (bbde91a / 
#1015)
+ Backport C security fixes from upstream commit bbde91a (v3.17.3) covering
+ CVE-2026-54500 and CVE-2026-54592: correct rb_intern3 buffer pointer in
+ form_attr, and add DepthError / where-- in doc_each_child.
+Origin: 
https://github.com/ohler55/oj/commit/bbde91a679728f94c4492ebc3683f4fa3309049f
+Forwarded: not-needed
+Last-Update: 2026-09-26
+---
+
+--- a/ext/oj/fast.c
++++ b/ext/oj/fast.c
+@@ -1515,6 +1515,9 @@
+             Leaf first = (*doc->where)->elements->next;
+             Leaf e     = first;
+ 
++            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++;
+             do {
+                 *doc->where = e;
+@@ -1524,6 +1527,7 @@
+                 }
+                 e = e->next;
+             } while (e != first);
++            doc->where--;
+         }
+         if (0 < wlen) {
+             memcpy(doc->where_path, save_path, sizeof(Leaf) * (wlen + 1));
+--- a/ext/oj/intern.c
++++ b/ext/oj/intern.c
+@@ -69,7 +69,7 @@
+           memcpy(b + 1, str, len);
+           b[len + 1] = '\0';
+       }
+-        id = rb_intern3(buf, len + 1, oj_utf8_encoding);
++        id = rb_intern3(b, len + 1, oj_utf8_encoding);
+         OJ_R_FREE(b);
+         return id;
+     }
diff --git a/debian/patches/CVE-2026-oj-security-ec368db.patch 
b/debian/patches/CVE-2026-oj-security-ec368db.patch
new file mode 100644
index 0000000..3e3812b
--- /dev/null
+++ b/debian/patches/CVE-2026-oj-security-ec368db.patch
@@ -0,0 +1,321 @@
+Description: backport of upstream stack/extreme-size security fixes (ec368db / 
#1014)
+ Backport C security fixes from upstream commit ec368db (v3.17.2) covering
+ CVE-2026-54502 and CVE-2026-54896 through CVE-2026-54903.
+ .
+ Includes CVE-2026-54902 saj2.c mark() fix: always rb_gc_mark SAJ stack keys
+ (drop if (!cache_keys) guard). Upstream ec368db claimed the SAJ long-key UAF
+ fixed but omitted this hunk; folded here rather than a separate patch.
+ .
+ Keep oj_rxclass_match(int len) unchanged for ABI; gate call sites with
+ INT_MAX before casting. parser_parse freezes an rb_str_dup copy, never the
+ caller string. On 3.14.2 that function uses DATA_PTR rather than
+ TypedData_Get_Struct. Dropped non-security hunks: CHANGELOG, version.rb,
+ notes, safe.c.
+Origin: 
https://github.com/ohler55/oj/commit/ec368dbe936ef0104b782e4b0f67b17d6c7276f7
+Forwarded: not-needed
+Last-Update: 2026-09-26
+---
+
+--- a/ext/oj/compat.c
++++ b/ext/oj/compat.c
+@@ -2,6 +2,7 @@
+ // Licensed under the MIT License. See LICENSE file in the project root for 
license details.
+ 
+ #include <stdio.h>
++#include <limits.h>
+ 
+ #include "mem.h"
+ #include "encode.h"
+@@ -40,6 +41,9 @@
+             }
+         }
+         if (Yes == pi->options.create_ok && NULL != pi->options.str_rx.head) {
++            if (INT_MAX < len) {
++                rb_raise(rb_eArgError, "string too long");
++            }
+             VALUE clas = oj_rxclass_match(&pi->options.str_rx, str, (int)len);
+ 
+             if (Qnil != clas) {
+@@ -97,6 +101,9 @@
+     volatile VALUE rstr = oj_cstr_to_value(str, len, 
(size_t)pi->options.cache_str);
+ 
+     if (Yes == pi->options.create_ok && NULL != pi->options.str_rx.head) {
++        if (INT_MAX < len) {
++            rb_raise(rb_eArgError, "string too long");
++        }
+         VALUE clas = oj_rxclass_match(&pi->options.str_rx, str, (int)len);
+ 
+         if (Qnil != clas) {
+@@ -176,6 +183,9 @@
+     volatile VALUE rstr = oj_cstr_to_value(str, len, 
(size_t)pi->options.cache_str);
+ 
+     if (Yes == pi->options.create_ok && NULL != pi->options.str_rx.head) {
++        if (INT_MAX < len) {
++            rb_raise(rb_eArgError, "string too long");
++        }
+         VALUE clas = oj_rxclass_match(&pi->options.str_rx, str, (int)len);
+ 
+         if (Qnil != clas) {
+--- a/ext/oj/custom.c
++++ b/ext/oj/custom.c
+@@ -2,6 +2,7 @@
+ // Licensed under the MIT License. See LICENSE file in the project root for 
license details.
+ 
+ #include <stdint.h>
++#include <limits.h>
+ #include <stdio.h>
+ 
+ #include "mem.h"
+@@ -917,6 +918,9 @@
+             }
+         }
+         if (Yes == pi->options.create_ok && NULL != pi->options.str_rx.head) {
++            if (INT_MAX < len) {
++                rb_raise(rb_eArgError, "string too long");
++            }
+             VALUE clas = oj_rxclass_match(&pi->options.str_rx, str, (int)len);
+ 
+             if (Qnil != clas) {
+@@ -1030,6 +1034,9 @@
+     volatile VALUE rstr = rb_utf8_str_new(str, len);
+ 
+     if (Yes == pi->options.create_ok && NULL != pi->options.str_rx.head) {
++        if (INT_MAX < len) {
++            rb_raise(rb_eArgError, "string too long");
++        }
+         VALUE clas = oj_rxclass_match(&pi->options.str_rx, str, (int)len);
+ 
+         if (Qnil != clas) {
+--- a/ext/oj/fast.c
++++ b/ext/oj/fast.c
+@@ -80,7 +80,7 @@
+ static int   move_step(Doc doc, const char *path, int loc);
+ static Leaf  get_doc_leaf(Doc doc, const char *path);
+ static Leaf  get_leaf(Leaf *stack, Leaf *lp, const char *path);
+-static void  each_value(Doc doc, Leaf leaf);
++static void  each_value(Doc doc, Leaf leaf, VALUE self);
+ 
+ VALUE oj_doc_class = Qundef;
+ 
+@@ -956,6 +956,9 @@
+         }
+     } else {
+         rb_yield(self);
++        if (NULL == DATA_PTR(self)) {
++            rb_raise(rb_eIOError, "Document closed.");
++        }
+     }
+ }
+ 
+@@ -1049,19 +1052,22 @@
+     return loc;
+ }
+ 
+-static void each_value(Doc doc, Leaf leaf) {
++static void each_value(Doc doc, Leaf leaf, VALUE self) {
+     if (COL_VAL == leaf->value_type) {
+         if (0 != leaf->elements) {
+             Leaf first = leaf->elements->next;
+             Leaf e     = first;
+ 
+             do {
+-                each_value(doc, e);
++                each_value(doc, e, self);
+                 e = e->next;
+             } while (e != first);
+         }
+     } else {
+         rb_yield(leaf_value(doc, leaf));
++        if (NULL == DATA_PTR(self)) {
++            rb_raise(rb_eIOError, "Document closed.");
++        }
+     }
+ }
+ 
+@@ -1513,6 +1519,9 @@
+             do {
+                 *doc->where = e;
+                 rb_yield(self);
++                if (NULL == DATA_PTR(self)) {
++                    rb_raise(rb_eIOError, "Document closed.");
++                }
+                 e = e->next;
+             } while (e != first);
+         }
+@@ -1559,7 +1568,7 @@
+             path = StringValuePtr(*argv);
+         }
+         if (0 != (leaf = get_doc_leaf(doc, path))) {
+-            each_value(doc, leaf);
++            each_value(doc, leaf, self);
+         }
+     }
+     return Qnil;
+--- a/ext/oj/oj.c
++++ b/ext/oj/oj.c
+@@ -19,6 +19,8 @@
+ #include "parse.h"
+ #include "rails.h"
+ 
++#define MAX_INDENT 16
++
+ typedef struct _yesNoOpt {
+     VALUE sym;
+     char *attr;
+@@ -623,7 +625,10 @@
+         case T_FIXNUM:
+             copts->dump_opts.indent_size = 0;
+             *copts->dump_opts.indent_str = '\0';
+-            copts->indent                = FIX2INT(v);
++            if (MAX_INDENT < FIX2INT(v)) {
++                rb_raise(rb_eArgError, "indent limited to %d characters.", 
MAX_INDENT);
++            }
++            copts->indent = FIX2INT(v);
+             break;
+         case T_STRING:
+             if (sizeof(copts->dump_opts.indent_str) <= (len = 
RSTRING_LEN(v))) {
+--- a/ext/oj/parse.c
++++ b/ext/oj/parse.c
+@@ -228,7 +228,7 @@
+ static void read_escaped_str(ParseInfo pi, const char *start) {
+     struct _buf buf;
+     const char *s;
+-    int         cnt = (int)(pi->cur - start);
++    size_t      cnt = pi->cur - start;
+     uint32_t    code;
+     Val         parent = stack_peek(&pi->stack);
+ 
+@@ -536,7 +536,7 @@
+             // A trailing . is not a valid decimal but if encountered allow it
+             // except when mimicking the JSON gem or in strict mode.
+             if (StrictMode == pi->options.mode || CompatMode == 
pi->options.mode) {
+-                int pos = (int)(pi->cur - ni.str);
++                size_t pos = pi->cur - ni.str;
+ 
+                 if (1 == pos || (2 == pos && ni.neg)) {
+                     oj_set_error_at(pi, oj_parse_error_class, __FILE__, 
__LINE__, "not a number");
+--- a/ext/oj/parser.c
++++ b/ext/oj/parser.c
+@@ -668,6 +668,10 @@
+             p->cur = b - json;
+             p->funcs[p->stack[p->depth]].open_object(p);
+             p->depth++;
++            if ((int)sizeof(p->stack) <= p->depth) {
++                parse_error(p, "too deeply nested");
++                break;
++            }
+             p->stack[p->depth] = OBJECT_FUN;
+             p->map             = key1_map;
+             break;
+@@ -690,6 +694,10 @@
+             p->cur = b - json;
+             p->funcs[p->stack[p->depth]].open_array(p);
+             p->depth++;
++            if ((int)sizeof(p->stack) <= p->depth) {
++                parse_error(p, "too deeply nested");
++                break;
++            }
+             p->stack[p->depth] = ARRAY_FUN;
+             p->map             = value_map;
+             break;
+@@ -1366,12 +1374,15 @@
+  * Returns the result according to the delegate of the parser.
+  */
+ static VALUE parser_parse(VALUE self, VALUE json) {
+-    ojParser p = (ojParser)DATA_PTR(self);
++    ojParser       p = (ojParser)DATA_PTR(self);
++    volatile VALUE json_copy;
+ 
+     Check_Type(json, T_STRING);
++    json_copy = rb_str_dup(json);
++    rb_str_freeze(json_copy);
+     parser_reset(p);
+     p->start(p);
+-    parse(p, (const byte *)rb_string_value_ptr(&json));
++    parse(p, (const byte *)rb_string_value_ptr(&json_copy));
+ 
+     return p->result(p);
+ }
+--- a/ext/oj/saj2.c
++++ b/ext/oj/saj2.c
+@@ -564,10 +564,9 @@
+     if (Qnil != d->handler) {
+         rb_gc_mark(d->handler);
+     }
+-    if (!d->cache_keys) {
+-        for (kp = d->keys; kp < d->tail; kp++) {
+-            rb_gc_mark(*kp);
+-        }
++    /* CVE-2026-54902: always mark stack keys; cached keys are also live 
VALUEs */
++    for (kp = d->keys; kp < d->tail; kp++) {
++        rb_gc_mark(*kp);
+     }
+ }
+ 
+--- a/ext/oj/usual.c
++++ b/ext/oj/usual.c
+@@ -62,7 +62,7 @@
+         memcpy(b + 1, str, len);
+         b[len + 1] = '\0';
+ 
+-        id = rb_intern3(buf, len + 1, oj_utf8_encoding);
++        id = rb_intern3(b, len + 1, oj_utf8_encoding);
+         OJ_R_FREE(b);
+         return id;
+     }
+@@ -199,7 +199,10 @@
+         d->ktail = d->khead + pos;
+         d->kend  = d->khead + cap;
+     }
+-    d->ktail->len = klen;
++    if (32000 < klen) {
++        rb_raise(oj_json_parser_error_class, "Key too long. Keys are limited 
to 32,000 bytes.");
++    }
++    d->ktail->len = (int16_t)klen;
+     if (klen < sizeof(d->ktail->buf)) {
+         memcpy(d->ktail->buf, key, klen);
+         d->ktail->buf[klen] = '\0';
+@@ -615,12 +618,16 @@
+     Usual d = (Usual)p->ctx;
+ 
+     cache_free(d->str_cache);
++    d->str_cache = NULL;
+     cache_free(d->attr_cache);
++    d->attr_cache = NULL;
+     if (NULL != d->sym_cache) {
+         cache_free(d->sym_cache);
++        d->sym_cache = NULL;
+     }
+     if (NULL != d->class_cache) {
+         cache_free(d->class_cache);
++        d->class_cache = NULL;
+     }
+     OJ_R_FREE(d->vhead);
+     OJ_R_FREE(d->chead);
+@@ -647,6 +654,12 @@
+     if (NULL != d->class_cache) {
+         cache_mark(d->class_cache);
+     }
++    if (Qnil != d->hash_class) {
++        rb_gc_mark(d->hash_class);
++    }
++    if (Qnil != d->array_class) {
++        rb_gc_mark(d->array_class);
++    }
+     for (vp = d->vhead; vp < d->vtail; vp++) {
+         if (Qundef != *vp) {
+             rb_gc_mark(*vp);
+@@ -1057,10 +1070,10 @@
+         if (NULL != d->sym_cache) {
+             cache_free(d->sym_cache);
+             d->sym_cache = NULL;
++            d->key_cache = NULL;
+         }
+-        if (!d->cache_keys) {
+-            d->get_key = str_key;
+-        }
++        d->cache_keys = false;
++        d->get_key    = str_key;
+     }
+     return (NULL != d->sym_cache) ? Qtrue : Qfalse;
+ }
diff --git a/debian/patches/series b/debian/patches/series
index e1dd96e..45f5845 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -1,2 +1,4 @@
 01_dont_mess_with_loadpath.patch
 03_find_test_helper.patch
+CVE-2026-oj-security-ec368db.patch
+CVE-2026-oj-security-bbde91a.patch

Attachment: OpenPGP_signature.asc
Description: OpenPGP digital signature

Reply via email to