Bug #22234
openIO::Buffer#resize(0) copies uninitialized memory into the buffer
Description
Reproduction¶
buffer = IO::Buffer.new(64)
slice = buffer.slice(0, 8)
slice.resize(0)
slice.size # => an arbitrary size or a SEGV
The outcome depends on what the stack happens to hold. Reading slice.inspect instead of slice.size, which dereferences source, gives either a crash or an unrelated TypeError such as wrong argument type VM/cc_table (expected IO::Buffer).
A mapped buffer is affected too, on platforms where mremap is unavailable (macOS, Windows), and IO::Buffer.new.resize(0) is enough to hit it because the 65536 byte default is larger than PAGE_SIZE. On Linux that case raises Errno::EINVAL: Invalid argument - rb_io_buffer_resize:mremap instead, as mremap rejects a zero new length.
What happens¶
io_buffer_resize_copy() fills a stack struct rb_io_buffer and then copies it over the live one:
struct rb_io_buffer resized;
io_buffer_initialize(self, &resized, NULL, size, io_flags_for_size(size), Qnil);
...
io_buffer_free(buffer);
*buffer = resized;
io_buffer_initialize() returns without assigning any field when the size is zero, because there is nothing to allocate. resized is therefore never initialized, and reading it is already undefined behaviour; all four of base, size, flags and source hold indeterminate values once it has been copied over the live buffer. source is the dangerous one: it is marked by the GC on every collection, and io_buffer_validate() dereferences it as a slice source whenever it is not Qnil.
rb_io_buffer_resize() reaches that path because a slice carries no flag other than READONLY, so the EXTERNAL, MAPPED and INTERNAL branches are all skipped. The example above uses an internal source buffer, so no mapping is involved and the platform does not matter.
Background¶
The INTERNAL branch is the only one with a size == 0 shortcut. It was added by GH-7569 for #19543, where resize(0) reached realloc(base, 0) and aborted with [BUG] rb_sys_fail(rb_io_buffer_resize:realloc) - errno == 0, the null return being taken for an allocation failure. As GH-7569 describes it, glibc frees the object and returns a null pointer for a zero size, while BSDs return an inaccessible object; passing zero to realloc is implementation defined in C17 and undefined in C23, so the shortcut keeps it from reaching realloc at all.
Zero is not a size that any of the allocation APIs behind a buffer can express, and the mapped and slice paths still pass it down.
No data to display