Skip to content

Ruby: fix use-after-free of map keys aliasing a temporary String - #29026

Open
jeremy wants to merge 1 commit into
protocolbuffers:mainfrom
jeremy:ruby-map-key-use-after-free
Open

Ruby: fix use-after-free of map keys aliasing a temporary String#29026
jeremy wants to merge 1 commit into
protocolbuffers:mainfrom
jeremy:ruby-map-key-use-after-free

Conversation

@jeremy

@jeremy jeremy commented Aug 5, 2026

Copy link
Copy Markdown

Fixes #29023.

Map#[]= and Message.new(map_field: {...}) build the map key as a upb_StringView
aliasing a Ruby String, then convert the value before upb_Map_Set copies the key. The
value conversion allocates, so it can trigger GC inside that window.

The aliased String is frequently a temporary: Convert_RubyToUpb replaces the caller's
object when the key is a Symbol (via to_s) or a String not already tagged UTF-8 (via
Convert_CheckStringUtf8), and nothing references the result once it returns. When GC
collects it, the freed block goes straight back to the next upb_Arena_Malloc, which
memcpys the value into it — leaving a silently corrupted key holding unrelated heap bytes,
tagged UTF-8 while containing invalid UTF-8, which then propagates into encode/to_json.

The fix

Pass the arena at both insertion sites, so the key is copied before anything can allocate.

The lookup paths (Map_index, Map_has_key, Map_delete) keep the NULL fast path — they
consume the key immediately with no allocation in between, which is exactly the precondition
Convert_StringData's comment describes. I reworded that comment, since it read as though
the aliasing were unconditionally safe; it holds for three of its five callers and not for
the two that insert.

Cost is one arena allocation per insert for string-typed keys. Non-string keys don't reach
Convert_StringData at all.

Trigger

Needs both:

  • a key that is a Symbol, or a String not already tagged UTF-8 — ASCII-8BIT is the
    common case for anything read from a socket, a file, Marshal, or String#pack; and
  • a value whose conversion allocates (a Symbol, or a non-UTF-8 String).

Plain UTF-8 keys are unaffected, which is presumably why this has gone unnoticed.

Verification

Reproduces under ordinary GC, no GC.stress required — one corrupted key across 150k
iterations (0/50k, 0/50k, 1/50k), versus 100/100 with stress. That second number is an
existence proof rather than a rate.

Added regression tests to ruby/tests/gc_test.rb covering string keys, Symbol keys, and the
map-field kwarg path. Verified red/green against the same tree:

ext build new tests
unpatched main 3 tests, 3 failures
with this change 3 tests, 300 assertions, 0 failures

Full Ruby suite green with the change on ruby 4.0.6 / arm64-darwin — basic.rb (133 tests,
157,864 assertions), basic_proto2.rb (93), repeated_field_test.rb (40),
encode_decode_test.rb, memory_test.rb, object_cache_test.rb, well_known_types_test.rb,
service_test.rb, oom_test.rb, multi_level_nesting_test.rb — 0 failures, 0 errors.

Reported separately via the channel in SECURITY.md, since this is a memory-safety issue in
an OT0 repository.

Map#[]= and Message.new(map_field: {...}) built the map key as a
upb_StringView aliasing a Ruby String, then converted the value before
upb_Map_Set copied the key. The value conversion allocates, so it can
trigger GC inside that window.

The aliased String is often a temporary. Convert_RubyToUpb replaces the
caller's object when the key is a Symbol (via to_s) or a String not
already tagged UTF-8 (via Convert_CheckStringUtf8), and nothing
references the result once Convert_RubyToUpb returns. When GC collects
it, the freed block is handed straight back to the next
upb_Arena_Malloc, which memcpys the value into it, and the map ends up
with a silently corrupted key holding unrelated heap bytes. The key is
tagged UTF-8 while containing invalid UTF-8, so it then propagates into
encode and to_json.

Pass the arena at both insertion sites so the key is copied before
anything can allocate. The lookup paths (Map_index, Map_has_key,
Map_delete) keep the NULL fast path: they consume the key immediately
with no allocation in between, which is the precondition
Convert_StringData's comment describes. Reword that comment to say so,
since it read as if the aliasing were unconditionally safe.

Reproduces under ordinary GC without GC.stress: one corrupted key across
150k iterations, versus 100/100 with stress. Trigger requires a key that
is a Symbol or a non-UTF-8 String (ASCII-8BIT is the common case for
anything read from a socket, file, Marshal or pack) together with a
value whose conversion allocates. Plain UTF-8 keys are unaffected.

Adds regression tests covering string keys, Symbol keys, and the
map-field kwarg path. They fail on the unpatched extension and pass with
this change.
@jeremy
jeremy requested a review from a team as a code owner August 5, 2026 06:58
@jeremy
jeremy requested review from JasonLunn and removed request for a team August 5, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Ruby] Use-after-free: Map#[]= aliases a temporary String as the key, corrupting map keys under GC

1 participant