From 2bc90384b1ab0a2fb79ea533f0d352690e685ecd Mon Sep 17 00:00:00 2001 From: ydah Date: Wed, 30 Sep 2026 23:11:55 +0900 Subject: [PATCH] Prevent use-after-free when strings change during IO generation --- ext/json/ext/fbuffer/fbuffer.h | 4 +++ ext/json/ext/generator/generator.c | 10 +++++-- java/src/json/ext/Generator.java | 2 ++ java/src/json/ext/StringEncoder.java | 7 +++-- test/json/json_generator_test.rb | 39 ++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/ext/json/ext/fbuffer/fbuffer.h b/ext/json/ext/fbuffer/fbuffer.h index cc44ea030..3f95f1f76 100644 --- a/ext/json/ext/fbuffer/fbuffer.h +++ b/ext/json/ext/fbuffer/fbuffer.h @@ -169,6 +169,10 @@ static inline void fbuffer_append_reserved_char(FBuffer *fb, char chr) static void fbuffer_append_str(FBuffer *fb, VALUE str) { + if (RB_UNLIKELY(fb->io)) { + // Growing the buffer may flush it and invoke arbitrary Ruby code. + str = rb_str_new_frozen(str); + } const char *ptr; size_t len; RSTRING_GETMEM(str, ptr, len); diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index 0b7397c56..1d9897551 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -892,12 +892,17 @@ ALWAYS_INLINE(static) VALUE ensure_valid_encoding(struct generate_json_data *dat static void raw_generate_json_string(FBuffer *buffer, struct generate_json_data *data, VALUE obj) { + VALUE str = obj; + if (RB_UNLIKELY(buffer->io)) { + // IO writes can mutate the original string while we are reading it. + str = rb_str_new_frozen(str); + } fbuffer_append_char(buffer, '"'); long len; search_state search; search.buffer = buffer; - RSTRING_GETMEM(obj, search.ptr, len); + RSTRING_GETMEM(str, search.ptr, len); search.cursor = search.ptr; search.end = search.ptr + len; @@ -908,7 +913,7 @@ static void raw_generate_json_string(FBuffer *buffer, struct generate_json_data search.chunk_end = NULL; #endif /* HAVE_SIMD */ - switch (json_str_coderange(obj)) { + switch (json_str_coderange(str)) { case ENC_CODERANGE_7BIT: case ENC_CODERANGE_VALID: if (RB_UNLIKELY(data->state->ascii_only)) { @@ -924,6 +929,7 @@ static void raw_generate_json_string(FBuffer *buffer, struct generate_json_data break; } fbuffer_append_char(buffer, '"'); + RB_GC_GUARD(str); } static void generate_json_string(FBuffer *buffer, struct generate_json_data *data, VALUE obj) diff --git a/java/src/json/ext/Generator.java b/java/src/json/ext/Generator.java index ce1b5b57e..fddb0ea32 100644 --- a/java/src/json/ext/Generator.java +++ b/java/src/json/ext/Generator.java @@ -722,6 +722,7 @@ static RubyString generateFragmentNew(ThreadContext context, Session session, IR static void generateFragment(ThreadContext context, Session session, IRubyObject object, OutputStream buffer) throws IOException { RubyString result = generateFragmentNew(context, session, object); + if (buffer instanceof BufferedOutputStream) result = result.newFrozen(); ByteList bytes = result.getByteList(); buffer.write(bytes.unsafeBytes(), bytes.begin(), bytes.length()); } @@ -814,6 +815,7 @@ static RubyString generateGenericNew(ThreadContext context, Session session, IRu static void generateGeneric(ThreadContext context, Session session, IRubyObject object, OutputStream buffer) throws IOException { RubyString result = generateGenericNew(context, session, object); + if (buffer instanceof BufferedOutputStream) result = result.newFrozen(); ByteList bytes = result.getByteList(); buffer.write(bytes.unsafeBytes(), bytes.begin(), bytes.length()); } diff --git a/java/src/json/ext/StringEncoder.java b/java/src/json/ext/StringEncoder.java index 4eea11c62..e059fc993 100644 --- a/java/src/json/ext/StringEncoder.java +++ b/java/src/json/ext/StringEncoder.java @@ -5,6 +5,7 @@ */ package json.ext; +import java.io.BufferedOutputStream; import java.io.IOException; import java.io.OutputStream; import java.lang.reflect.Constructor; @@ -192,11 +193,13 @@ static StringEncoder createBasicEncoder() { void generate(ThreadContext context, RubyString object, OutputStream buffer) throws IOException { object = ensureValidEncoding(context, object); - ByteList byteList = object.getByteList(); + // IO writes can mutate the original string while we are reading it. + RubyString source = buffer instanceof BufferedOutputStream ? object.newFrozen() : object; + ByteList byteList = source.getByteList(); init(byteList); out = buffer; append('"'); - switch (object.scanForCodeRange()) { + switch (source.scanForCodeRange()) { case StringSupport.CR_7BIT: case StringSupport.CR_VALID: encode(byteList); diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index 1b2197735..1d7312cbb 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -91,6 +91,45 @@ def test_dump_deprecated_limit assert_equal '[1]', io.string end + def test_dump_string_mutated_during_io_write + [{}, {ascii_only: true}, {script_safe: true}, {buffer_initial_length: 1}].each do |options| + ['a', "a\né/"].each do |pattern| + string = pattern * 100_000 + assert_dump_preserves_string_during_io_write(string, string, options) + end + end + end + + def test_dump_fragment_mutated_during_io_write + string = JSON.generate('a' * 100_000) + assert_dump_preserves_string_during_io_write(JSON::Fragment.new(string), string) + end + + def test_dump_to_json_result_mutated_during_io_write + string = JSON.generate('a' * 100_000) + object = Object.new + object.define_singleton_method(:to_json) { |*| string } + assert_dump_preserves_string_during_io_write(object, string) + end + + def assert_dump_preserves_string_during_io_write(object, string, options = {}) + expected = JSON.dump([object], options) + io = StringIO.new + mutated = false + io.define_singleton_method(:write) do |chunk| + unless mutated + string.setbyte(0, 'b'.ord) + string.replace('changed') + mutated = true + GC.start + end + super(chunk) + end + assert_same io, JSON.dump([object], io, options) + assert_equal 'changed', string + assert_equal true, expected == io.string, 'IO output must preserve the original string' + end + def test_not_frozen [ [[], '[]'],