From dd57ddec580034c8798f78985a189e68e00f49c9 Mon Sep 17 00:00:00 2001 From: Mrmaxmeier Date: Mon, 24 Aug 2026 16:26:09 +0200 Subject: [PATCH] Fix block pass dropping a JMP across an unreachable_free block An unreachable block that frees a loop variable created in a reachable block is marked ZEND_BB_UNREACHABLE_FREE and is still emitted, because the FREE opcodes it contains determine where the live range of that variable ends. Two places that decide whether a ZEND_JMP can be dropped because it targets the block that physically follows skipped over every block that is not ZEND_BB_REACHABLE, including those. The jump was therefore removed even though an unreachable_free block still sat between the two, and control fell straight into a FREE opcode that was never meant to be executed: function test($a, $b, $c) { do { if ($a) { switch ($b[0]) { case 'x': switch ($c[0]) { default: return "returned"; } default: continue 2; } } } while (false); return $b[0]; } Taking the "continue 2" path freed the switch subject twice, and the resulting live range was inverted, which also tripped the start < end assertion in emit_live_range_raw(). Assisted-By: Claude Opus 5 --- NEWS | 2 + Zend/Optimizer/block_pass.c | 12 +++++- ...zer_function_jit_unreachable_free_jmp.phpt | 43 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 ext/opcache/tests/fuzzer_function_jit_unreachable_free_jmp.phpt diff --git a/NEWS b/NEWS index 608bacc29109..33aa3e946bfd 100644 --- a/NEWS +++ b/NEWS @@ -54,6 +54,8 @@ PHP NEWS . Fixed a tracing JIT crash when compiling a side trace for a method of a class that could not be stored in the inheritance cache. (GH-21710) (Arnaud, iliaal) + . Fixed a ZEND_JMP being removed across an unreachable block that is kept + alive for its loop variable frees. (Mrmaxmeier) - PDO: . Fixed a leak when a persistent connection failed a liveness check diff --git a/Zend/Optimizer/block_pass.c b/Zend/Optimizer/block_pass.c index ee70d021f4a9..70e9eb142ac9 100644 --- a/Zend/Optimizer/block_pass.c +++ b/Zend/Optimizer/block_pass.c @@ -950,7 +950,12 @@ static void assemble_code_blocks(zend_cfg *cfg, zend_op_array *op_array, zend_op if (opline->opcode == ZEND_JMP) { zend_basic_block *next = b + 1; - while (next < end && !(next->flags & ZEND_BB_REACHABLE)) { + /* Unreachable blocks that are kept alive for their loop var + * frees are emitted as well, so they still separate this block + * from its successor. */ + while (next < end + && !(next->flags & ZEND_BB_REACHABLE) + && !((next->flags & ZEND_BB_UNREACHABLE_FREE) && next->len != 0)) { next++; } if (next < end && next == blocks + b->successors[0]) { @@ -1149,6 +1154,11 @@ static zend_always_inline zend_basic_block *get_next_block(const zend_cfg *cfg, return NULL; } else if (next_block->flags & ZEND_BB_REACHABLE) { break; + } else if ((next_block->flags & ZEND_BB_UNREACHABLE_FREE) && next_block->len != 0) { + /* This block is unreachable, but it is still emitted to keep the + * live range of a loop var alive, so it separates the block from + * whatever follows it. */ + return NULL; } next_block++; } diff --git a/ext/opcache/tests/fuzzer_function_jit_unreachable_free_jmp.phpt b/ext/opcache/tests/fuzzer_function_jit_unreachable_free_jmp.phpt new file mode 100644 index 000000000000..b46475c5d82c --- /dev/null +++ b/ext/opcache/tests/fuzzer_function_jit_unreachable_free_jmp.phpt @@ -0,0 +1,43 @@ +--TEST-- +Block pass must not strip a JMP across an unreachable block that is kept for its loop var frees +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +opcache.jit=disable +--ENV-- +USE_ZEND_ALLOC=0 +USE_TRACKED_ALLOC=1 +--FILE-- + +--EXPECT-- +bool(true) +string(8) "returned" +string(1) "y" +OK