Project

General

Profile

Actions

Bug #22120

open

Segfault caused by ar_find_entry_hint() not checking for conversion to st_table

Bug #22120: Segfault caused by ar_find_entry_hint() not checking for conversion to st_table

Added by Eregon (Benoit Daloze) 1 day ago. Updated about 24 hours ago.

Status:
Open
Target version:
ruby -v:
ruby 4.1.0dev (2026-06-17T10:52:40Z master 0888fa8cb5) +PRISM [arm64-darwin25]
[ruby-core:125788]

Description

While perusing code in hash.c (motivated by some crashes reported in https://github.com/DataDog/dd-trace-rb/issues/5718),
I found it suspicious that ar_find_entry_hint() didn't reread bound in the loop and yet called arbitrary code through #eql?.
Indeed, ar_find_entry_hint() does not check if bound or the storage (AR->ST) changed and would return a bin index which is not correct to access:

static unsigned
ar_find_entry_hint(VALUE hash, ar_hint_t hint, st_data_t key)
{
    unsigned i, bound = RHASH_AR_TABLE_BOUND(hash);
    const ar_hint_t *hints = RHASH_AR_TABLE(hash)->ar_hint.ary;

    /* if table is NULL, then bound also should be 0 */

    for (i = 0; i < bound; i++) {
        if (hints[i] == hint) {
            ar_table_pair *pair = RHASH_AR_TABLE_REF(hash, i);
            if (ar_equal(key, pair->key)) {
                RB_DEBUG_COUNTER_INC(artable_hint_hit);
                return i;
            }
            else {
                RB_DEBUG_COUNTER_INC(artable_hint_miss);
            }
        }
    }
    RB_DEBUG_COUNTER_INC(artable_hint_notfound);
    return RHASH_AR_TABLE_MAX_BOUND;
}

Fix: https://github.com/ruby/ruby/pull/17383

With the conversion from AR to ST it can actually trigger a segfault:
This repro uses a single thread and mutates inside eql? for simplicity, but the same crash happens with another Thread mutating while the main Thread is in eql?.

class Key
  attr_reader :v
  def initialize(v, h = nil)
    @v = v
    @h = h
  end

  def hash = 0

  def eql?(other)
    if @h
      # Trigger AR->ST conversion
      @h[42] = 42
    end
    other.is_a?(Key) && @v == other.v
  end

  def inspect = "K(#{@v})"
end

h = {}
8.times { |i| h[Key.new(i)] = i }

# Not in the hash, so ar_find_entry_hint checks every entry.
lookup_key = Key.new(-1, h)

p h[lookup_key]
$ ./miniruby ar_find_entry_hint_bug.rb
ar_find_entry_hint_bug.rb:15: [BUG] Segmentation fault at 0x0000000000000004
ruby 4.1.0dev (2026-06-17T10:52:40Z master 0888fa8cb5) +PRISM [arm64-darwin25]

-- Crash Report log information --------------------------------------------
   See Crash Report log file in one of the following locations:
     * ~/Library/Logs/DiagnosticReports
     * /Library/Logs/DiagnosticReports
   for more details.
Don't forget to include the above Crash Report log file in bug reports.

-- Control frame information -----------------------------------------------
c:0003 p:0021 s:0018 e:000015 l:y b:0001 r:0x0 METHOD ar_find_entry_hint_bug.rb:15 [FINISH]
c:0002 p:0046 s:0011 E:0014e0 l:n b:---- r:0x0 EVAL   ar_find_entry_hint_bug.rb:27 [FINISH]
c:0001 p:0000 s:0003 E:002480 l:y b:---- r:0x0 DUMMY  [FINISH]

-- Ruby level backtrace information ----------------------------------------
ar_find_entry_hint_bug.rb:27:in '<main>'
ar_find_entry_hint_bug.rb:15:in 'eql?'

-- Threading information ---------------------------------------------------
Total ractor count: 1
Ruby thread count for this ractor: 1

-- Machine register context ------------------------------------------------
  x0: 0x0000000000000004  x1: 0x00000000000011bf  x2: 0x000000016b9ed680
  x3: 0x0000000000000000  x4: 0x0000000000000001  x5: 0x0000000000000001
  x6: 0x0000000104d400a0  x7: 0xfffff0003ffff800 x18: 0x0000000000000000
 x19: 0x000000016b9ed6d0 x20: 0x0000000104d41200 x21: 0x0000000000000000
 x22: 0x00000000000011bf x23: 0x0000000000000000 x24: 0x0000000000000000
 x25: 0x0000000104a96000 x26: 0x0000000000000009 x27: 0x0000000afd00aa00
 x28: 0x0000000104a96000  lr: 0x00000001046a21dc  fp: 0x000000016b9ed6c0
  sp: 0x000000016b9ed680

-- C level backtrace information -------------------------------------------
/Users/benoit.daloze/code/ruby/miniruby(rb_vm_bugreport+0xbfc) [0x1046aec04] /Users/benoit.daloze/code/ruby/vm_dump.c:1473
/Users/benoit.daloze/code/ruby/miniruby(rb_vm_bugreport) (null):0
/Users/benoit.daloze/code/ruby/miniruby(rb_bug_for_fatal_signal+0x10c) [0x1044c7cd4] /Users/benoit.daloze/code/ruby/error.c:1140
/Users/benoit.daloze/code/ruby/miniruby(sigsegv+0x94) [0x1045ff93c] /Users/benoit.daloze/code/ruby/signal.c:948
/usr/lib/system/libsystem_platform.dylib(_sigtramp+0x38) [0x1891a57a4]
/Users/benoit.daloze/code/ruby/miniruby(lookup_method_table+0x7c) [0x1046a21dc] ./vm_method.c:1363
/Users/benoit.daloze/code/ruby/miniruby(search_method0) ./vm_method.c:1822
/Users/benoit.daloze/code/ruby/miniruby(search_method+0x14) [0x104685960] ./vm_method.c:1845
/Users/benoit.daloze/code/ruby/miniruby(callable_method_entry_or_negative) ./vm_method.c:2049
/Users/benoit.daloze/code/ruby/miniruby(callable_method_entry+0x10) [0x104677fd0] ./vm_method.c:2078
/Users/benoit.daloze/code/ruby/miniruby(rb_callable_method_entry) ./vm_method.c:2085
/Users/benoit.daloze/code/ruby/miniruby(rb_vm_search_method_slowpath) ./vm_insnhelper.c:2039
/Users/benoit.daloze/code/ruby/miniruby(vm_search_method_slowpath0+0x8) [0x10467e6cc] ./vm_insnhelper.c:2214
/Users/benoit.daloze/code/ruby/miniruby(vm_exec_core) ./vm_insnhelper.c:2277
/Users/benoit.daloze/code/ruby/miniruby(vm_exec_loop+0x0) [0x10467a970] /Users/benoit.daloze/code/ruby/vm.c:2796
/Users/benoit.daloze/code/ruby/miniruby(rb_vm_exec) /Users/benoit.daloze/code/ruby/vm.c:2799
/Users/benoit.daloze/code/ruby/miniruby(rb_funcallv_scope+0x238) [0x10468a0c0] ./vm_eval.c:101
/Users/benoit.daloze/code/ruby/miniruby(rb_funcallv+0x8) [0x10468a4cc] ./vm_eval.c:1084
/Users/benoit.daloze/code/ruby/miniruby(rb_funcall) ./vm_eval.c:1141
/Users/benoit.daloze/code/ruby/miniruby(RB_TEST+0x0) [0x10455f248] /Users/benoit.daloze/code/ruby/object.c:161
/Users/benoit.daloze/code/ruby/miniruby(rb_eql) /Users/benoit.daloze/code/ruby/object.c:163
/Users/benoit.daloze/code/ruby/miniruby(rb_any_cmp+0xd0) [0x1044ff0e0] /Users/benoit.daloze/code/ruby/hash.c:138
/Users/benoit.daloze/code/ruby/miniruby(ar_equal+0xc) [0x104508328] /Users/benoit.daloze/code/ruby/hash.c:603
/Users/benoit.daloze/code/ruby/miniruby(ar_find_entry+0x54) [0x104500af4] /Users/benoit.daloze/code/ruby/hash.c:617
/Users/benoit.daloze/code/ruby/miniruby(ar_lookup) /Users/benoit.daloze/code/ruby/hash.c:1025
/Users/benoit.daloze/code/ruby/miniruby(rb_hash_aref) /Users/benoit.daloze/code/ruby/hash.c:2037
/Users/benoit.daloze/code/ruby/miniruby(vm_exec_core+0x4790) [0x104680c18] ./vm_insnhelper.c:6958
/Users/benoit.daloze/code/ruby/miniruby(vm_exec_loop+0x0) [0x10467a970] /Users/benoit.daloze/code/ruby/vm.c:2796
/Users/benoit.daloze/code/ruby/miniruby(rb_vm_exec) /Users/benoit.daloze/code/ruby/vm.c:2799
/Users/benoit.daloze/code/ruby/miniruby(rb_ec_exec_node+0xb4) [0x1044d317c] /Users/benoit.daloze/code/ruby/eval.c:284
/Users/benoit.daloze/code/ruby/miniruby(ruby_run_node+0x4c) [0x1044d3074] /Users/benoit.daloze/code/ruby/eval.c:322
/Users/benoit.daloze/code/ruby/miniruby(rb_main+0x1c) [0x104410980] ./main.c:42
/Users/benoit.daloze/code/ruby/miniruby(main) ./main.c:62

-- Other runtime information -----------------------------------------------

* Loaded script: ar_find_entry_hint_bug.rb

* Ruby Box: disabled
* Loaded features:

    0 enumerator.so
    1 monitor.so
    2 thread.rb
    3 fiber.so
    4 rational.so
    5 complex.so
    6 pathname.so
    7 ruby2_keywords.rb
    8 set.rb

I can also repro on 3.2.11.
Reproducing locally seems harder on 3.3, 3.4 and 4.0 but I believe they are affected too since they use very similar code and miss those checks, so we should backport there as well.
Backporting to 3.2 may make sense too since this could be seen as a form of DoS.
The repro depends on how the ar_table and st_table layouts overlap and specific values written & read.


For completeness, I initially tried another repro, which shows unclear semantics but doesn't crash:

$break = false

class Key
  def hash = 42
  def eql?(o)
    if $break
      $break = false
      H.shift
      H[Key.new] = 42 until H.size == 8
      o.equal?($key1)
    else
      equal?(o)
    end
  end
end

$key1 = key1 = Key.new
key2 = Key.new
key3 = Key.new

h = { key1 => 1, key2 => 2 }
H = h
p h

$break = true
p h[key3]
$ ./miniruby bug.rb
{#<Key:0x0000000101041200> => 1, #<Key:0x0000000101041240> => 2}
2

So reading a key considered eql? to key1 returns the value for key2, that seems quite surprising.
The underlying issue there is ar_find_entry_hint() returns an index but doesn't read the value, so the value is read after eql?, in which/during which the Hash may have changed.


Files

ar_find_entry_hint_race.rb (2.34 KB) ar_find_entry_hint_race.rb Eregon (Benoit Daloze), 06/18/2026 09:28 AM

Updated by Eregon (Benoit Daloze) about 24 hours ago Actions #1 [ruby-core:125792]

For completeness, I'm attaching a threaded repro that fails reliably on miniruby.
The interesting part is it shows this can happen with regular code accessing a Hash from different threads and without needing to override or do anything special in eql? (the repro only uses sleep to make it easier to repro).

Actions

Also available in: PDF Atom