[ruby-core:126720] [Ruby Bug#22316] String#bit_set can access out-of-bounds memory after reentrant Warning.warn mutation
Issue #22316 has been reported by cozerercument (Boran Per). ---------------------------------------- Bug #22316: String#bit_set can access out-of-bounds memory after reentrant Warning.warn mutation https://bugs.ruby-lang.org/issues/22316 * Author: cozerercument (Boran Per) * Status: Open * ruby -v: ruby 4.1.0dev (2026-09-14T08:30:15Z master 03a426ec8d) +PRISM [x86_64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- `String#bit_set` can access memory outside the current String backing buffer when a chilled String is mutated reentrantly from a customized `Warning.warn` callback. The bit offset is validated against the String length before `rb_str_modify()` is called. For a chilled String, `rb_str_modify()` can emit a deprecation warning and invoke Ruby-level `Warning.warn`. If that callback shrinks/reallocates the same String, the previously validated offset becomes stale. After the callback returns, `String#bit_set` continues using that offset without re-validating it against the new String length. With an AddressSanitizer build this produces a deterministic heap-buffer-overflow. ## Tested revision ```text ruby 4.1.0dev (2026-09-14T08:30:15Z master 03a426ec8d) +PRISM [x86_64-linux] ``` Commit: ```text 03a426ec8d30929558bf1244b4ad95eb7e1f54d2 ``` ## Reproduction Build Ruby with AddressSanitizer enabled and run: ```ruby $VERBOSE = true Warning[:deprecated] = true $victim = "A" * 8192 $reentered = false module ReallocateWarning def warn(message, category: nil, **kwargs) if category == :deprecated && !$reentered $reentered = true STDERR.puts "[+] callback entered" STDERR.puts "[+] before clear: #{$victim.bytesize}" $victim.clear $victim << ("B" * 1024) STDERR.puts "[+] after rebuild: #{$victim.bytesize}" return nil end super end end Warning.extend(ReallocateWarning) STDERR.puts "[*] initial size=#{$victim.bytesize}" # Valid against the original 8192-byte String. $victim.bit_set(7000 * 8) STDERR.puts "[!] bit_set returned normally" ``` Run with: ```sh ASAN_OPTIONS="detect_leaks=0:halt_on_error=1:abort_on_error=1" \ ./miniruby poc_single_bit_reentrant.rb ``` ## Actual result The callback shrinks the String from 8192 bytes to 1024 bytes: ```text [*] initial size=8192 [+] callback entered [+] before clear: 8192 [+] after rebuild: 1024 ``` AddressSanitizer then reports: ```text ERROR: AddressSanitizer: heap-buffer-overflow READ of size 1 ``` Relevant stack: ```text str_apply_bit_mask ../string.c:7097 str_mutate_single_bit ../string.c:7175 str_mutate_bit ../string.c:7191 rb_str_bit_set ../string.c:7246 ``` The process aborts with exit status `134`. ## Expected result Reentrant mutation during the warning callback should not leave a previously validated bit offset usable against the new, smaller String buffer. The operation should either revalidate the bounds against the current String state or fail safely. ## Root cause The offset is checked against `RSTRING_LEN(str)` before `rb_str_modify(str)`. `rb_str_modify()` may invoke the deprecation-warning path for a chilled String, which can call a customized Ruby-level `Warning.warn`. That callback can mutate and shrink/reallocate the same receiver. After `rb_str_modify()` returns, the implementation obtains the current `RSTRING_PTR(str)`, but continues using the offset validated against the old String length without checking it again against the current `RSTRING_LEN(str)`. The region form of the bit-mutation APIs appears to be affected by the same reentrancy issue, but the single-bit example above is the minimal reproducer. I also verified that the underlying single-bit check/modify/access ordering existed before the recent region-argument change, so I am not attributing introduction of the issue to that change. ---Files-------------------------------- poc_single_bit_reentrant.out (8.12 KB) poc_single_bit_reentrant.rb (8.73 KB) -- https://bugs.ruby-lang.org/
participants (1)
-
cozerercument (Boran Per)