From 6804addeecffeb0eb5235408511d8dc2f17c981a Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 30 Aug 2026 14:59:15 +0000 Subject: [PATCH 1/2] Keep the fallback string alive across rb_econv_insert_output transcode_loop() passed the fallback result to rb_econv_insert_output() as a bare pointer and length. Nothing roots the string during that call: args.rep still holds the pre-fallback error-bytes string, and with clang -O3 the VALUE itself lives in no stack slot the conservative scan can see -- only the raw data pointer survives in a register, and for an embedded string that is an interior pointer the scanner rejects. rb_econv_insert_output() can run a GC: when the fallback string's encoding differs from the converter's insert encoding it calls allocate_converted_string(), which opens a fresh econv and allocates the destination buffer. The fallback string is then swept mid-call and the sub-conversion reads its freed bytes. On an ASAN build this reports use-after-poison at the 1-byte input read in transcode_restartable0(); CI hit it about once in two hundred runs of test_fallback_proc, and GC.stress reproduces it in a few hundred iterations on a clang -O3 ASAN build. With RB_GC_GUARD the same loop is clean. Co-Authored-By: Claude Opus 5 --- transcode.c | 1 + 1 file changed, 1 insertion(+) diff --git a/transcode.c b/transcode.c index e4400f6622d86a..d2900a41c96660 100644 --- a/transcode.c +++ b/transcode.c @@ -2439,6 +2439,7 @@ transcode_loop(const unsigned char **in_pos, unsigned char **out_pos, if (!UNDEF_P(rep) && !NIL_P(rep)) { ret = rb_econv_insert_output(ec, (const unsigned char *)RSTRING_PTR(rep), RSTRING_LEN(rep), rb_enc_name(rb_enc_get(rep))); + RB_GC_GUARD(rep); // insert_output may GC while reading rep's bytes if ((int)ret == -1) { rb_econv_close(ec); rb_raise(rb_eArgError, "too big fallback string"); From aa33db6903ce255cefb5d6bf5a51f569ef3ce657 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 30 Aug 2026 14:59:24 +0000 Subject: [PATCH 2/2] Pass the buffer's size to ruby_xrealloc_sized, not the field distance rb_econv_insert_output() grew the insertion buffer with ruby_xrealloc_sized(*buf_start_p, s, buf_end_p - buf_start_p); The old-size argument subtracts the two local pointer variables instead of the pointers they point at, so the allocator's size accounting gets a small constant rather than the buffer's size. Present since the sized variant was introduced; the memory itself was never corrupted. Co-Authored-By: Claude Opus 5 --- test/ruby/test_transcode.rb | 10 ++++++++++ transcode.c | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/test/ruby/test_transcode.rb b/test/ruby/test_transcode.rb index d0710358ebefdc..7b0bde91ff2262 100644 --- a/test/ruby/test_transcode.rb +++ b/test/ruby/test_transcode.rb @@ -2232,6 +2232,16 @@ def test_fallback_proc assert_equal("U+3042", "\u{3042}".encode("US-ASCII", fallback: fallback)) end + def test_fallback_grow_insert_buffer + # A later fallback insertion larger than the first one's buffer grows it + # in rb_econv_insert_output; the sized realloc there passed a wrong old + # size (caught by RUBY_DEBUG builds). + n = 0 + r = "\u{3042}\u{3044}\u{3046}".encode("US-ASCII", + fallback: proc {|x| n += 1; "Y" * (5000 * n)}) + assert_equal(30000, r.bytesize) + end + def test_fallback_method def (fallback = "U+%.4X").escape(x) self % x.unpack("U") diff --git a/transcode.c b/transcode.c index d2900a41c96660..de363c9dcd5789 100644 --- a/transcode.c +++ b/transcode.c @@ -1712,7 +1712,7 @@ rb_econv_insert_output(rb_econv_t *ec, size_t s = (*data_end_p - *buf_start_p) + need; if (s < need) goto fail; - buf = ruby_xrealloc_sized(*buf_start_p, s, buf_end_p - buf_start_p); + buf = ruby_xrealloc_sized(*buf_start_p, s, *buf_end_p - *buf_start_p); *data_start_p = buf; *data_end_p = buf + (*data_end_p - *buf_start_p); *buf_start_p = buf;