From cc8abaf9f109a83c741b9fe5297f1c094c775811 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Wed, 15 Jul 2026 16:20:48 -0400 Subject: [PATCH 1/2] Fix use-after-free serializing an array grown by an element's hook (#22714) The IS_ARRAY case of php_var_serialize_intern() walked the array's HashTable without holding a reference across php_var_serialize_nested_data(), which recurses into user hooks (__serialize, __sleep, Serializable::serialize). A hook that grows the same array through a by-reference alias reallocs the backing store mid-walk, so the iterator reads freed memory. Hold a ref across the walk, as the object path and var_dump/var_export already do, so the append separates a copy instead of reallocating in place. --- .../serialize_array_ref_reentrancy.phpt | 21 +++++++++++++++++++ ext/standard/var.c | 8 +++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 ext/standard/tests/serialize/serialize_array_ref_reentrancy.phpt diff --git a/ext/standard/tests/serialize/serialize_array_ref_reentrancy.phpt b/ext/standard/tests/serialize/serialize_array_ref_reentrancy.phpt new file mode 100644 index 000000000000..490fa4f6c3b0 --- /dev/null +++ b/ext/standard/tests/serialize/serialize_array_ref_reentrancy.phpt @@ -0,0 +1,21 @@ +--TEST-- +serialize(): a by-reference __serialize() that grows the array being walked must not free it +--FILE-- +ref[] = 'x' . $i; + } + return ['d' => 1]; + } +} +$g = new G(); +$inner = [$g, 'tail']; +$g->ref = &$inner; +$top = [&$inner]; +var_dump(serialize($top)); +?> +--EXPECT-- +string(59) "a:1:{i:0;a:2:{i:0;O:1:"G":1:{s:1:"d";i:1;}i:1;s:4:"tail";}}" diff --git a/ext/standard/var.c b/ext/standard/var.c index 3137a270f661..d79b8941634d 100644 --- a/ext/standard/var.c +++ b/ext/standard/var.c @@ -1303,13 +1303,17 @@ static void php_var_serialize_intern(smart_str *buf, zval *struc, php_serialize_ zend_release_properties(myht); return; } - case IS_ARRAY: + case IS_ARRAY: { smart_str_appendl(buf, "a:", 2); myht = Z_ARRVAL_P(struc); + bool rcn = !is_root && (in_rcn_array || GC_REFCOUNT(myht) > 1); + GC_TRY_ADDREF(myht); php_var_serialize_nested_data( buf, struc, myht, zend_array_count(myht), /* incomplete_class */ false, var_hash, - !is_root && (in_rcn_array || GC_REFCOUNT(myht) > 1)); + rcn); + GC_TRY_DTOR_NO_REF(myht); return; + } case IS_REFERENCE: struc = Z_REFVAL_P(struc); goto again; From 6441f89857b3528e6b71a5f9ce6fa1ade3e67d7e Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 9 Apr 2026 15:02:50 -0400 Subject: [PATCH 2/2] Fix GH-21691: OPcache CFG optimizer drops QM_ASSIGN feeding JMPZ/JMPZ_EX with IS_VAR The CFG optimizer (pass 5) substituted a QM_ASSIGN's source operand into its consumer without checking the source's operand type. When ASSIGN_REF produces IS_VAR and a QM_ASSIGN converts it to IS_TMP_VAR before JMPZ/JMPNZ or JMPZ_EX/JMPNZ_EX, eliminating the QM_ASSIGN leaves an IS_VAR operand on a consumer whose handler is declared CONST|TMP|CV, producing "Invalid opcode 43/4/0" (or 46/4/0 for the _EX variants). Guard both substitution sites with src->op1_type != IS_VAR, folded into the enclosing condition (per dstogov's review) so the BOOL_NOT branch is defensively covered as well. Test exercises both JMPZ and JMPZ_EX paths. Closes GH-21691 Closes GH-21696 --- Zend/Optimizer/block_pass.c | 4 ++-- ext/opcache/tests/gh21691.phpt | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 ext/opcache/tests/gh21691.phpt diff --git a/Zend/Optimizer/block_pass.c b/Zend/Optimizer/block_pass.c index 02c28ead33e1..3775f8165c3e 100644 --- a/Zend/Optimizer/block_pass.c +++ b/Zend/Optimizer/block_pass.c @@ -648,7 +648,7 @@ static void zend_optimize_block(zend_basic_block *block, zend_op_array *op_array } else if (opline->op1_type == IS_TMP_VAR && !zend_bitset_in(used_ext, VAR_NUM(opline->op1.var))) { src = VAR_SOURCE(opline->op1); - if (src) { + if (src && (src->op1_type != IS_VAR)) { if (src->opcode == ZEND_BOOL_NOT) { VAR_SOURCE(opline->op1) = NULL; COPY_NODE(opline->op1, src->op1); @@ -692,7 +692,7 @@ static void zend_optimize_block(zend_basic_block *block, zend_op_array *op_array (!zend_bitset_in(used_ext, VAR_NUM(opline->op1.var)) || opline->result.var == opline->op1.var)) { src = VAR_SOURCE(opline->op1); - if (src) { + if (src && (src->op1_type != IS_VAR)) { if (src->opcode == ZEND_BOOL || src->opcode == ZEND_QM_ASSIGN) { VAR_SOURCE(opline->op1) = NULL; diff --git a/ext/opcache/tests/gh21691.phpt b/ext/opcache/tests/gh21691.phpt new file mode 100644 index 000000000000..3926b76c7a12 --- /dev/null +++ b/ext/opcache/tests/gh21691.phpt @@ -0,0 +1,20 @@ +--TEST-- +GH-21691 (OPcache CFG optimizer breaks reference returns with JMPZ/JMPZ_EX) +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +--EXTENSIONS-- +opcache +--FILE-- + +--EXPECT-- +NULL