Skip to content

Fix stack-use-after-return when the depth limit refuses a hash - #4210

Open
matz wants to merge 1 commit into
ruby:mainfrom
matz:fix-hash-keys-stack-use-after-return
Open

Fix stack-use-after-return when the depth limit refuses a hash#4210
matz wants to merge 1 commit into
ruby:mainfrom
matz:fix-hash-keys-stack-use-after-return

Conversation

@matz

@matz matz commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

This fixes a stack-use-after-return found while fuzzing mruby, which vendors Prism as its compiler. It is the same shape as #4155: a stack address parked in the parser and left behind on a path that never takes it back out.

Reproduction, a plain Ruby file:

"\n*[(~->return e,**{:," * 50

Under ASan with detect_stack_use_after_return=1:

ERROR: AddressSanitizer: stack-use-after-return
READ of size 4 at ... thread T0
    #0 pm_node_hash_insert src/static_literals.c:207
    #1 pm_static_literals_add src/static_literals.c:488
    #2 pm_hash_key_static_literals_add src/prism.c:13760
    #3 parse_assocs src/prism.c:13884
Address ... is located in stack of thread T0 in frame parse_arguments (src/prism.c:14105)
    hash_keys (line 14140) <== Memory access is inside this variable

Cause

parse_assocs stores the address of its caller's stack-local pm_static_literals_t into parser->current_hash_keys, so that a hash written directly into another one with ** shares its keys for the sake of the duplicate-key warning. The PM_TOKEN_BRACE_LEFT case in parse_expression_prefix is the only place that takes it back out, and it sets the field to NULL as it does, exactly so that no later expression can use it.

parse_expression returns before it reaches parse_expression_prefix when depth has hit PRISM_DEPTH_MAXIMUM. On input deep enough to reach the limit inside such a hash, nothing takes the pointer. parse_arguments then returns, its hash_keys dies, and the next hash to be parsed adds its keys to a dead stack frame.

Fix

Clear parser->current_hash_keys where the expression it was handed over for is refused. The hand-over is one-shot and its consumer runs first thing inside the very parse_expression call that is declining here, so a pointer still set at this point is one nothing will ever take.

Reaching it at the default depth

PRISM_DEPTH_MAXIMUM is 10000 by default, and an ASan build's frames are large enough that the C stack goes first on input that deep. mruby compiles Prism with PRISM_DEPTH_MAXIMUM=256, which is how the fuzzer found it; the reproduction above is with that value. The defect itself is not conditional on the figure, only on reaching the limit inside a **{...}.

Verification

  • The reproduction above, and the original 485-byte fuzzer testcase, parse cleanly with this patch and report the stack-use-after-return without it. Checked on main at 3ea210b with PRISM_DEPTH_MAXIMUM=256, and in mruby's build.
  • Serialized ASTs of 270 Ruby files (mruby's own .rb sources) are byte-identical before and after, at the default depth.
  • The duplicate-key warning this mechanism exists for is unchanged across 11 shapes, including {a: 1, **{a: 2}}, foo(a: 1, **{a: 2}) and {a: 1, **{**{a: 2}}}.
  • mruby's full suite is green with the patch: 2549 assertions, 0 failures, under MRB_GC_STRESS, ASan/UBSan, and its five CI build configurations.

I could not run Prism's own Ruby test suite here (no rake-compiler available), so I have leaned on the AST differential above; CI will cover the rest.

parse_assocs stores the address of its caller's stack-local
pm_static_literals_t into parser->current_hash_keys so that a hash
written directly into another with ** shares its keys, and the
PM_TOKEN_BRACE_LEFT case in parse_expression_prefix is the only place
that takes it back out and sets it to NULL.

parse_expression returns before reaching parse_expression_prefix when
depth has hit PRISM_DEPTH_MAXIMUM, so on input deep enough to reach the
limit inside such a hash, nothing takes the pointer. The frame holding
the static literals then returns, and the next hash to be parsed adds
its keys to a dead stack frame.

Clear the pointer where the expression it was handed over for is
refused.

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant