Bug #22316
closedString#bit_set can access out-of-bounds memory after reentrant Warning.warn mutation
Description
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¶
Commit:
Reproduction¶
Build Ruby with AddressSanitizer enabled and run:
$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:
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:
AddressSanitizer then reports:
Relevant stack:
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