vm.c: refresh regs before storing the OP_GETIDX0 Hash result - #7023
Merged
Conversation
`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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe 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. ChangesHash indexing stack safety
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
vm_op_getidx0()stores the Hash result through the registers it came in with:regsis#define regs (ci->stack).mrb_hash_get()runs the default proc when the key is missing. For a plainHash.new { ... }thedefaultmethod is still the builtin, somrb_func_basic_p()succeeds and the work goes tohash_default(), which calls the proc throughmrb_funcall_argv2(); themrb_funcall_argv()at the end ofmrb_hash_get()is the other route into the same place, taken whendefaulthas been overridden. Either way the VM is re-entered, and a proc that pushes enough frames reachesstack_extend(), which reallocatesc->stbaseand 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. Acirefresh 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
Built with
build_config/clang-asan.rb: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]toh[1]moves the work toOP_GETIDX, and that one prints:from_procand 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_SIZEis 32, so fifty frames also reallocatec->cibase, which leaves the helper's owncipointing 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 whatvm_op_getidx()and both branches ofvm_op_setidx()already do. Theci = mrb->c->ci;in theCASE(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 forOP_GETIVandOP_ARYSPLAT.vm_op_getidx0()was not covered there.The test
test/t/hash.rbgets an assertion over bothh[0]andh[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_procand exits 0 all the same. The sanitizer build is what makes the failure deterministic, andbuild_config/clang-asan.rbalready runsrake test.build_config/default.rb)build_config/clang-asan.rbheap-use-after-freeatvm_op_getidx0, run abortedOverlap 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:
This branches from
0b4e3f15drather than from #7022 so that it can be read and merged on its own.Summary by CodeRabbit
Bug Fixes
Tests