Skip to content

Commit 9d89a89

Browse files
cataphractbwoebi
authored andcommitted
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 # Conflicts: # Zend/tests/gc_048.phpt # Zend/tests/gc_049.phpt
1 parent 2c112e3 commit 9d89a89

6 files changed

Lines changed: 163 additions & 14 deletions

File tree

Zend/tests/gc_051.phpt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
--TEST--
2+
GC 048: FE_FREE should mark variable as UNDEF to prevent use-after-free during GC
3+
--FILE--
4+
<?php
5+
// FE_FREE frees the iterator but doesn't set zval to UNDEF
6+
// When GC runs during RETURN, zend_gc_remove_root_tmpvars() may access freed memory
7+
8+
function test_foreach_early_return(string $s): object {
9+
foreach ((array) $s as $v) {
10+
$obj = new stdClass;
11+
// in the early return, the VAR for the cast result is still live
12+
return $obj; // the return may trigger GC
13+
}
14+
}
15+
16+
for ($i = 0; $i < 100000; $i++) {
17+
// create cyclic garbage to fill GC buffer
18+
$a = new stdClass;
19+
$b = new stdClass;
20+
$a->ref = $b;
21+
$b->ref = $a;
22+
23+
$result = test_foreach_early_return("x");
24+
}
25+
26+
echo "OK\n";
27+
?>
28+
--EXPECT--
29+
OK

Zend/tests/gc_052.phpt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
--TEST--
2+
GC 049: Multiple early returns from foreach should create separate live ranges
3+
--FILE--
4+
<?php
5+
6+
function f(int $n): object {
7+
foreach ((array) $n as $v) {
8+
if ($n === 1) {
9+
$a = new stdClass;
10+
return $a;
11+
}
12+
if ($n === 2) {
13+
$b = new stdClass;
14+
return $b;
15+
}
16+
if ($n === 3) {
17+
$c = new stdClass;
18+
return $c;
19+
}
20+
}
21+
return new stdClass;
22+
}
23+
24+
for ($i = 0; $i < 100000; $i++) {
25+
// Create cyclic garbage to trigger GC
26+
$a = new stdClass;
27+
$b = new stdClass;
28+
$a->r = $b;
29+
$b->r = $a;
30+
31+
$r = f($i % 3 + 1);
32+
}
33+
echo "OK\n";
34+
?>
35+
--EXPECT--
36+
OK

Zend/zend_opcode.c

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,38 @@ static void zend_calc_live_ranges(
981981
/* OP_DATA is really part of the previous opcode. */
982982
last_use[var_num] = opnum - (opline->opcode == ZEND_OP_DATA);
983983
}
984+
} else if (opline->opcode == ZEND_FE_FREE
985+
&& opline->extended_value & ZEND_FREE_ON_RETURN
986+
&& opnum + 1 < op_array->last
987+
&& ((opline + 1)->opcode == ZEND_RETURN
988+
|| (opline + 1)->opcode == ZEND_RETURN_BY_REF
989+
|| (opline + 1)->opcode == ZEND_GENERATOR_RETURN)) {
990+
/* FE_FREE with ZEND_FREE_ON_RETURN immediately followed by RETURN frees
991+
* the loop variable on early return. We need to split the live range
992+
* so GC doesn't access the freed variable after this FE_FREE.
993+
*
994+
* FE_FREE is included in the range only if it pertains to an early
995+
* return. */
996+
uint32_t opnum_last_use = last_use[var_num]; // likely a FE_FREE
997+
__auto_type opline_last_use = &op_array->opcodes[opnum_last_use];
998+
if (opline_last_use->opcode == ZEND_FE_FREE &&
999+
opline_last_use->extended_value & ZEND_FREE_ON_RETURN) {
1000+
/* another early return; we include the FE_FREE */
1001+
emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP,
1002+
opnum + 2, opnum_last_use + 1);
1003+
} else if (opline_last_use->opcode == ZEND_FE_FREE &&
1004+
!(opline_last_use->extended_value & ZEND_FREE_ON_RETURN)) {
1005+
/* the normal return; don't include the FE_FREE */
1006+
emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP,
1007+
opnum + 2, opnum_last_use);
1008+
} else {
1009+
/* if the last use is not FE_FREE, include it */
1010+
emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP,
1011+
opnum + 2, opnum_last_use + 1);
1012+
}
1013+
1014+
/* Update last_use so next range includes this FE_FREE */
1015+
last_use[var_num] = opnum + 1;
9841016
}
9851017
}
9861018
if (opline->op2_type & (IS_TMP_VAR|IS_VAR)) {

Zend/zend_vm_def.h

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8144,19 +8144,45 @@ ZEND_VM_HANDLER(149, ZEND_HANDLE_EXCEPTION, ANY, ANY)
81448144
*/
81458145
const zend_live_range *range = find_live_range(
81468146
&EX(func)->op_array, throw_op_num, throw_op->op1.var);
8147-
/* free op1 of the corresponding RETURN */
8148-
for (i = throw_op_num; i < range->end; i++) {
8149-
if (EX(func)->op_array.opcodes[i].opcode == ZEND_FREE
8150-
|| EX(func)->op_array.opcodes[i].opcode == ZEND_FE_FREE) {
8147+
8148+
/* free op1 of the corresponding RETURN - must use original throw_op_num
8149+
* and first range, before any split-range skipping */
8150+
uint32_t range_end = range->end;
8151+
for (i = throw_op_num; i < range_end; i++) {
8152+
__auto_type current_opline = EX(func)->op_array.opcodes[i];
8153+
if (current_opline.opcode == ZEND_FREE
8154+
|| current_opline.opcode == ZEND_FE_FREE) {
8155+
if (current_opline.extended_value & ZEND_FREE_ON_RETURN) {
8156+
/* if this is a split end, the ZEND_RETURN is not included
8157+
* in the range, so extend the range */
8158+
range_end++;
8159+
}
81518160
/* pass */
81528161
} else {
8153-
if (EX(func)->op_array.opcodes[i].opcode == ZEND_RETURN
8154-
&& (EX(func)->op_array.opcodes[i].op1_type & (IS_VAR|IS_TMP_VAR))) {
8155-
zval_ptr_dtor(EX_VAR(EX(func)->op_array.opcodes[i].op1.var));
8162+
if (current_opline.opcode == ZEND_RETURN
8163+
&& (current_opline.op1_type & (IS_VAR|IS_TMP_VAR))) {
8164+
zval_ptr_dtor(EX_VAR(current_opline.op1.var));
81568165
}
81578166
break;
81588167
}
81598168
}
8169+
8170+
/* skip any split ranges to find the final range of the loop var and
8171+
* adjust throw_op_num */
8172+
for (;;) {
8173+
if (range->end < EX(func)->op_array.last) {
8174+
__auto_type last_range_opline = EX(func)->op_array.opcodes[range->end - 1];
8175+
if (last_range_opline.opcode == ZEND_FE_FREE &&
8176+
(last_range_opline.extended_value & ZEND_FREE_ON_RETURN)) {
8177+
/* the range was split, skip to find the final range */
8178+
throw_op_num = range->end + 1;
8179+
range = find_live_range(
8180+
&EX(func)->op_array, throw_op_num, throw_op->op1.var);
8181+
continue;
8182+
}
8183+
}
8184+
break;
8185+
}
81608186
throw_op_num = range->end;
81618187
}
81628188

Zend/zend_vm_execute.h

Lines changed: 33 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)