From 0c6e1b585bfebd04cefd539ff5d8c209d092f700 Mon Sep 17 00:00:00 2001 From: Gustavo Lopes Date: Tue, 23 Dec 2025 10:50:09 +0000 Subject: [PATCH] Fix use-after-free in FE_FREE with GC interaction When FE_FREE with ZEND_FREE_ON_RETURN frees the loop variable during an early return from a foreach loop, the live range for the loop variable was incorrectly extending past the FE_FREE to the normal loop end. This caused GC to access the already-freed loop variable when it ran after the RETURN opcode, resulting in use-after-free. Fix by splitting the ZEND_LIVE_LOOP range when an FE_FREE with ZEND_FREE_ON_RETURN is encountered: - One range covers the early return path up to the FE_FREE - A separate range covers the normal loop end FE_FREE - Multiple early returns create multiple separate ranges --- Zend/tests/gc_048.phpt | 29 +++++++++++++++++++++++++++++ Zend/tests/gc_049.phpt | 36 ++++++++++++++++++++++++++++++++++++ Zend/zend_opcode.c | 32 ++++++++++++++++++++++++++++++++ Zend/zend_vm_def.h | 40 +++++++++++++++++++++++++++++++++------- Zend/zend_vm_execute.h | 40 +++++++++++++++++++++++++++++++++------- 5 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 Zend/tests/gc_048.phpt create mode 100644 Zend/tests/gc_049.phpt diff --git a/Zend/tests/gc_048.phpt b/Zend/tests/gc_048.phpt new file mode 100644 index 0000000000000..575a25a108a15 --- /dev/null +++ b/Zend/tests/gc_048.phpt @@ -0,0 +1,29 @@ +--TEST-- +GC 048: FE_FREE should mark variable as UNDEF to prevent use-after-free during GC +--FILE-- +ref = $b; + $b->ref = $a; + + $result = test_foreach_early_return("x"); +} + +echo "OK\n"; +?> +--EXPECT-- +OK diff --git a/Zend/tests/gc_049.phpt b/Zend/tests/gc_049.phpt new file mode 100644 index 0000000000000..dd15c56bcbf54 --- /dev/null +++ b/Zend/tests/gc_049.phpt @@ -0,0 +1,36 @@ +--TEST-- +GC 049: Multiple early returns from foreach should create separate live ranges +--FILE-- +r = $b; + $b->r = $a; + + $r = f($i % 3 + 1); +} +echo "OK\n"; +?> +--EXPECT-- +OK diff --git a/Zend/zend_opcode.c b/Zend/zend_opcode.c index 06f411a1d3663..f1f1bc4247a82 100644 --- a/Zend/zend_opcode.c +++ b/Zend/zend_opcode.c @@ -963,6 +963,38 @@ static void zend_calc_live_ranges( /* OP_DATA is really part of the previous opcode. */ last_use[var_num] = opnum - (opline->opcode == ZEND_OP_DATA); } + } else if (opline->opcode == ZEND_FE_FREE + && opline->extended_value & ZEND_FREE_ON_RETURN + && opnum + 1 < op_array->last + && ((opline + 1)->opcode == ZEND_RETURN + || (opline + 1)->opcode == ZEND_RETURN_BY_REF + || (opline + 1)->opcode == ZEND_GENERATOR_RETURN)) { + /* FE_FREE with ZEND_FREE_ON_RETURN immediately followed by RETURN frees + * the loop variable on early return. We need to split the live range + * so GC doesn't access the freed variable after this FE_FREE. + * + * FE_FREE is included in the range only if it pertains to an early + * return. */ + uint32_t opnum_last_use = last_use[var_num]; // likely a FE_FREE + __auto_type opline_last_use = &op_array->opcodes[opnum_last_use]; + if (opline_last_use->opcode == ZEND_FE_FREE && + opline_last_use->extended_value & ZEND_FREE_ON_RETURN) { + /* another early return; we include the FE_FREE */ + emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP, + opnum + 2, opnum_last_use + 1); + } else if (opline_last_use->opcode == ZEND_FE_FREE && + !(opline_last_use->extended_value & ZEND_FREE_ON_RETURN)) { + /* the normal return; don't include the FE_FREE */ + emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP, + opnum + 2, opnum_last_use); + } else { + /* if the last use is not FE_FREE, include it */ + emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP, + opnum + 2, opnum_last_use + 1); + } + + /* Update last_use so next range includes this FE_FREE */ + last_use[var_num] = opnum + 1; } } if (opline->op2_type & (IS_TMP_VAR|IS_VAR)) { diff --git a/Zend/zend_vm_def.h b/Zend/zend_vm_def.h index f1d4f7448ce1e..83f58abf638ed 100644 --- a/Zend/zend_vm_def.h +++ b/Zend/zend_vm_def.h @@ -8059,19 +8059,45 @@ ZEND_VM_HANDLER(149, ZEND_HANDLE_EXCEPTION, ANY, ANY) */ const zend_live_range *range = find_live_range( &EX(func)->op_array, throw_op_num, throw_op->op1.var); - /* free op1 of the corresponding RETURN */ - for (i = throw_op_num; i < range->end; i++) { - if (EX(func)->op_array.opcodes[i].opcode == ZEND_FREE - || EX(func)->op_array.opcodes[i].opcode == ZEND_FE_FREE) { + + /* free op1 of the corresponding RETURN - must use original throw_op_num + * and first range, before any split-range skipping */ + uint32_t range_end = range->end; + for (i = throw_op_num; i < range_end; i++) { + __auto_type current_opline = EX(func)->op_array.opcodes[i]; + if (current_opline.opcode == ZEND_FREE + || current_opline.opcode == ZEND_FE_FREE) { + if (current_opline.extended_value & ZEND_FREE_ON_RETURN) { + /* if this is a split end, the ZEND_RETURN is not included + * in the range, so extend the range */ + range_end++; + } /* pass */ } else { - if (EX(func)->op_array.opcodes[i].opcode == ZEND_RETURN - && (EX(func)->op_array.opcodes[i].op1_type & (IS_VAR|IS_TMP_VAR))) { - zval_ptr_dtor(EX_VAR(EX(func)->op_array.opcodes[i].op1.var)); + if (current_opline.opcode == ZEND_RETURN + && (current_opline.op1_type & (IS_VAR|IS_TMP_VAR))) { + zval_ptr_dtor(EX_VAR(current_opline.op1.var)); } break; } } + + /* skip any split ranges to find the final range of the loop var and + * adjust throw_op_num */ + for (;;) { + if (range->end < EX(func)->op_array.last) { + __auto_type last_range_opline = EX(func)->op_array.opcodes[range->end - 1]; + if (last_range_opline.opcode == ZEND_FE_FREE && + (last_range_opline.extended_value & ZEND_FREE_ON_RETURN)) { + /* the range was split, skip to find the final range */ + throw_op_num = range->end + 1; + range = find_live_range( + &EX(func)->op_array, throw_op_num, throw_op->op1.var); + continue; + } + } + break; + } throw_op_num = range->end; } diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h index 5f7f4997ce8d7..a074776527abe 100644 --- a/Zend/zend_vm_execute.h +++ b/Zend/zend_vm_execute.h @@ -3234,19 +3234,45 @@ static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_HANDLE_EXCEPTION_SPEC_HANDLER( */ const zend_live_range *range = find_live_range( &EX(func)->op_array, throw_op_num, throw_op->op1.var); - /* free op1 of the corresponding RETURN */ - for (i = throw_op_num; i < range->end; i++) { - if (EX(func)->op_array.opcodes[i].opcode == ZEND_FREE - || EX(func)->op_array.opcodes[i].opcode == ZEND_FE_FREE) { + + /* free op1 of the corresponding RETURN - must use original throw_op_num + * and first range, before any split-range skipping */ + uint32_t range_end = range->end; + for (i = throw_op_num; i < range_end; i++) { + __auto_type current_opline = EX(func)->op_array.opcodes[i]; + if (current_opline.opcode == ZEND_FREE + || current_opline.opcode == ZEND_FE_FREE) { + if (current_opline.extended_value & ZEND_FREE_ON_RETURN) { + /* if this is a split end, the ZEND_RETURN is not included + * in the range, so extend the range */ + range_end++; + } /* pass */ } else { - if (EX(func)->op_array.opcodes[i].opcode == ZEND_RETURN - && (EX(func)->op_array.opcodes[i].op1_type & (IS_VAR|IS_TMP_VAR))) { - zval_ptr_dtor(EX_VAR(EX(func)->op_array.opcodes[i].op1.var)); + if (current_opline.opcode == ZEND_RETURN + && (current_opline.op1_type & (IS_VAR|IS_TMP_VAR))) { + zval_ptr_dtor(EX_VAR(current_opline.op1.var)); } break; } } + + /* skip any split ranges to find the final range of the loop var and + * adjust throw_op_num */ + for (;;) { + if (range->end < EX(func)->op_array.last) { + __auto_type last_range_opline = EX(func)->op_array.opcodes[range->end - 1]; + if (last_range_opline.opcode == ZEND_FE_FREE && + (last_range_opline.extended_value & ZEND_FREE_ON_RETURN)) { + /* the range was split, skip to find the final range */ + throw_op_num = range->end + 1; + range = find_live_range( + &EX(func)->op_array, throw_op_num, throw_op->op1.var); + continue; + } + } + break; + } throw_op_num = range->end; }