Skip to content

Commit 8f9bd3b

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 8f9bd3b

File tree

3 files changed

+85
-0
lines changed

3 files changed

+85
-0
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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,26 @@ 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+
* Emit a range for the segment after RETURN (the loop continuation),
977+
* then update last_use so the main range ends at this FE_FREE. */
978+
uint32_t next_fe_free = last_use[var_num];
979+
/* Emit range for loop continuation: [RETURN+1, next_FE_FREE) */
980+
if (opnum + 2 < next_fe_free) {
981+
emit_live_range_raw(op_array, var_num, ZEND_LIVE_LOOP,
982+
opnum + 2, next_fe_free);
983+
}
984+
/* Update last_use so the main range ends at this FE_FREE */
985+
last_use[var_num] = opnum;
966986
}
967987
}
968988
if (opline->op2_type & (IS_TMP_VAR|IS_VAR)) {

0 commit comments

Comments
 (0)