Commit 2b99297b764 for php

commit 2b99297b76457100e1688e49697bb0e6a5124327
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Mon Oct 19 08:26:05 2026 -0400

    ext/standard: Validate objects before queuing unserialize callbacks

    Check the closing brace before queuing __unserialize() or __wakeup().
    Incomplete object records must use the same cleanup as invalid property
    data, including destructor suppression. The __unserialize() data array of
    a failed record now stays in the unserialize context until var_destroy()
    instead of being released at once, so a destructor of a nested object
    cannot resolve a back-reference to an element the array already released.

    Closes GH-24433.

diff --git a/NEWS b/NEWS
index 14df7132c7c..a654cec2f39 100644
--- a/NEWS
+++ b/NEWS
@@ -248,6 +248,8 @@ PHP                                                                        NEWS
 - Standard:
   . Fixed memory leaks in checkdnsrr() and dns_check_record() on Windows.
     (Ilia Alshanetsky)
+  . Fixed __unserialize() and __wakeup() being called for objects with a missing
+    closing brace. (Ilia Alshanetsky)
   . Fixed bug GH-24308 (Request shutdown calls closelog() even when syslog
     was not opened by PHP). (lazerg)
   . Fixed bug GH-24399 (usleep() and time_nanosleep() ignore queued APCs on
diff --git a/ext/date/tests/unserialize-test.phpt b/ext/date/tests/unserialize-test.phpt
index aaff75db6b7..6096d7b7288 100644
--- a/ext/date/tests/unserialize-test.phpt
+++ b/ext/date/tests/unserialize-test.phpt
@@ -31,7 +31,6 @@

 ossfuzz-55599.txt:
 %s: unserialize(): Error at offset 26 of 26 bytes in %sunserialize-test.php on line %d
-Error: Invalid serialization data for DateTime object
 bool(false)


@@ -42,6 +41,4 @@

 ossfuzz-56931.txt:
 %s: unserialize(): Error at offset 39 of 39 bytes in %sunserialize-test.php on line %d
-
-Deprecated: Creation of dynamic property DateInterval::$ is deprecated in %s on line %d
 bool(false)
diff --git a/ext/standard/tests/serialize/__unserialize_malformed.phpt b/ext/standard/tests/serialize/__unserialize_malformed.phpt
new file mode 100644
index 00000000000..6d43767e20f
--- /dev/null
+++ b/ext/standard/tests/serialize/__unserialize_malformed.phpt
@@ -0,0 +1,38 @@
+--TEST--
+__unserialize() and destructors are not called for incomplete objects
+--FILE--
+<?php
+class Test {
+    public function __unserialize(array $data): void {
+        echo "__unserialize: ";
+        var_dump($data);
+    }
+
+    public function __destruct() {
+        echo "__destruct\n";
+    }
+}
+
+foreach ([
+    'empty' => 'O:4:"Test":0:{',
+    'nonempty' => 'O:4:"Test":1:{i:0;i:42;',
+    'nested' => 'a:1:{i:0;O:4:"Test":0:{',
+] as $name => $data) {
+    echo "$name: ";
+    var_dump(@unserialize($data));
+}
+
+echo "valid: ";
+var_dump(unserialize('O:4:"Test":1:{i:0;i:42;}'));
+?>
+--EXPECT--
+empty: bool(false)
+nonempty: bool(false)
+nested: bool(false)
+valid: __unserialize: array(1) {
+  [0]=>
+  int(42)
+}
+object(Test)#1 (0) {
+}
+__destruct
diff --git a/ext/standard/tests/serialize/__unserialize_malformed_nested_destructor.phpt b/ext/standard/tests/serialize/__unserialize_malformed_nested_destructor.phpt
new file mode 100644
index 00000000000..94ae62bb097
--- /dev/null
+++ b/ext/standard/tests/serialize/__unserialize_malformed_nested_destructor.phpt
@@ -0,0 +1,27 @@
+--TEST--
+Destructor of a nested object in a failed __unserialize() payload cannot reach freed slots
+--FILE--
+<?php
+class T {
+    public function __unserialize(array $data): void {}
+}
+
+class D {
+    public function __destruct() {
+        var_dump(unserialize('R:2;'));
+    }
+}
+
+foreach ([
+    'missing brace' => 'O:1:"T":2:{i:0;s:5:"hello";i:1;O:1:"D":0:{}',
+    'malformed element' => 'O:1:"T":3:{i:0;s:5:"hello";i:1;O:1:"D":0:{}i:2;X',
+] as $name => $data) {
+    echo "$name: ";
+    var_dump(@unserialize($data));
+}
+?>
+--EXPECT--
+missing brace: bool(false)
+bool(false)
+malformed element: bool(false)
+bool(false)
diff --git a/ext/standard/tests/serialize/__wakeup_malformed.phpt b/ext/standard/tests/serialize/__wakeup_malformed.phpt
new file mode 100644
index 00000000000..b8cceead72a
--- /dev/null
+++ b/ext/standard/tests/serialize/__wakeup_malformed.phpt
@@ -0,0 +1,38 @@
+--TEST--
+__wakeup() and destructors are not called for incomplete objects
+--FILE--
+<?php
+class Test {
+    public int $value = 0;
+
+    public function __wakeup(): void {
+        echo "__wakeup: $this->value\n";
+    }
+
+    public function __destruct() {
+        echo "__destruct\n";
+    }
+}
+
+foreach ([
+    'empty' => 'O:4:"Test":0:{',
+    'nonempty' => 'O:4:"Test":1:{s:5:"value";i:42;',
+    'nested' => 'a:1:{i:0;O:4:"Test":0:{',
+] as $name => $data) {
+    echo "$name: ";
+    var_dump(@unserialize($data));
+}
+
+echo "valid: ";
+var_dump(unserialize('O:4:"Test":1:{s:5:"value";i:42;}'));
+?>
+--EXPECT--
+empty: bool(false)
+nonempty: bool(false)
+nested: bool(false)
+valid: __wakeup: 42
+object(Test)#1 (1) {
+  ["value"]=>
+  int(42)
+}
+__destruct
diff --git a/ext/standard/var_unserializer.re b/ext/standard/var_unserializer.re
index e5cc1acd758..01092119b2a 100644
--- a/ext/standard/var_unserializer.re
+++ b/ext/standard/var_unserializer.re
@@ -855,10 +855,11 @@ static inline int object_common(UNSERIALIZE_PARAMETER, zend_long elements, bool
 		if (UNEXPECTED(!array_finit_size_mixed(&ary, elements))) {
 			return 0;
 		}
-		if (!process_nested_array_data(UNSERIALIZE_PASSTHRU, Z_ARRVAL(ary), elements)) {
+		if (!process_nested_array_data(UNSERIALIZE_PASSTHRU, Z_ARRVAL(ary), elements)
+				|| !finish_nested_data(UNSERIALIZE_PASSTHRU)) {
 			ZVAL_DEREF(rval);
 			GC_ADD_FLAGS(Z_OBJ_P(rval), IS_OBJ_DESTRUCTOR_CALLED);
-			zval_ptr_dtor(&ary);
+			ZVAL_COPY_VALUE(var_tmp_var(var_hash), &ary);
 			return 0;
 		}

@@ -871,7 +872,7 @@ static inline int object_common(UNSERIALIZE_PARAMETER, zend_long elements, bool
 		tmp++;
 		ZVAL_COPY_VALUE(tmp, &ary);

-		return finish_nested_data(UNSERIALIZE_PASSTHRU);
+		return 1;
 	}

 	has_wakeup = Z_OBJCE_P(rval) != PHP_IC_ENTRY
@@ -885,7 +886,8 @@ static inline int object_common(UNSERIALIZE_PARAMETER, zend_long elements, bool
 	if (UNEXPECTED(zend_hash_fextend(ht, zend_hash_num_elements(ht) + elements, HT_IS_PACKED(ht)) == FAILURE)) {
 		return 0;
 	}
-	if (!process_nested_object_data(UNSERIALIZE_PASSTHRU, ht, elements, Z_OBJ_P(rval))) {
+	if (!process_nested_object_data(UNSERIALIZE_PASSTHRU, ht, elements, Z_OBJ_P(rval))
+			|| !finish_nested_data(UNSERIALIZE_PASSTHRU)) {
 		if (has_wakeup) {
 			ZVAL_DEREF(rval);
 			GC_ADD_FLAGS(Z_OBJ_P(rval), IS_OBJ_DESTRUCTOR_CALLED);
@@ -901,7 +903,7 @@ static inline int object_common(UNSERIALIZE_PARAMETER, zend_long elements, bool
 		Z_EXTRA_P(wakeup_var) = VAR_WAKEUP_FLAG;
 	}

-	return finish_nested_data(UNSERIALIZE_PASSTHRU);
+	return 1;
 }
 #ifdef PHP_WIN32
 # pragma optimize("", on)