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
OpenPGP_signature.asc
Description: OpenPGP digital signature
