Bug #21952
openRuby::Box double free at process exit when `fiddle/import` is required in multiple boxes
Description
I found what looks like a separate Ruby::Box bug from the existing require and LoadError issues such as #21760.
This is not a LoadError case. I was able to reduce it to a reproducer where requiring fiddle/import from multiple boxes causes Ruby to abort at process exit with a double free.
Environment:
- ruby 4.0.1 (2026-01-13 revision e04267a14b) +PRISM [x86_64-linux]
- Linux x86_64
RUBY_BOX=1
Reproducer¶
Create /tmp/fiddle_require.rb:
require "rubygems"
$:.unshift(*Gem::Specification.find_by_name("fiddle").full_require_paths)
require "fiddle/import"
Then run:
RUBY_BOX=1 ruby -e 'b1 = Ruby::Box.new; b1.require("/tmp/fiddle_require.rb"); b2 = Ruby::Box.new; b2.require("/tmp/fiddle_require.rb")'
Expected behavior¶
Both Ruby::Box#require calls succeed, and Ruby exits normally.
Actual behavior¶
Ruby aborts at process exit with:
free(): double free detected in tcache 2
[BUG] Aborted
The C backtrace points into Ruby's Ruby::Box cleanup path, including:
free_classext_for_boxcleanup_all_local_extensionsbox_entry_freerb_class_classext_freecvar_table_free_iruby_sized_xfree
ASAN¶
I also rebuilt Ruby with AddressSanitizer and reran the same reproducer.
ASAN reports:
AddressSanitizer: attempting to call malloc_usable_size() for pointer which is not owned- the pointer was originally allocated by
rb_cvar_set - it was then freed once via
cvar_table_free_i - and later reached the same
cvar_table_free_icleanup path again throughfree_classext_for_boxandbox_entry_free
This makes it look like a class-variable-related allocation created while loading fiddle/import is being freed twice during Ruby::Box cleanup.
Notes¶
- I first noticed this while testing
fiddletogether with shared libraries, but shared library loading is not required for the crash. -
dlloadis not necessary. - Reusing the same Ruby module name is not necessary.
- As a control case, one box requiring
fiddle/importand another box requiring a plain Ruby file exits normally. - The explicit
$:adjustment above is only there to avoid the separateRuby::Box#requireissue whererequire "fiddle/import"may otherwise fail withLoadErrorunderRUBY_BOX=1.
So this seems to be a separate crash bug in Ruby::Box cleanup triggered by loading fiddle/import in multiple boxes.
Files
Updated by katsyoshi (Katsuyoshi MATSUMOTO) 8 days ago
I managed to reduce this further.
This is reproducible with pure Ruby now, without fiddle/import. The current reduced reproducer is along these lines:
Ruby::Box.root.eval(<<~RUBY)
module M
@@x = 0
end
class A
include M
end
class B < A
end
RUBY
code = <<~REPRO
class ::B
@@x += 1
end
REPRO
b1 = Ruby::Box.new
b1.eval(code)
b2 = Ruby::Box.new
b2.eval(code)
On unpatched 4.0.1 with RUBY_BOX=1, this aborts at process exit with a double free.
So fiddle/import was not the root requirement here. What seems to matter is reopening a class from multiple boxes and
touching a class variable that comes from an ancestor/include chain.
Updated by byroot (Jean Boussier) 8 days ago
- Assignee set to tagomoris (Satoshi Tagomori)
- Backport changed from 3.2: UNKNOWN, 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN to 3.2: DONTNEED, 3.3: DONTNEED, 3.4: DONTNEED, 4.0: REQUIRED
So it appears that when duplicating a class in another box, we copy the class variables table, but not its entries, causing both boxes to think they own that memory, resulting in a double free.
I have a fix for the specific reproducer: https://github.com/ruby/ruby/pull/16594, however I'm not familiar enough with box design to know for sure if there isn't another way this situation could occur.