Commit eafcd683690 for php.net

commit eafcd683690ce00ce53023f4847fa4fb0f94546d
Author: Tim Düsterhus <tim@tideways-gmbh.com>
Date:   Tue Sep 15 20:09:44 2026 +0200

    zend_API: Verify property types in `object_properties_load()` (#23639)

    * zend_API: Verify property types in `object_properties_load()`

    Fixes php/php-src#9708.

    * zend_API: Fix various issues in `object_properties_load()`

    Fixes Fixes php/php-src#9707.

    Co-authored-by: Tim Düsterhus <tim@tideways-gmbh.com>

    * NEWS

    * random: Remove now-obsolete manual `$engine` type check in `Randomizer::__unserialize()`

    * zend_API: Make `object_properties_load()` use `strict_types=1`

    This is consistent with regular unserialization, which also performs strict
    type checking.

    Co-authored-by: Gina Peter Banyard <girgias@php.net>

    * Block creation of readonly reference properties

    Co-authored-by: Arnaud Le Blanc <365207+arnaud-lb@users.noreply.github.com>

    ---------

    Co-authored-by: Nora Dossche <7771979+ndossche@users.noreply.github.com>
    Co-authored-by: Gina Peter Banyard <girgias@php.net>
    Co-authored-by: Arnaud Le Blanc <365207+arnaud-lb@users.noreply.github.com>

diff --git a/NEWS b/NEWS
index 193316edf84..0a3e4cb674f 100644
--- a/NEWS
+++ b/NEWS
@@ -5,6 +5,8 @@ PHP                                                                        NEWS
 - Core:
   . Fixed incorrect internal pointer and foreach iterator positions when
     compacting arrays with holes. (Weilin Du)
+  . Fix handling of references to typed properties during unserialization
+    of various internal classes. (ndossche, timwolla)

 - DOM:
   . Fixed use-after-free when re-constructing a DOMXPath whose php:function
diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS
index 31995eb8075..8bbdc5caabe 100644
--- a/UPGRADING.INTERNALS
+++ b/UPGRADING.INTERNALS
@@ -182,6 +182,8 @@ PHP 8.6 INTERNALS UPGRADE NOTES
     instead of a zval*. Accordingly, zend_get_closure_this_ptr() now returns
     that zend_object*, or NULL when the closure is unbound, instead of a
     zval* that is IS_UNDEF when the closure is unbound.
+  . object_properties_load() now verifies that the given value is assignable
+    to typed properties. The check is performed in strict mode.

 - Added:
   . New zend_class_entry.ce_flags2 and zend_function.fn_flags2 fields were
diff --git a/Zend/zend_API.c b/Zend/zend_API.c
index 43e21cafd56..4f84404919f 100644
--- a/Zend/zend_API.c
+++ b/Zend/zend_API.c
@@ -1758,7 +1758,7 @@ ZEND_API void object_properties_load(zend_object *object, const HashTable *prope
 	zval *prop, tmp;
 	zend_string *key;
 	zend_long h;
-	const zend_property_info *property_info;
+	zend_property_info *property_info;

 	ZEND_HASH_FOREACH_KEY_VAL(properties, h, key, prop) {
 		if (key) {
@@ -1785,18 +1785,56 @@ ZEND_API void object_properties_load(zend_object *object, const HashTable *prope
 			if (property_info != ZEND_WRONG_PROPERTY_INFO &&
 				property_info &&
 				(property_info->flags & ZEND_ACC_STATIC) == 0) {
+				bool is_typed = ZEND_TYPE_IS_SET(property_info->type);
+
+				/* Mimick unserialize behaviour for virtual properties. */
+				if (UNEXPECTED(property_info->flags & ZEND_ACC_VIRTUAL)) {
+					zend_throw_error(NULL, "Cannot unserialize value for virtual property %s::$%s", ZSTR_VAL(object->ce->name), zend_get_unmangled_property_name(property_info->name));
+					return;
+				}
+
 				zval *slot = OBJ_PROP(object, property_info->offset);
-				if (UNEXPECTED((property_info->flags & ZEND_ACC_READONLY) && !Z_ISUNDEF_P(slot))) {
-					if (Z_PROP_FLAG_P(slot) & IS_PROP_REINITABLE) {
-						Z_PROP_FLAG_P(slot) &= ~IS_PROP_REINITABLE;
+				zval val;
+
+				if (is_typed) {
+					if (UNEXPECTED(Z_ISREF_P(prop))) {
+						/* Block taking a reference to a readonly property. */
+						if (UNEXPECTED(property_info->flags & ZEND_ACC_READONLY)) {
+							zend_readonly_property_indirect_modification_error(property_info);
+							return;
+						}
+						if (UNEXPECTED(!zend_verify_prop_assignable_by_ref(property_info, prop, /* strict */ true))) {
+							ZEND_ASSERT(EG(exception));
+							return;
+						}
+						ZVAL_COPY(&val, prop);
+						ZEND_REF_ADD_TYPE_SOURCE(Z_REF_P(&val), property_info);
 					} else {
-						zend_readonly_property_modification_error(property_info);
-						return;
+						/* Mimick zend_assign_to_typed_prop() by reporting the error before doing work. */
+						if (UNEXPECTED((property_info->flags & ZEND_ACC_READONLY)
+						 && !Z_ISUNDEF_P(slot)
+						 && !(Z_PROP_FLAG_P(slot) & IS_PROP_REINITABLE))) {
+							zend_readonly_property_modification_error(property_info);
+							return;
+						}
+
+						ZVAL_COPY(&val, prop);
+						if (UNEXPECTED(!zend_verify_property_type(property_info, &val, /* strict */ true))) {
+							zval_ptr_dtor(&val);
+							return;
+						}
 					}
+					if (UNEXPECTED(Z_ISREF_P(slot))
+					 && (ZEND_DEBUG || ZEND_REF_HAS_TYPE_SOURCES(Z_REF_P(slot)))) {
+						ZEND_REF_DEL_TYPE_SOURCE(Z_REF_P(slot), property_info);
+					}
+				} else {
+					ZVAL_COPY(&val, prop);
 				}
+
+				Z_PROP_FLAG_P(slot) &= ~IS_PROP_REINITABLE;
 				zval_ptr_dtor(slot);
-				ZVAL_COPY_VALUE(slot, prop);
-				zval_add_ref(slot);
+				ZVAL_COPY_VALUE(slot, &val);
 				if (object->properties) {
 					ZVAL_INDIRECT(&tmp, slot);
 					zend_hash_update(object->properties, key, &tmp);
diff --git a/ext/date/tests/time/duration/gh23639.phpt b/ext/date/tests/time/duration/gh23639.phpt
new file mode 100644
index 00000000000..adb9d4fc71f
--- /dev/null
+++ b/ext/date/tests/time/duration/gh23639.phpt
@@ -0,0 +1,34 @@
+--TEST--
+GH-23639 (object_properties_load allows creating readonly reference properties)
+--CREDITS--
+arnaud-lb
+ndossche
+--XFAIL--
+Test can only succeed when GH-23629 is also merged
+--FILE--
+<?php
+
+class Time_Duration {
+    public int $seconds;
+    public int $nanoseconds;
+    public bool $negative;
+}
+
+$d = new Time_Duration();
+$d->seconds = 1;
+$a = [$d, &$d->seconds];
+
+$payload = serialize($a);
+
+try {
+    unserialize(str_replace('Time_Duration', 'Time\\Duration', $payload));
+} catch (Throwable $e) {
+    do {
+        echo $e::class, ": ", $e->getMessage(), "\n";
+    } while ($e = $e->getPrevious());
+}
+
+?>
+--EXPECT--
+Exception: Invalid serialization data for Time\Duration object
+Error: Cannot indirectly modify readonly property Time\Duration::$seconds
diff --git a/ext/random/randomizer.c b/ext/random/randomizer.c
index 0738380ca25..4c5ff34c9fe 100644
--- a/ext/random/randomizer.c
+++ b/ext/random/randomizer.c
@@ -508,7 +508,6 @@ PHP_METHOD(Random_Randomizer, __unserialize)
 	php_random_randomizer *randomizer = Z_RANDOM_RANDOMIZER_P(ZEND_THIS);
 	HashTable *d;
 	zval *members_zv;
-	zval *zengine;

 	ZEND_PARSE_PARAMETERS_START(1, 1)
 		Z_PARAM_ARRAY_HT(d);
@@ -531,12 +530,7 @@ PHP_METHOD(Random_Randomizer, __unserialize)
 		RETURN_THROWS();
 	}

-	zengine = zend_read_property(randomizer->std.ce, &randomizer->std, "engine", strlen("engine"), 1, NULL);
-	if (Z_TYPE_P(zengine) != IS_OBJECT || !instanceof_function(Z_OBJCE_P(zengine), random_ce_Random_Engine)) {
-		zend_throw_exception(NULL, "Invalid serialization data for Random\\Randomizer object", 0);
-		RETURN_THROWS();
-	}
-
+	zval *zengine = zend_read_property(randomizer->std.ce, &randomizer->std, "engine", strlen("engine"), /* silent */ true, NULL);
 	randomizer_common_init(randomizer, Z_OBJ_P(zengine));
 }
 /* }}} */
diff --git a/ext/random/tests/03_randomizer/gh_9708_unserialize.phpt b/ext/random/tests/03_randomizer/gh_9708_unserialize.phpt
new file mode 100644
index 00000000000..c5689d7b6b9
--- /dev/null
+++ b/ext/random/tests/03_randomizer/gh_9708_unserialize.phpt
@@ -0,0 +1,14 @@
+--TEST--
+GH-9708: object_properties_load() bypasses typed property checks
+--FILE--
+<?php
+
+try {
+    unserialize('O:17:"Random\Randomizer":1:{i:0;a:1:{s:6:"engine";N;}}');
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Exception: Invalid serialization data for Random\Randomizer object
diff --git a/ext/spl/tests/ArrayObject/gh_9707_unserialize.phpt b/ext/spl/tests/ArrayObject/gh_9707_unserialize.phpt
new file mode 100644
index 00000000000..ee11c5c28e5
--- /dev/null
+++ b/ext/spl/tests/ArrayObject/gh_9707_unserialize.phpt
@@ -0,0 +1,41 @@
+--TEST--
+GH-9707: object_properties_load crashes in debug mode when unserializing references to typed properties in php 8.1+
+--FILE--
+<?php
+
+class Foo extends ArrayObject {
+    public int $a = 0;
+    public int $b = 0;
+}
+
+$f = new Foo();
+$r = &$f->a;
+$f->b = &$r;
+
+$f->b = 1;
+var_dump($unserialized = unserialize(serialize($f)));
+
+$unserialized->b = 2;
+
+var_dump($unserialized);
+
+?>
+--EXPECTF--
+object(Foo)#%d (3) {
+  ["a"]=>
+  &int(1)
+  ["b"]=>
+  &int(1)
+  ["storage":"ArrayObject":private]=>
+  array(0) {
+  }
+}
+object(Foo)#%d (3) {
+  ["a"]=>
+  &int(2)
+  ["b"]=>
+  &int(2)
+  ["storage":"ArrayObject":private]=>
+  array(0) {
+  }
+}
diff --git a/ext/spl/tests/ArrayObject/gh_9708_unserialize.phpt b/ext/spl/tests/ArrayObject/gh_9708_unserialize.phpt
new file mode 100644
index 00000000000..fec7ef60346
--- /dev/null
+++ b/ext/spl/tests/ArrayObject/gh_9708_unserialize.phpt
@@ -0,0 +1,21 @@
+--TEST--
+GH-9708: object_properties_load() bypasses typed property checks
+--FILE--
+<?php
+
+class Foo extends ArrayObject {
+    public int $a = 5;
+    public string $b = "10";
+}
+
+try {
+    // a = "10", b = 5
+    unserialize('O:3:"Foo":4:{i:0;i:0;i:1;a:0:{}i:2;a:2:{s:1:"a";s:2:"10";s:1:"b";i:5;}i:3;N;}');
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+
+?>
+--EXPECT--
+TypeError: Cannot assign string to property Foo::$a of type int