Commit c74b2587fef for php.net

commit c74b2587fefa5e088e8dc152d7fe2caeb010e767
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Mon Jun 29 10:20:44 2026 -0400

    Keep the object alive across jsonSerialize() in json_encode() (#22469)

    php_json_encode_serializable_object() holds a raw pointer to the object
    across the jsonSerialize() call, then reads its recursion guard and
    compares the returned value's identity against it. A user error handler
    triggered from jsonSerialize() can drop the last reference to the object,
    for example by nulling a reference that aliases the encoded array slot,
    freeing it before those reads and causing a use-after-free.

    Hold a reference on the object across the call. The array path already
    guards against this with a ZVAL_COPY; the JsonSerializable object path
    did not. Same use-after-free class as GH-21024 in var_dump().

diff --git a/ext/json/json_encoder.c b/ext/json/json_encoder.c
index 424315eca7e..22af1c15a83 100644
--- a/ext/json/json_encoder.c
+++ b/ext/json/json_encoder.c
@@ -577,6 +577,11 @@ static zend_result php_json_encode_serializable_object(smart_str *buf, zend_obje

 	ZEND_GUARD_PROTECT_RECURSION(guard, JSON);

+	/* jsonSerialize() may drop the last reference to the object, e.g. by
+	 * nulling a reference that aliases the encoded array slot; keep it alive
+	 * so the recursion guard and the identity check below stay valid. */
+	GC_ADDREF(obj);
+
 	zend_function *json_serialize_method = zend_hash_str_find_ptr(&ce->function_table, ZEND_STRL("jsonserialize"));
 	ZEND_ASSERT(json_serialize_method != NULL && "This should be guaranteed prior to calling this function");
 	zend_call_known_function(json_serialize_method, obj, ce, &retval, 0, NULL, NULL);
@@ -586,6 +591,7 @@ static zend_result php_json_encode_serializable_object(smart_str *buf, zend_obje
 			smart_str_appendl(buf, "null", 4);
 		}
 		ZEND_GUARD_UNPROTECT_RECURSION(guard, JSON);
+		OBJ_RELEASE(obj);
 		return FAILURE;
 	}

@@ -600,6 +606,7 @@ static zend_result php_json_encode_serializable_object(smart_str *buf, zend_obje
 	}

 	zval_ptr_dtor(&retval);
+	OBJ_RELEASE(obj);

 	return return_code;
 }
diff --git a/ext/json/tests/gh21024.phpt b/ext/json/tests/gh21024.phpt
new file mode 100644
index 00000000000..612c577b2d8
--- /dev/null
+++ b/ext/json/tests/gh21024.phpt
@@ -0,0 +1,21 @@
+--TEST--
+GH-21024 (UAF in json_encode() when jsonSerialize() frees the object)
+--EXTENSIONS--
+json
+--FILE--
+<?php
+class Bar implements JsonSerializable {
+    public function jsonSerialize(): mixed {
+        global $ref;
+        $ref = null;
+        return ['k' => 1];
+    }
+}
+$arr = [new Bar];
+$ref = &$arr[0];
+var_dump(json_encode($arr));
+echo "survived\n";
+?>
+--EXPECT--
+string(9) "[{"k":1}]"
+survived