From 4f93355ea7b292e98edcd099ba9de6d98f2cfb3e Mon Sep 17 00:00:00 2001 From: Randy Stauner Date: Tue, 21 Apr 2026 15:27:10 -0700 Subject: [PATCH 1/3] Add asan_config.rb for AddressSanitizer builds Instruments mruby with AddressSanitizer to help surface memory safety issues in tests and tools. See the file header for usage. --- asan_config.rb | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 asan_config.rb diff --git a/asan_config.rb b/asan_config.rb new file mode 100644 index 0000000000..0a76d4f332 --- /dev/null +++ b/asan_config.rb @@ -0,0 +1,42 @@ +# Build config for building mruby under AddressSanitizer to help +# surface memory safety issues (use-after-free, heap overflow, etc.) +# in tests and tools. +# +# Usage: +# MRUBY_CONFIG=$(pwd)/asan_config.rb rake clean +# MRUBY_CONFIG=$(pwd)/asan_config.rb rake test +# +# Note: `rake clean test` in a single invocation fails because +# mrbgems/mruby-test/mrbgem.rake writes build/test/active_gems.lst +# eagerly at rakefile-load time; `clean` then deletes it, and nothing +# regenerates it later in the same invocation. Run the two tasks as +# separate rake calls. + +def detect_toolchain + cc = ENV['CC'] || 'cc' + `#{cc} --version 2>&1` =~ /clang/i ? :clang : :gcc +end + +MRuby::Build.new('test') do |conf| + toolchain detect_toolchain + + enable_debug + conf.enable_bintest + conf.enable_test + + conf.gembox 'default' + + asan_flags = %w[-fsanitize=address -fno-omit-frame-pointer -g -O1] + conf.cc.flags.concat asan_flags + conf.cxx.flags.concat asan_flags if conf.respond_to?(:cxx) + conf.linker.flags.concat asan_flags +end + +MRuby::Build.new('host') do |conf| + toolchain detect_toolchain + conf.gembox 'default' + + asan_flags = %w[-fsanitize=address -fno-omit-frame-pointer -g -O1] + conf.cc.flags.concat asan_flags + conf.linker.flags.concat asan_flags +end From d08cc4872dbca38ecc77ef6e59274f55cc4550cc Mon Sep 17 00:00:00 2001 From: Randy Stauner Date: Tue, 21 Apr 2026 11:54:54 -0700 Subject: [PATCH 2/3] Add regression test for mrb_exc_backtrace use-after-free The C API mrb_exc_backtrace() walks the exception's saved ciidx against the live callinfo stack. After exception unwinding, the callinfo entries at cibase[ci+1..ciidx] still hold RProc pointers, but those RProcs are no longer reachable from any GC root (mark_context only marks up to c->ci). A subsequent GC frees them, and the walk then dereferences freed memory at ci->proc->body.irep. Adds an ExceptionTest.run_uaf_poc C helper that reproduces this in an isolated mrb_state: 1. Load a Ruby script that recurses 80+ levels deep, each level creating a unique lambda, then raises. 2. Immediately (no intervening Ruby) force several full GCs and churn the heap with throwaway string allocations so the freed RProc slots get overwritten. 3. Call mrb_exc_backtrace() and return the length of the result. Run under AddressSanitizer to turn the UAF into an observable error: MRUBY_CONFIG=$(pwd)/asan_config.rb rake clean MRUBY_CONFIG=$(pwd)/asan_config.rb rake test Note: equivalent pure-Ruby scenarios do not reliably reproduce because the test VM keeps executing Ruby between rescue and backtrace extraction, pushing new callinfo entries that overwrite the stale slots. A dedicated mrb_state is required. --- mrbgems/mruby-error/test/exception.c | 69 +++++++++++++++++++++++++++ mrbgems/mruby-error/test/exception.rb | 16 +++++++ 2 files changed, 85 insertions(+) diff --git a/mrbgems/mruby-error/test/exception.c b/mrbgems/mruby-error/test/exception.c index 4de0e96076..849e64273d 100644 --- a/mrbgems/mruby-error/test/exception.c +++ b/mrbgems/mruby-error/test/exception.c @@ -1,6 +1,74 @@ #include #include #include +#include +#include +#include + +/* + * Reproduces the HackerOne #3677726 PoC (use-after-free in each_backtrace) + * from a single C entry point. + * + * The PoC must run in a fresh mrb_state and call mrb_exc_backtrace() + * immediately after mrb_load_string returns with mrb->exc set. At that + * point c->ci is back at cibase[0] and *no Ruby code runs* in between, + * which keeps the high-index callinfo slots in their stale/unreachable + * state. Running the equivalent Ruby from the test VM fails to reproduce + * because the test harness's own Ruby execution between raise and the + * backtrace call overwrites those slots with live callinfo. + * + * Each recursive level builds a *unique* lambda and invokes it, so every + * callinfo entry in the peak stack references a distinct RProc. When GC + * sweeps them after the unwind, the ci->proc pointers become dangling; + * the subsequent mrb_exc_backtrace() dereferences that freed memory. + */ +static const char *uaf_script = + "def recurse(n)\n" + " local = lambda { n }\n" + " if n >= 80\n" + " raise 'trigger_uaf'\n" + " end\n" + " f = lambda { recurse(n + 1) }\n" + " f.call\n" + "end\n" + "recurse(1)\n"; + +static mrb_value +run_uaf_poc(mrb_state *mrb, mrb_value self) +{ + /* + * Run the PoC inside a fresh mrb_state so the test harness's own Ruby + * execution doesn't overwrite the stale callinfo slots between raise + * and backtrace extraction (which is what prevents reproduction when + * the same scenario is attempted from pure Ruby in the test VM). + */ + mrb_state *mrb2 = mrb_open(); + if (!mrb2) return mrb_nil_value(); + + mrbc_context *ctx = mrbc_context_new(mrb2); + mrbc_filename(mrb2, ctx, "uaf_poc.rb"); + mrb_load_string_cxt(mrb2, uaf_script, ctx); + + mrb_int len = -1; + if (mrb2->exc) { + mrb_value exception = mrb_obj_value(mrb2->exc); + + /* Churn the heap so freed RProc slots get reused with other data — + turning a silent UAF into an observable crash (SIGSEGV or ASan + error). Mirrors the H1 PoC's post-raise loop. */ + for (int i = 0; i < 20; i++) mrb_full_gc(mrb2); + for (int i = 0; i < 10000; i++) mrb_str_new_lit(mrb2, "ZZZZZZZZZZZZZZZZ"); + + /* Unsafe path: walks cibase[ciidx] down to 0, including stale entries. */ + mrb_value backtrace = mrb_exc_backtrace(mrb2, exception); + len = RARRAY_LEN(backtrace); + } + + mrbc_context_free(mrb2, ctx); + mrb_close(mrb2); + + return mrb_fixnum_value(len); +} static mrb_value protect_cb(mrb_state *mrb, mrb_value b) @@ -56,4 +124,5 @@ mrb_mruby_error_gem_test(mrb_state *mrb) mrb_define_module_function(mrb, cls, "mrb_ensure", run_ensure, MRB_ARGS_REQ(2)); mrb_define_module_function(mrb, cls, "mrb_rescue", run_rescue, MRB_ARGS_REQ(2)); mrb_define_module_function(mrb, cls, "mrb_rescue_exceptions", run_rescue_exceptions, MRB_ARGS_REQ(2)); + mrb_define_module_function(mrb, cls, "run_uaf_poc", run_uaf_poc, MRB_ARGS_NONE()); } diff --git a/mrbgems/mruby-error/test/exception.rb b/mrbgems/mruby-error/test/exception.rb index 9084650451..d00f1886e2 100644 --- a/mrbgems/mruby-error/test/exception.rb +++ b/mrbgems/mruby-error/test/exception.rb @@ -53,3 +53,19 @@ class CustomExp < Exception ExceptionTest.mrb_rescue_exceptions Proc.new { raise TypeError.new 'test' }, Proc.new { 'rescue' } end end + +assert 'each_backtrace UAF regression (H1 #3677726 PoC in isolated mrb_state)' do + # This mirrors the original HackerOne PoC's C harness exactly: a fresh + # mrb_state runs the deep-recursion-then-raise script, then (without any + # intervening Ruby execution to clobber the stale callinfo slots) calls + # mrb_exc_backtrace directly. Without the fix, GC churn between raise + # and backtrace walk should free RProc objects whose pointers still live + # in cibase[ciidx..], and the walk will dereference freed memory. + # + # A passing return value means the walk completed without crashing and + # produced some backtrace entries. Under ASan, a regression shows up as + # a heap-use-after-free report rather than a silent success. + len = ExceptionTest.run_uaf_poc + assert_kind_of Integer, len + assert_true len >= 0, "expected non-negative backtrace length, got #{len}" +end From d0dafa56a2700430f8203c574655c22a547924e6 Mon Sep 17 00:00:00 2001 From: Randy Stauner Date: Mon, 16 Mar 2026 20:26:28 -0700 Subject: [PATCH 3/3] Fix use-after-free in mrb_exc_backtrace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C API mrb_exc_backtrace() walked the exception's saved ciidx against the live callinfo stack. By the time a host extracts the backtrace, that stack has usually unwound — the callinfo entries at cibase[ci+1..ciidx] still hold RProc pointers, but those RProcs are no longer reachable from any GC root (mark_context only marks up to c->ci). A subsequent GC frees them, and the walk then dereferences freed memory at ci->proc->body.irep. Fix: mrb_exc_backtrace() now returns the snapshot that mrb_save_backtrace already captured at raise time (while the callinfo was still valid), matching what Exception#backtrace does on the Ruby side. The unsafe walk over potentially-stale callinfo is gone. Resolution order: 1. The exception's 'backtrace' ivar, if already materialized. 2. mrb->backtrace.entries — the snapshot from raise time. 3. An empty array, if neither is available (rather than risking UAF). each_backtrace() itself is left as-is; it is still reached via the print/save paths from mrb_print_backtrace and mrb_save_backtrace, both of which run while the callinfo stack is valid. --- src/backtrace.c | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/backtrace.c b/src/backtrace.c index 2a180f9284..0dd92809bb 100644 --- a/src/backtrace.c +++ b/src/backtrace.c @@ -292,15 +292,45 @@ mrb_print_backtrace(mrb_state *mrb) #endif +MRB_API mrb_value mrb_restore_backtrace(mrb_state *mrb); + MRB_API mrb_value mrb_exc_backtrace(mrb_state *mrb, mrb_value self) { - mrb_value ary; + mrb_sym attr_name; + mrb_value backtrace; - ary = mrb_ary_new(mrb); - exc_output_backtrace(mrb, mrb_obj_ptr(self), get_backtrace_i, (void*)mrb_ary_ptr(ary)); + /* + ** Return the exception's backtrace without walking the live callinfo + ** stack. The callinfo entries referenced by the exception's saved ciidx + ** may have been unwound by the time a C caller asks for the backtrace, + ** leaving stale ci->proc pointers to RProc objects that GC has since + ** reclaimed (they are not marked above c->ci). Walking those entries + ** would be a use-after-free. + ** + ** Preferred sources, in order: + ** 1. The "backtrace" ivar, if already materialized (e.g. by + ** Exception#backtrace on the Ruby side). + ** 2. mrb->backtrace.entries — the snapshot mrb_save_backtrace() + ** captured at raise time, while callinfo was still valid. + ** If neither is available, return an empty array rather than risk + ** dereferencing freed memory. + */ + attr_name = mrb_intern_lit(mrb, "backtrace"); + backtrace = mrb_iv_get(mrb, self, attr_name); + if (!mrb_nil_p(backtrace)) { + return backtrace; + } - return ary; + if (mrb_obj_ptr(self) == mrb->backtrace.exc && mrb->backtrace.n > 0) { + backtrace = mrb_restore_backtrace(mrb); + mrb->backtrace.n = 0; + mrb->backtrace.exc = 0; + mrb_iv_set(mrb, self, attr_name, backtrace); + return backtrace; + } + + return mrb_ary_new(mrb); } MRB_API mrb_value