From e8915391e333998cc0df1bfe57b40a693e89e3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Heath=20Dutton=F0=9F=95=B4=EF=B8=8F?= Date: Tue, 16 Dec 2025 18:37:39 -0500 Subject: [PATCH] Fix GH-20503: Assertion failure with ext/date DateInterval property hash When a DateInterval object has a circular reference (e.g., $obj->prop = $obj), calling json_encode() triggered an assertion failure because the get_properties handler modified a HashTable with refcount > 1. Fixed by duplicating the properties HashTable when its refcount is greater than 1 before modifying it. --- ext/date/php_date.c | 69 +++++++++++++++++++++++++++++++++ ext/date/php_date.h | 1 + ext/date/tests/bug-gh20503.phpt | 24 ++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 ext/date/tests/bug-gh20503.phpt diff --git a/ext/date/php_date.c b/ext/date/php_date.c index 84fad72948fd4..06337944c4389 100644 --- a/ext/date/php_date.c +++ b/ext/date/php_date.c @@ -352,6 +352,7 @@ static HashTable *date_object_get_gc(zend_object *object, zval **table, int *n); static HashTable *date_object_get_properties_for(zend_object *object, zend_prop_purpose purpose); static HashTable *date_object_get_gc_interval(zend_object *object, zval **table, int *n); static HashTable *date_object_get_properties_interval(zend_object *object); +static HashTable *date_object_get_properties_for_interval(zend_object *object, zend_prop_purpose purpose); static HashTable *date_object_get_gc_period(zend_object *object, zval **table, int *n); static HashTable *date_object_get_properties_for_timezone(zend_object *object, zend_prop_purpose purpose); static HashTable *date_object_get_gc_timezone(zend_object *object, zval **table, int *n); @@ -396,6 +397,7 @@ static PHP_GINIT_FUNCTION(date) date_globals->default_timezone = NULL; date_globals->timezone = NULL; date_globals->tzcache = NULL; + date_globals->interval_props_cache = NULL; } /* }}} */ @@ -407,6 +409,13 @@ static void _php_date_tzinfo_dtor(zval *zv) /* {{{ */ timelib_tzinfo_dtor(tzi); } /* }}} */ +static void _php_date_interval_props_dtor(zval *zv) /* {{{ */ +{ + HashTable *props = (HashTable*)Z_PTR_P(zv); + + zend_hash_release(props); +} /* }}} */ + /* {{{ PHP_RINIT_FUNCTION */ PHP_RINIT_FUNCTION(date) { @@ -416,6 +425,7 @@ PHP_RINIT_FUNCTION(date) DATEG(timezone) = NULL; DATEG(tzcache) = NULL; DATEG(last_errors) = NULL; + DATEG(interval_props_cache) = NULL; return SUCCESS; } @@ -441,6 +451,12 @@ ZEND_MODULE_POST_ZEND_DEACTIVATE_D(date) DATEG(tzcache) = NULL; } + if (DATEG(interval_props_cache)) { + zend_hash_destroy(DATEG(interval_props_cache)); + FREE_HASHTABLE(DATEG(interval_props_cache)); + DATEG(interval_props_cache) = NULL; + } + if (DATEG(last_errors)) { timelib_error_container_dtor(DATEG(last_errors)); DATEG(last_errors) = NULL; @@ -1816,6 +1832,7 @@ static void date_register_classes(void) /* {{{ */ date_object_handlers_interval.read_property = date_interval_read_property; date_object_handlers_interval.write_property = date_interval_write_property; date_object_handlers_interval.get_properties = date_object_get_properties_interval; + date_object_handlers_interval.get_properties_for = date_object_get_properties_for_interval; date_object_handlers_interval.get_property_ptr_ptr = date_interval_get_property_ptr_ptr; date_object_handlers_interval.get_gc = date_object_get_gc_interval; date_object_handlers_interval.compare = date_interval_compare_objects; @@ -2240,6 +2257,54 @@ static HashTable *date_object_get_properties_interval(zend_object *object) /* {{ return props; } /* }}} */ +static HashTable *date_object_get_properties_for_interval(zend_object *object, zend_prop_purpose purpose) /* {{{ */ +{ + HashTable *props; + HashTable *cached; + php_interval_obj *intervalobj; + zend_ulong handle = object->handle; + + switch (purpose) { + case ZEND_PROP_PURPOSE_DEBUG: + case ZEND_PROP_PURPOSE_SERIALIZE: + case ZEND_PROP_PURPOSE_VAR_EXPORT: + case ZEND_PROP_PURPOSE_JSON: + case ZEND_PROP_PURPOSE_ARRAY_CAST: + break; + default: + return zend_std_get_properties_for(object, purpose); + } + + intervalobj = php_interval_obj_from_obj(object); + + if (!intervalobj->initialized) { + return zend_array_dup(zend_std_get_properties(object)); + } + + /* Lazily allocate the cache HashTable */ + if (!DATEG(interval_props_cache)) { + ALLOC_HASHTABLE(DATEG(interval_props_cache)); + zend_hash_init(DATEG(interval_props_cache), 8, NULL, _php_date_interval_props_dtor, 0); + } + + /* If cache exists and is actively in use (refcount > 1), we're in a recursive + * call (e.g., circular reference during json_encode). Return the same cache + * so that circular reference detection works correctly. */ + cached = zend_hash_index_find_ptr(DATEG(interval_props_cache), handle); + if (cached && GC_REFCOUNT(cached) > 1) { + GC_ADDREF(cached); + return cached; + } + + /* Create new cache or replace stale one */ + props = zend_array_dup(zend_std_get_properties(object)); + date_interval_object_to_hash(intervalobj, props); + zend_hash_index_update_ptr(DATEG(interval_props_cache), handle, props); + + GC_ADDREF(props); + return props; +} /* }}} */ + static zend_object *date_object_new_period(zend_class_entry *class_type) /* {{{ */ { php_period_obj *intern = zend_object_alloc(sizeof(php_period_obj), class_type); @@ -2306,6 +2371,10 @@ static void date_object_free_storage_interval(zend_object *object) /* {{{ */ zend_string_release(intern->date_string); intern->date_string = NULL; } + /* Clean up cached properties for this object */ + if (DATEG(interval_props_cache)) { + zend_hash_index_del(DATEG(interval_props_cache), object->handle); + } timelib_rel_time_dtor(intern->diff); zend_object_std_dtor(&intern->std); } /* }}} */ diff --git a/ext/date/php_date.h b/ext/date/php_date.h index a4729ff58ffeb..94d5cbaa15c7c 100644 --- a/ext/date/php_date.h +++ b/ext/date/php_date.h @@ -108,6 +108,7 @@ ZEND_BEGIN_MODULE_GLOBALS(date) char *timezone; HashTable *tzcache; timelib_error_container *last_errors; + HashTable *interval_props_cache; ZEND_END_MODULE_GLOBALS(date) #define DATEG(v) ZEND_MODULE_GLOBALS_ACCESSOR(date, v) diff --git a/ext/date/tests/bug-gh20503.phpt b/ext/date/tests/bug-gh20503.phpt new file mode 100644 index 0000000000000..d9361497190ec --- /dev/null +++ b/ext/date/tests/bug-gh20503.phpt @@ -0,0 +1,24 @@ +--TEST-- +GH-20503 (Assertion failure with DateInterval and json_encode on circular reference) +--FILE-- +circular = $obj; + +// json_encode with circular reference previously caused an assertion failure +// in debug builds when modifying a HashTable with refcount > 1 +$result = json_encode($obj); +var_dump($result === false); +var_dump(json_last_error() === JSON_ERROR_RECURSION); + +// Also verify array cast works +$props = (array) $obj; +var_dump(count($props) > 0); +var_dump(isset($props['circular'])); +?> +--EXPECTF-- +Deprecated: Creation of dynamic property DateInterval::$circular is deprecated in %s on line %d +bool(true) +bool(true) +bool(true) +bool(true)