Hi,
we ran into an assertion failure and a memory leak with the php-fuzz-function-jit fuzzing harness.
The fix for this seems a bit complex, so I'm opening an issue instead of a PR.
When the opcache block pass eliminates an unreachable basic block, it does not account for temporaries that were created in a reachable block and only consumed in the removed one. If no uses remain, zend_calc_live_ranges() gives the temporary no live range and nothing destroys it while an exception unwinds. If a non-consuming use such as ZEND_BIND_LEXICAL remains, live-range calculation can instead hit an assertion.
The following code aborts inside zend_optimize_script in debug builds:
<?php
function test_assert() {
(function () use ($u) {}) ** match (1) { 2 => 3 };
}
php: Zend/zend_opcode.c:912: bool keeps_op1_alive(zend_op *): Assertion `opline->opcode != 78
&& opline->opcode != 126 && opline->opcode != 124 && opline->opcode != 182 && opline->opcode != 55'
failed.
Here ZEND_BIND_LEXICAL is left behind as the only remaining use of the closure temporary. keeps_op1_alive() asserts that this can never be the last use of its operand, so a debug build aborts while recomputing the live ranges.
Reproducer for memory leak
The following code:
<?php
class D {
public function __construct(public string $name) {}
public function __clone() { $this->name .= '-clone'; }
public function __destruct() { echo "destruct {$this->name}\n"; }
}
function one($o) {
(clone $o) ** match (1) { 2 => 3 };
}
$a = new D('a');
try { one($a); } catch (\Throwable $e) { echo "caught\n"; }
unset($a);
echo "done\n";
Results in this output (php -d opcache.enable_cli=1 repro.php):
caught
done
destruct a
destruct a-clone
But results in this with opcache.optimization_level=0 or without opcache:
destruct a-clone
caught
done
destruct a
The clone is not destroyed while the UnhandledMatchError unwinds one(); it survives
until request shutdown. On a debug build it is reported as a memory leak:
Zend/zend_objects.c(215) : Freeing 0x7f9ad6e5dde0 (56 bytes), script=repro.php
=== Total 1 memory leaks detected ===
LLM root cause analysis
The constant comparison in match (1) { 2 => 3 } folds to false, leaving an unconditional MATCH_ERROR. This makes the rest of the expression unreachable, including the ZEND_POW that consumes the clone. The dumps below are for a minimal version of the memory-leak reproducer:
function test_leak($o) {
(clone $o) ** match (1) { 2 => 3 };
}
Before the optimizer (opcache.opt_debug_level=65536):
test_leak:
; (lines=11, args=1, vars=1, tmps=4)
0000 CV0($o) = RECV 1
0001 T1 = CLONE CV0($o)
0002 T2 = IS_IDENTICAL int(1) int(2)
0003 JMPNZ T2 0006
0004 JMP 0005
0005 MATCH_ERROR int(1)
0006 T3 = QM_ASSIGN int(3)
0007 JMP 0008
0008 T4 = POW T1 T3
0009 FREE T4
0010 RETURN null
LIVE RANGES:
1: 0002 - 0008 (tmp/var)
3: 0007 - 0008 (tmp/var)
After the optimizer (opcache.opt_debug_level=131072):
test_leak:
; (lines=3, args=1, vars=1, tmps=1)
0000 CV0($o) = RECV 1
0001 T1 = CLONE CV0($o)
0002 MATCH_ERROR int(1)
0002 folds to false, so 0006-0009 become unreachable and are dropped. T1 is defined at 0001 and now has no use anywhere, so it gets no live range and cleanup_live_vars() never sees it when MATCH_ERROR throws.
The same shape for the assertion case:
test_assert:
0000 T1 = DECLARE_LAMBDA_FUNCTION 0
0001 BIND_LEXICAL T1 CV0($u) <- non-consuming use, survives
0002 T2 = IS_IDENTICAL int(1) int(2)
0003 JMPNZ T2 0006
0004 JMP 0005
0005 MATCH_ERROR int(1)
0006 T3 = QM_ASSIGN int(3)
0007 JMP 0008
0008 T4 = POW T1 T3 <- consuming use, removed with the block
0009 FREE T4
0010 RETURN null
The machinery to handle this already exists but only covers loop variables: an unreachable block that frees a loop var created in a reachable block is marked ZEND_BB_UNREACHABLE_FREE (Zend/Optimizer/zend_cfg.c) and kept, reduced to the FREE/FE_FREE it contains, precisely so that the variable's live range still ends there. Ordinary temporaries get no such treatment.
Other expressions reach the same state. The orphaned value can also be the string produced by interpolation, and one unreachable block can orphan more than one temporary:
function two($a, $b) { (clone $a) ** ((clone $b) ** match (1) { 2 => 3 }); }
function rope($o) { return "x{$o->name}y" . match (1) { 2 => 3 }; }
Reference ASAN leak report from the fuzzing harness
/out/php-fuzz-function-jit: Running 1 inputs 100 time(s) each.
Running: /testcase
=================================================================
==14==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 40 byte(s) in 1 object(s) allocated from:
#0 0x560c44ba9df4 in malloc /src/llvm-project/compiler-rt/lib/asan/asan_malloc_linux.cpp:67:3
#1 0x560c458e0231 in tracked_malloc /src/php-src/Zend/zend_alloc.c:3016:14
#2 0x560c45e9abf2 in zend_objects_new /src/php-src/Zend/zend_objects.c:190:24
#3 0x560c45e9abf2 in zend_objects_clone_obj /src/php-src/Zend/zend_objects.c:341:15
#4 0x560c45c9f177 in ZEND_CLONE_SPEC_CV_TAILCALL_HANDLER /src/php-src/Zend/zend_vm_execute.h:92932:2
#5 0x560c45a37304 in execute_ex /src/php-src/Zend/zend_vm_execute.h:110551:12
#6 0x560c45a3813d in zend_execute /src/php-src/Zend/zend_vm_execute.h:115989:2
#7 0x560c45f153bf in fuzzer_do_request_from_buffer /src/php-src/sapi/fuzzer/fuzzer-sapi.c:293:5
#8 0x560c45f139e0 in LLVMFuzzerTestOneInput /src/php-src/sapi/fuzzer/fuzzer-function-jit.c:40:3
[..]
DEDUP_TOKEN: __interceptor_malloc--tracked_malloc--zend_objects_new
Direct leak of 40 byte(s) in 1 object(s) allocated from:
#0 0x560c44ba9df4 in malloc /src/llvm-project/compiler-rt/lib/asan/asan_malloc_linux.cpp:67:3
#1 0x560c458e0231 in tracked_malloc /src/php-src/Zend/zend_alloc.c:3016:14
#2 0x560c45e9abf2 in zend_objects_new /src/php-src/Zend/zend_objects.c:190:24
#3 0x560c45e9abf2 in zend_objects_clone_obj /src/php-src/Zend/zend_objects.c:341:15
#4 0x560c45b18212 in ZEND_CLONE_SPEC_CV_HANDLER /src/php-src/Zend/zend_vm_execute.h:40289:2
#5 0x560c45f140bb in fuzzer_execute_ex /src/php-src/sapi/fuzzer/fuzzer-execute-common.h:65:12
#6 0x560c45acad0c in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /src/php-src/Zend/zend_vm_execute.h:1996:4
#7 0x560c45f140bb in fuzzer_execute_ex /src/php-src/sapi/fuzzer/fuzzer-execute-common.h:65:12
#8 0x560c45a3813d in zend_execute /src/php-src/Zend/zend_vm_execute.h:115989:2
#9 0x560c45f153bf in fuzzer_do_request_from_buffer /src/php-src/sapi/fuzzer/fuzzer-sapi.c:293:5
#10 0x560c45f13954 in LLVMFuzzerTestOneInput /src/php-src/sapi/fuzzer/fuzzer-function-jit.c:32:2
[..]
DEDUP_TOKEN: __interceptor_malloc--tracked_malloc--zend_objects_new
SUMMARY: AddressSanitizer: 80 byte(s) leaked in 2 allocation(s).
INFO: a leak has been found in the initial corpus.
INFO: to ignore leaks on libFuzzer side use -detect_leaks=0.
Reference assertion report from the fuzzing harness
/out/php-fuzz-function-jit: Running 1 inputs 100 time(s) each.
Running: /testcase
php-fuzz-function-jit: /src/php-src/Zend/zend_opcode.c:934: _Bool keeps_op1_alive(zend_op *): Assertion `opline->opcode != 78 && opline->opcode != 126 && opline->opcode != 124 && opline->opcode != 182 && opline->opcode != 55' failed.
AddressSanitizer:DEADLYSIGNAL
=================================================================
==14==ERROR: AddressSanitizer: ABRT on unknown address 0x00000000000e (pc 0x7fd3bd3f800b bp 0x7fd3bd56d588 sp 0x7ffd70670130 T0)
SCARINESS: 10 (signal)
#0 0x7fd3bd3f800b in raise (/lib/x86_64-linux-gnu/libc.so.6+0x4300b) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
#1 0x7fd3bd3d7858 in abort (/lib/x86_64-linux-gnu/libc.so.6+0x22858) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
#2 0x7fd3bd3d7728 (/lib/x86_64-linux-gnu/libc.so.6+0x22728) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
#3 0x7fd3bd3e8fd5 in __assert_fail (/lib/x86_64-linux-gnu/libc.so.6+0x33fd5) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
#4 0x5640272a99a8 in keeps_op1_alive /src/php-src/Zend/zend_opcode.c:930:2
#5 0x5640272a99a8 in zend_calc_live_ranges /src/php-src/Zend/zend_opcode.c:1006:9
#6 0x564026cc4c31 in zend_optimize_script /src/php-src/Zend/Optimizer/zend_optimizer.c
#7 0x564026437b79 in cache_script_in_shared_memory /src/php-src/ext/opcache/ZendAccelerator.c:1598:2
#8 0x564026439b6d in persistent_compile_file /src/php-src/ext/opcache/ZendAccelerator.c:2399:24
#9 0x56402731538e in fuzzer_do_request_from_buffer /src/php-src/sapi/fuzzer/fuzzer-sapi.c:289:29
#10 0x564027313954 in LLVMFuzzerTestOneInput /src/php-src/sapi/fuzzer/fuzzer-function-jit.c:32:2
[..]
DEDUP_TOKEN: raise--abort--
SUMMARY: AddressSanitizer: ABRT (/lib/x86_64-linux-gnu/libc.so.6+0x4300b) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d) in raise
==14==ABORTING
In case this is useful: Here's a branch with a generated fix for this. I'm not sure if that's the way to go, but feel free to pick the commit and/or re-use the test case: https://github.com/Mrmaxmeier/php-src/tree/fix-unreachable-orphan-tmp
Thanks!
Found by the CISPA Fandango team while triaging findings in oss-fuzz harnesses.
PHP Version
Reproduces on debug builds of PHP 8.4, 8.5, and master (Linux).
Hi,
we ran into an assertion failure and a memory leak with the
php-fuzz-function-jitfuzzing harness.The fix for this seems a bit complex, so I'm opening an issue instead of a PR.
When the opcache block pass eliminates an unreachable basic block, it does not account for temporaries that were created in a reachable block and only consumed in the removed one. If no uses remain,
zend_calc_live_ranges()gives the temporary no live range and nothing destroys it while an exception unwinds. If a non-consuming use such asZEND_BIND_LEXICALremains, live-range calculation can instead hit an assertion.The following code aborts inside
zend_optimize_scriptin debug builds:Here
ZEND_BIND_LEXICALis left behind as the only remaining use of the closure temporary.keeps_op1_alive()asserts that this can never be the last use of its operand, so a debug build aborts while recomputing the live ranges.Reproducer for memory leak
The following code:
Results in this output (
php -d opcache.enable_cli=1 repro.php):But results in this with
opcache.optimization_level=0or without opcache:The clone is not destroyed while the
UnhandledMatchErrorunwindsone(); it survivesuntil request shutdown. On a debug build it is reported as a memory leak:
LLM root cause analysis
The constant comparison in
match (1) { 2 => 3 }folds to false, leaving an unconditionalMATCH_ERROR. This makes the rest of the expression unreachable, including theZEND_POWthat consumes the clone. The dumps below are for a minimal version of the memory-leak reproducer:Before the optimizer (
opcache.opt_debug_level=65536):After the optimizer (
opcache.opt_debug_level=131072):0002folds to false, so0006-0009become unreachable and are dropped.T1is defined at0001and now has no use anywhere, so it gets no live range andcleanup_live_vars()never sees it whenMATCH_ERRORthrows.The same shape for the assertion case:
The machinery to handle this already exists but only covers loop variables: an unreachable block that frees a loop var created in a reachable block is marked
ZEND_BB_UNREACHABLE_FREE(Zend/Optimizer/zend_cfg.c) and kept, reduced to theFREE/FE_FREEit contains, precisely so that the variable's live range still ends there. Ordinary temporaries get no such treatment.Other expressions reach the same state. The orphaned value can also be the string produced by interpolation, and one unreachable block can orphan more than one temporary:
Reference ASAN leak report from the fuzzing harness
Reference assertion report from the fuzzing harness
In case this is useful: Here's a branch with a generated fix for this. I'm not sure if that's the way to go, but feel free to pick the commit and/or re-use the test case: https://github.com/Mrmaxmeier/php-src/tree/fix-unreachable-orphan-tmp
Thanks!
Found by the CISPA Fandango team while triaging findings in oss-fuzz harnesses.
PHP Version