From e92e4c7f5f2f93786c99e941e25a9c3be02dfadc Mon Sep 17 00:00:00 2001 From: Mrmaxmeier Date: Mon, 24 Aug 2026 16:26:09 +0200 Subject: [PATCH] Fix live ranges of ZEND_JMP_SET and ZEND_COALESCE Both opcodes write their result only on the branch they take. zend_calc_live_ranges() started their live range right behind the definition, so it also covered the fall-through path, on which the temporary was never written. Normally another opcode redefines the result on the fall-through path, which hides the problem because the backward walk stops at that later definition. Once the optimizer has removed it the bogus range shows up: try { $a ?: match (true) { 1 => 1 }; } catch (UnhandledMatchError $e) { } Here the QM_ASSIGN of the else branch is gone because the match always throws, and unwinding the UnhandledMatchError makes cleanup_live_vars() destroy uninitialized stack memory. With ZEND_COALESCE the temporary slot may be shared with a preceding opcode whose value has already been freed, which turns this into a use after free. Start the live range at the jump target instead. On the branch that is taken control transfers there directly, so that is exactly the region in which the result is defined. Assisted-By: Claude Opus 5 --- NEWS | 2 ++ Zend/zend_opcode.c | 16 +++++++++ ...zzer_function_jit_live_range_coalesce.phpt | 33 +++++++++++++++++++ ...uzzer_function_jit_live_range_jmp_set.phpt | 32 ++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 ext/opcache/tests/fuzzer_function_jit_live_range_coalesce.phpt create mode 100644 ext/opcache/tests/fuzzer_function_jit_live_range_jmp_set.phpt diff --git a/NEWS b/NEWS index 519b0ccaf053..2034934a0f88 100644 --- a/NEWS +++ b/NEWS @@ -7,6 +7,8 @@ PHP NEWS next() call on the inner generator). (iliaal) . Fixed bug GH-23301 (Nested "yield from" yields a value twice when the middle generator delegates again). (Lazizbek Ergashev) + . Fixed the live range of ZEND_JMP_SET and ZEND_COALESCE covering the + fall-through path. (Mrmaxmeier) - CLI: . Fixed bug GH-23425 (sapi_cli_server_send_headers() does not check the diff --git a/Zend/zend_opcode.c b/Zend/zend_opcode.c index 317f68b486bb..1df4f1da3f6f 100644 --- a/Zend/zend_opcode.c +++ b/Zend/zend_opcode.c @@ -777,6 +777,22 @@ static void emit_live_range( kind = ZEND_LIVE_LOOP; start++; break; + case ZEND_JMP_SET: + case ZEND_COALESCE: + /* These opcodes only write their result on the branch they take. + * The live range must therefore start at the jump target, not + * behind the definition, or it would also cover the fall-through + * path on which the result was never written. */ + if (needs_live_range && !needs_live_range(op_array, orig_def_opline)) { + return; + } + kind = ZEND_LIVE_TMPVAR; + start = OP_JMP_ADDR(def_opline, def_opline->op2) - op_array->opcodes; + if (start >= end) { + /* The result is freed right at the jump target. */ + return; + } + break; /* Objects created via ZEND_NEW are only fully initialized * after the DO_FCALL (constructor call). * We are creating two live-ranges: ZEND_LINE_NEW for uninitialized diff --git a/ext/opcache/tests/fuzzer_function_jit_live_range_coalesce.phpt b/ext/opcache/tests/fuzzer_function_jit_live_range_coalesce.phpt new file mode 100644 index 000000000000..a0a323b04774 --- /dev/null +++ b/ext/opcache/tests/fuzzer_function_jit_live_range_coalesce.phpt @@ -0,0 +1,33 @@ +--TEST-- +Live range of ZEND_COALESCE must not cover the fall-through path +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +opcache.jit=disable +--ENV-- +USE_ZEND_ALLOC=0 +USE_TRACKED_ALLOC=1 +--FILE-- + 1 }; + } +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +function coalesce($a, $b) { + return $a ?? $b; +} +var_dump(coalesce("x", "y"), coalesce(null, "y")); +echo "OK\n"; +?> +--EXPECT-- +UnhandledMatchError: Unhandled match case true +string(1) "x" +string(1) "y" +OK diff --git a/ext/opcache/tests/fuzzer_function_jit_live_range_jmp_set.phpt b/ext/opcache/tests/fuzzer_function_jit_live_range_jmp_set.phpt new file mode 100644 index 000000000000..5b8bb9a008c3 --- /dev/null +++ b/ext/opcache/tests/fuzzer_function_jit_live_range_jmp_set.phpt @@ -0,0 +1,32 @@ +--TEST-- +Live range of ZEND_JMP_SET must not cover the fall-through path +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +opcache.jit=disable +--ENV-- +USE_ZEND_ALLOC=0 +USE_TRACKED_ALLOC=1 +--FILE-- + 1 }; +} catch (Throwable $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +function elvis($a, $b) { + return $a ?: $b; +} +var_dump(elvis("x", "y"), elvis("", "y"), elvis(null, "z")); +echo "OK\n"; +?> +--EXPECTF-- +Warning: Undefined variable $a in %s on line %d +UnhandledMatchError: Unhandled match case true +string(1) "x" +string(1) "y" +string(1) "z" +OK