Skip to content

Commit 0c6e1b5

Browse files
committed
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
1 parent f3b9482 commit 0c6e1b5

File tree

5 files changed

+163
-14
lines changed

5 files changed

+163
-14
lines changed

Zend/tests/gc_048.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_049.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
@@ -963,6 +963,38 @@ static void zend_calc_live_ranges(
963963
/* OP_DATA is really part of the previous opcode. */
964964
last_use[var_num] = opnum - (opline->opcode == ZEND_OP_DATA);
965965
}
966+
} else if (opline->opcode == ZEND_FE_FREE
967+
&& opline->extended_value & ZEND_FREE_ON_RETURN
968+
&& opnum + 1 < op_array->last
969+
&& ((opline + 1)->opcode == ZEND_RETURN
970+
|| (opline + 1)->opcode == ZEND_RETURN_BY_REF
971+
|| (opline + 1)->opcode == ZEND_GENERATOR_RETURN)) {
972+
/* FE_FREE with ZEND_FREE_ON_RETURN immediately followed by RETURN frees
973+
* the loop variable on early return. We need to split the live range
974+
* so GC doesn't access the freed variable after this FE_FREE.
975+
*
976+
* FE_FREE is included in the range only if it pertains to an early
977+
* return. */
978+
uint32_t opnum_last_use = last_use[var_num]; // likely a FE_FREE
979+
__auto_type opline_last_use = &op_array->opcodes[opnum_last_use];
980+
if (opline_last_use->opcode == ZEND_FE_FREE &&
981+
opline_last_use->extended_value & ZEND_FREE_ON_RETURN) {
982+
/* another early return; we include the FE_FREE */
983+
emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP,
984+
opnum + 2, opnum_last_use + 1);
985+
} else if (opline_last_use->opcode == ZEND_FE_FREE &&
986+
!(opline_last_use->extended_value & ZEND_FREE_ON_RETURN)) {
987+
/* the normal return; don't include the FE_FREE */
988+
emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP,
989+
opnum + 2, opnum_last_use);
990+
} else {
991+
/* if the last use is not FE_FREE, include it */
992+
emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP,
993+
opnum + 2, opnum_last_use + 1);
994+
}
995+
996+
/* Update last_use so next range includes this FE_FREE */
997+
last_use[var_num] = opnum + 1;
966998
}
967999
}
9681000
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
@@ -8059,19 +8059,45 @@ ZEND_VM_HANDLER(149, ZEND_HANDLE_EXCEPTION, ANY, ANY)
80598059
*/
80608060
const zend_live_range *range = find_live_range(
80618061
&EX(func)->op_array, throw_op_num, throw_op->op1.var);
8062-
/* free op1 of the corresponding RETURN */
8063-
for (i = throw_op_num; i < range->end; i++) {
8064-
if (EX(func)->op_array.opcodes[i].opcode == ZEND_FREE
8065-
|| EX(func)->op_array.opcodes[i].opcode == ZEND_FE_FREE) {
8062+
8063+
/* free op1 of the corresponding RETURN - must use original throw_op_num
8064+
* and first range, before any split-range skipping */
8065+
uint32_t range_end = range->end;
8066+
for (i = throw_op_num; i < range_end; i++) {
8067+
__auto_type current_opline = EX(func)->op_array.opcodes[i];
8068+
if (current_opline.opcode == ZEND_FREE
8069+
|| current_opline.opcode == ZEND_FE_FREE) {
8070+
if (current_opline.extended_value & ZEND_FREE_ON_RETURN) {
8071+
/* if this is a split end, the ZEND_RETURN is not included
8072+
* in the range, so extend the range */
8073+
range_end++;
8074+
}
80668075
/* pass */
80678076
} else {
8068-
if (EX(func)->op_array.opcodes[i].opcode == ZEND_RETURN
8069-
&& (EX(func)->op_array.opcodes[i].op1_type & (IS_VAR|IS_TMP_VAR))) {
8070-
zval_ptr_dtor(EX_VAR(EX(func)->op_array.opcodes[i].op1.var));
8077+
if (current_opline.opcode == ZEND_RETURN
8078+
&& (current_opline.op1_type & (IS_VAR|IS_TMP_VAR))) {
8079+
zval_ptr_dtor(EX_VAR(current_opline.op1.var));
80718080
}
80728081
break;
80738082
}
80748083
}
8084+
8085+
/* skip any split ranges to find the final range of the loop var and
8086+
* adjust throw_op_num */
8087+
for (;;) {
8088+
if (range->end < EX(func)->op_array.last) {
8089+
__auto_type last_range_opline = EX(func)->op_array.opcodes[range->end - 1];
8090+
if (last_range_opline.opcode == ZEND_FE_FREE &&
8091+
(last_range_opline.extended_value & ZEND_FREE_ON_RETURN)) {
8092+
/* the range was split, skip to find the final range */
8093+
throw_op_num = range->end + 1;
8094+
range = find_live_range(
8095+
&EX(func)->op_array, throw_op_num, throw_op->op1.var);
8096+
continue;
8097+
}
8098+
}
8099+
break;
8100+
}
80758101
throw_op_num = range->end;
80768102
}
80778103

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)