Skip to content

Commit e730ac4

Browse files
authored
YJIT: Fix version_map use-after-free from mutable aliasing UB
Multiple YJIT functions created overlapping `&'static mut IseqPayload` references by calling `get_iseq_payload()` multiple times for the same iseq. Overlapping &mut is UB in rust's aliasing model, and as consequence, we trigered use-after-free on the `version_map` Vec header due to false claims of LLVM `noalias`. This manifested as crashes in various YJIT operations (block lookup, GC marking, block removal) that dereference the stale pointer. Fix by moving `delayed_deallocation` and `get_or_create_version_list` from free functions (which each call `get_iseq_payload()` internally) to methods on `IseqPayload` that operate through `&mut self`. This lets callers obtain a single payload reference and use it for all operations without creating overlapping mutable borrows. The three fixed call sites: 1. `rb_yjit_tracing_invalidate_all` (invariants.rs): The loop called `delayed_deallocation()` which internally called `get_iseq_payload()`, creating a second `&mut` overlapping with the outer `payload` reference. Fix: call `payload.delayed_deallocation()` method instead. 2. `add_block_version` (core.rs): Called `get_or_create_version_list()` then later `get_iseq_payload()` for pages, creating two references. Fix: use a single `get_or_create_iseq_payload()` call then call the `get_or_create_version_list()` method on it for both version_map and pages access. Also adds regression tests exercising tracing invalidation with on-stack methods and suspended fibers. [alan: edited commit message] Reviewed-by: Alan Wu <alanwu@ruby-lang.org>
1 parent 906176a commit e730ac4

File tree

3 files changed

+100
-48
lines changed

3 files changed

+100
-48
lines changed

bootstraptest/test_yjit.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5486,3 +5486,52 @@ def test = foo(1)
54865486
test
54875487
test
54885488
}
5489+
5490+
# regression test for tracing invalidation with on-stack compiled methods
5491+
# Exercises the on_stack_iseqs path in rb_yjit_tracing_invalidate_all
5492+
# where delayed deallocation must not create aliasing &mut references
5493+
# to IseqPayload (use-after-free of version_map backing storage).
5494+
assert_normal_exit %q{
5495+
def deep = 42
5496+
def mid = deep
5497+
def outer = mid
5498+
5499+
# Compile all three methods with YJIT
5500+
10.times { outer }
5501+
5502+
# Enable tracing from within a call chain so that outer/mid/deep
5503+
# are on the stack when rb_yjit_tracing_invalidate_all runs.
5504+
# This triggers the on_stack_iseqs (delayed deallocation) path.
5505+
def deep
5506+
TracePoint.new(:line) {}.enable
5507+
42
5508+
end
5509+
5510+
outer
5511+
5512+
# After invalidation, verify YJIT can recompile and run correctly
5513+
def deep = 42
5514+
10.times { outer }
5515+
}
5516+
5517+
# regression test for tracing invalidation with on-stack fibers
5518+
# Suspended fibers have iseqs on their stack that must survive invalidation.
5519+
assert_equal '42', %q{
5520+
def compiled_method
5521+
Fiber.yield
5522+
42
5523+
end
5524+
5525+
# Compile the method
5526+
10.times { compiled_method rescue nil }
5527+
5528+
fiber = Fiber.new { compiled_method }
5529+
fiber.resume # suspends inside compiled_method — it's now on the fiber's stack
5530+
5531+
# Enable tracing while compiled_method is on the fiber's stack.
5532+
# This triggers rb_yjit_tracing_invalidate_all with on-stack iseqs.
5533+
TracePoint.new(:call) {}.enable
5534+
5535+
# Resume the fiber — compiled_method's iseq must still be valid
5536+
fiber.resume.to_s
5537+
}

yjit/src/core.rs

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1769,6 +1769,35 @@ impl IseqPayload {
17691769
// Turn it into an iterator that owns the blocks and return
17701770
version_map.into_iter().flatten()
17711771
}
1772+
1773+
/// Get or create all blocks for a particular place in an iseq.
1774+
fn get_or_create_version_list(&mut self, insn_idx: usize) -> &mut VersionList {
1775+
if insn_idx >= self.version_map.len() {
1776+
self.version_map.resize(insn_idx + 1, VersionList::default());
1777+
}
1778+
1779+
return self.version_map.get_mut(insn_idx).unwrap();
1780+
}
1781+
1782+
// We cannot deallocate blocks immediately after invalidation since patching
1783+
// the code for setting up return addresses does not affect outstanding return
1784+
// addresses that are on stack and will use invalidated branch pointers when
1785+
// hit. Example:
1786+
// def foo(n)
1787+
// if n == 2
1788+
// # 1.times.each to create a cfunc frame to preserve the JIT frame
1789+
// # which will return to a stub housed in an invalidated block
1790+
// return 1.times.each { Object.define_method(:foo) {} }
1791+
// end
1792+
//
1793+
// foo(n + 1) # The block for this call houses the return branch stub
1794+
// end
1795+
// p foo(1)
1796+
pub fn delayed_deallocation(&mut self, blockref: BlockRef) {
1797+
block_assumptions_free(blockref);
1798+
unsafe { blockref.as_ref() }.set_iseq_null();
1799+
self.dead_blocks.push(blockref);
1800+
}
17721801
}
17731802

17741803
/// Get the payload for an iseq. For safety it's up to the caller to ensure the returned `&mut`
@@ -2140,21 +2169,6 @@ fn get_version_list(blockid: BlockId) -> Option<&'static mut VersionList> {
21402169
}
21412170
}
21422171

2143-
/// Get or create all blocks for a particular place in an iseq.
2144-
fn get_or_create_version_list(blockid: BlockId) -> &'static mut VersionList {
2145-
let payload = get_or_create_iseq_payload(blockid.iseq);
2146-
let insn_idx = blockid.idx.as_usize();
2147-
2148-
// Expand the version map as necessary
2149-
if insn_idx >= payload.version_map.len() {
2150-
payload
2151-
.version_map
2152-
.resize(insn_idx + 1, VersionList::default());
2153-
}
2154-
2155-
return payload.version_map.get_mut(insn_idx).unwrap();
2156-
}
2157-
21582172
/// Take all of the blocks for a particular place in an iseq
21592173
pub fn take_version_list(blockid: BlockId) -> VersionList {
21602174
let insn_idx = blockid.idx.as_usize();
@@ -2343,15 +2357,21 @@ unsafe fn add_block_version(blockref: BlockRef, cb: &CodeBlock) {
23432357
// Function entry blocks must have stack size 0
23442358
debug_assert!(!(block.iseq_range.start == 0 && Context::decode(block.ctx).stack_size > 0));
23452359

2346-
let version_list = get_or_create_version_list(block.get_blockid());
2360+
// Use a single payload reference for both version_map and pages access
2361+
// to avoid mutable aliasing UB from multiple get_iseq_payload calls.
2362+
let iseq_payload = get_or_create_iseq_payload(block.iseq.get());
2363+
let insn_idx = block.get_blockid().idx.as_usize();
23472364

2348-
// If this the first block being compiled with this block id
2349-
if version_list.len() == 0 {
2350-
incr_counter!(compiled_blockid_count);
2351-
}
2365+
{
2366+
let version_list = iseq_payload.get_or_create_version_list(insn_idx);
23522367

2353-
version_list.push(blockref);
2354-
version_list.shrink_to_fit();
2368+
if version_list.is_empty() {
2369+
incr_counter!(compiled_blockid_count);
2370+
}
2371+
2372+
version_list.push(blockref);
2373+
version_list.shrink_to_fit();
2374+
}
23552375

23562376
// By writing the new block to the iseq, the iseq now
23572377
// contains new references to Ruby objects. Run write barriers.
@@ -2376,7 +2396,6 @@ unsafe fn add_block_version(blockref: BlockRef, cb: &CodeBlock) {
23762396
}
23772397

23782398
// Mark code pages for code GC
2379-
let iseq_payload = get_iseq_payload(block.iseq.get()).unwrap();
23802399
for page in cb.addrs_to_pages(block.start_addr, block.end_addr.get()) {
23812400
iseq_payload.pages.insert(page);
23822401
}
@@ -2495,6 +2514,11 @@ impl Block {
24952514
self.incoming.push(branch);
24962515
}
24972516

2517+
/// Mark this block as dead by setting its iseq to null.
2518+
pub fn set_iseq_null(&self) {
2519+
self.iseq.replace(ptr::null());
2520+
}
2521+
24982522
// Compute the size of the block code
24992523
pub fn code_size(&self) -> usize {
25002524
(self.end_addr.get().as_offset() - self.start_addr.as_offset()).try_into().unwrap()
@@ -4298,37 +4322,16 @@ pub fn invalidate_block_version(blockref: &BlockRef) {
42984322
// in this function before we removed it, so it's well connected.
42994323
unsafe { remove_from_graph(*blockref) };
43004324

4301-
delayed_deallocation(*blockref);
4325+
if let Some(payload) = get_iseq_payload(id_being_invalidated.iseq) {
4326+
payload.delayed_deallocation(*blockref);
4327+
}
43024328

43034329
ocb.unwrap().mark_all_executable();
43044330
cb.mark_all_executable();
43054331

43064332
incr_counter!(invalidation_count);
43074333
}
43084334

4309-
// We cannot deallocate blocks immediately after invalidation since patching the code for setting
4310-
// up return addresses does not affect outstanding return addresses that are on stack and will use
4311-
// invalidated branch pointers when hit. Example:
4312-
// def foo(n)
4313-
// if n == 2
4314-
// # 1.times.each to create a cfunc frame to preserve the JIT frame
4315-
// # which will return to a stub housed in an invalidated block
4316-
// return 1.times.each { Object.define_method(:foo) {} }
4317-
// end
4318-
//
4319-
// foo(n + 1) # The block for this call houses the return branch stub
4320-
// end
4321-
// p foo(1)
4322-
pub fn delayed_deallocation(blockref: BlockRef) {
4323-
block_assumptions_free(blockref);
4324-
4325-
let block = unsafe { blockref.as_ref() };
4326-
// Set null ISEQ on the block to signal that it's dead.
4327-
let iseq = block.iseq.replace(ptr::null());
4328-
let payload = get_iseq_payload(iseq).unwrap();
4329-
payload.dead_blocks.push(blockref);
4330-
}
4331-
43324335
trait RefUnchecked {
43334336
type Contained;
43344337
unsafe fn ref_unchecked(&self) -> &Self::Contained;

yjit/src/invariants.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,7 +642,7 @@ pub extern "C" fn rb_yjit_tracing_invalidate_all() {
642642
if on_stack_iseqs.contains(&iseq) {
643643
// This ISEQ is running, so we can't free blocks immediately
644644
for block in blocks {
645-
delayed_deallocation(block);
645+
payload.delayed_deallocation(block);
646646
}
647647
payload.dead_blocks.shrink_to_fit();
648648
} else {

0 commit comments

Comments
 (0)