Skip to content

vm.c: refresh regs before storing the OP_GETIDX0 Hash result - #7023

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:vm-getidx0-refresh-regs
Aug 9, 2026
Merged

vm.c: refresh regs before storing the OP_GETIDX0 Hash result#7023
matz merged 1 commit into
mruby:masterfrom
takumin:vm-getidx0-refresh-regs

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The bug

vm_op_getidx0() stores the Hash result through the registers it came in with:

  else if (tt == MRB_TT_HASH) {
    if (mrb_obj_ptr(recv)->c != mrb->hash_class) goto getidx0_fallback;
    regs[a] = mrb_hash_get(mrb, recv, mrb_fixnum_value(0));
    return VM_NEXT;
  }

regs is #define regs (ci->stack). mrb_hash_get() runs the default proc when the key is missing. For a plain Hash.new { ... } the default method is still the builtin, so mrb_func_basic_p() succeeds and the work goes to hash_default(), which calls the proc through mrb_funcall_argv2(); the mrb_funcall_argv() at the end of mrb_hash_get() is the other route into the same place, taken when default has been overridden. Either way the VM is re-entered, and a proc that pushes enough frames reaches stack_extend(), which reallocates c->stbase and frees the old buffer.

The destination of the store is ci->stack + a, and the C evaluation order for an assignment is unspecified, so the address is computed on the way in and the write lands in the freed buffer. A ci refresh cannot be spliced into that single statement either, so the value has to pass through a local first, which is what puts the refresh before the store.

Reproducing

def deep(n)
  return 0 if n == 0
  deep(n - 1)
end

h = Hash.new { |hash, key| deep(50); :from_proc }

def probe(h)
  v = h[0]
  v
end

p probe(h)

Built with build_config/clang-asan.rb:

==614957==ERROR: AddressSanitizer: heap-use-after-free on address 0x70412fbe00b8
WRITE of size 8 at 0x70412fbe00b8 thread T0
    #1 vm_op_getidx0 src/vm.c:2127:15
    #2 mrb_vm_exec   src/vm.c:2555:15
freed by thread T0 here:
    #4 stack_extend_alloc     src/vm.c:209:37
    #5 stack_extend           src/vm.c:226:5
    #6 mrb_vm_exec            src/vm.c:2934:11
    #9 mrb_funcall_with_block src/vm.c:878:13
    #10 mrb_funcall_argv      src/vm.c:906:10
    #11 mrb_funcall_argv2     include/mruby.h:1235:10
    #12 hash_default          src/hash.c:1314:14
    #13 mrb_hash_get          src/hash.c:1403:12
    #14 vm_op_getidx0         src/vm.c:2127:15
    #15 mrb_vm_exec           src/vm.c:2555:15

The frame that frees the buffer is the default proc's own VM run, so the free and the write both belong to the one h[0].

Changing h[0] to h[1] moves the work to OP_GETIDX, and that one prints :from_proc and exits 0 under the same sanitizer. So what the two branches differ by is the refresh, not the default proc.

deep(50) is the whole requirement. CALLINFO_INIT_SIZE is 32, so fifty frames also reallocate c->cibase, which leaves the helper's own ci pointing into a freed array as well. The sanitizer reports the stack buffer first because that is what the store touches, but both are stale.

The fix

Take the result into a local, refresh ci, then store, which is what vm_op_getidx() and both branches of vm_op_setidx() already do. The ci = mrb->c->ci; in the CASE(OP_GETIDX0) arm refreshes the caller's copy after the helper has returned, which is too late for a store inside it.

This is the shape 7b503f3a3 ("vm.c: store through the refreshed regs after a VM re-entry") fixed for OP_GETIV and OP_ARYSPLAT. vm_op_getidx0() was not covered there.

The test

test/t/hash.rb gets an assertion over both h[0] and h[1], so it covers the branch that is broken and the branch that is the model for the fix. It asserts the value rather than the memory error, since the abandoned buffer usually still holds something plausible: on an unsanitized build the unfixed VM prints :from_proc and exits 0 all the same. The sanitizer build is what makes the failure deterministic, and build_config/clang-asan.rb already runs rake test.

build with the change without
host (build_config/default.rb) 1925 OK, 0 KO, 0 Crash same, the corruption is silent
build_config/clang-asan.rb 2112 OK, 0 KO, 0 Crash heap-use-after-free at vm_op_getidx0, run aborted

Overlap with #7022

#7022 puts an arena save and restore around these same three lines. The two changes are independent in effect, the refresh does not change what the arena does and the restore does not change where the store goes, but they do touch the same lines, so whichever lands second wants a trivial resolve. The combined form is:

    int ai = mrb_gc_arena_save(mrb);
    mrb_value val = mrb_hash_get(mrb, recv, mrb_fixnum_value(0));
    ci = mrb->c->ci;
    regs[a] = val;
    mrb_gc_arena_restore(mrb, ai);

This branches from 0b4e3f15d rather than from #7022 so that it can be read and merged on its own.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed hash lookups with default procedures during deep recursive calls, preventing incorrect results when the runtime stack grows.
    • Hash indexing now reliably returns the expected default value in these scenarios.
  • Tests

    • Added regression coverage for hash access that recursively re-enters the runtime.

`mrb_hash_get()` reaches the default proc through `hash_default()` and
`mrb_funcall_argv2()`, which re-enters the VM, and a proc that pushes
enough frames reaches `stack_extend()` and reallocates the stack.
`regs[a] = mrb_hash_get(...)` computes the address of `regs[a]` before
the call, so the store lands in the freed buffer. A `ci` refresh cannot
be spliced into that one statement either, so the value has to pass
through a local first.

`vm_op_getidx()` and both branches of `vm_op_setidx()` already do that.
`vm_op_getidx0()` is the one that does not, and the `ci = mrb->c->ci;`
in the `CASE(OP_GETIDX0)` arm refreshes the caller's copy after the
helper has already returned, which is too late. This is the shape
7b503f3 fixed for `OP_GETIV` and `OP_ARYSPLAT`.

AddressSanitizer reports a `heap-use-after-free` on

    def deep(n)
      return 0 if n == 0
      deep(n - 1)
    end
    h = Hash.new { |_, _| deep(50); :from_proc }
    h[0]

with the write in `vm_op_getidx0()` and the free in `stack_extend()`
inside the proc's own VM run. Changing `h[0]` to `h[1]` moves the work
to `OP_GETIDX` and is clean under the same sanitizer, so what the two
differ by is the refresh rather than the default proc.

The test asserts the value rather than the memory error, since the
abandoned buffer usually still holds something plausible and an
unsanitized build notices nothing. It aborts the run under
`build_config/clang-asan.rb`, which is the build the assertion is there
to feed.
@takumin
takumin requested a review from matz as a code owner August 9, 2026 07:28
@github-actions github-actions Bot added the core label Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 34f6d441-b86c-430a-9786-20d2898070c4

📥 Commits

Reviewing files that changed from the base of the PR and between 9318697 and 13e017c.

📒 Files selected for processing (2)
  • src/vm.c
  • test/t/hash.rb

📝 Walkthrough

Walkthrough

The hash index fast path now saves the lookup result before VM stack pointers are refreshed. A regression test covers recursive default-proc execution that grows the VM stack.

Changes

Hash indexing stack safety

Layer / File(s) Summary
Preserve lookup result across stack growth
src/vm.c, test/t/hash.rb
vm_op_getidx0 saves the result of mrb_hash_get before refreshing VM pointers. The regression test checks h[0] and h[1] with a recursive, stack-growing default proc.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • mruby/mruby#7022: Both changes modify VM hash indexing and test recursive default-proc behavior.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: refreshing VM registers before storing the OP_GETIDX0 Hash result.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matz
matz merged commit 4461d87 into mruby:master Aug 9, 2026
21 checks passed
@takumin
takumin deleted the vm-getidx0-refresh-regs branch August 9, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants