Issue #22120 has been updated by rwstauner (Randy Stauner). Backport changed from 3.3: UNKNOWN, 3.4: REQUIRED, 4.0: REQUIRED to 3.3: UNKNOWN, 3.4: REQUIRED, 4.0: DONE ruby_4_0 commit:37ab334cd72e027d03e7d9e71eea766ac4d1f174 merged revision(s) commit:c88430e1878f. ---------------------------------------- Bug #22120: Segfault caused by ar_find_entry_hint() not checking for conversion to st_table https://bugs.ruby-lang.org/issues/22120#change-117770 * Author: Eregon (Benoit Daloze) * Status: Closed * Assignee: Eregon (Benoit Daloze) * Target version: 4.1 * ruby -v: ruby 4.1.0dev (2026-06-17T10:52:40Z master 0888fa8cb5) +PRISM [arm64-darwin25] * Backport: 3.3: UNKNOWN, 3.4: REQUIRED, 4.0: DONE ---------------------------------------- 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: ```c 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?`. ```ruby 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.3 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: ```ruby $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) -- https://bugs.ruby-lang.org/