From 473a7e04515f19612ade59e489671e5d49a0fab4 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Sat, 29 Aug 2026 20:57:55 +0100 Subject: [PATCH 01/11] ext/zip: ZipArchive::getNameIndex() index truncated to int. The index was cast to int before being handed to zip_get_name(), whose parameter is a zip_uint64_t, so any value with a non-zero upper half wrapped and selected the wrong entry: getNameIndex(1 << 32) returned the name of entry 0 instead of false. Cast to zip_uint64_t instead, letting libzip reject out of range indices. --- NEWS | 2 + ext/zip/php_zip.c | 2 +- .../tests/oo_getnameindex_large_index.phpt | 44 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 ext/zip/tests/oo_getnameindex_large_index.phpt diff --git a/NEWS b/NEWS index 04adab59625e..aa7862f63bb4 100644 --- a/NEWS +++ b/NEWS @@ -80,6 +80,8 @@ PHP NEWS garbage collected). (Weilin Du, ndossche) . Fixed ZipArchive::extractTo() and ZipArchive::getFrom*() reporting success on corrupted entries. (David Carlier) + . Fixed ZipArchive::getNameIndex() truncating the entry index to int. + (David Carlier) - SAPI: . Fixed fuzzer targets failing to build in isolation. (Mrmaxmeier) diff --git a/ext/zip/php_zip.c b/ext/zip/php_zip.c index 5e640df9a102..69b81b88753d 100644 --- a/ext/zip/php_zip.c +++ b/ext/zip/php_zip.c @@ -2181,7 +2181,7 @@ PHP_METHOD(ZipArchive, getNameIndex) ZIP_FROM_OBJECT(intern, self); - name = zip_get_name(intern, (int) index, flags); + name = zip_get_name(intern, (zip_uint64_t) index, flags); if (name) { RETVAL_STRING((char *)name); diff --git a/ext/zip/tests/oo_getnameindex_large_index.phpt b/ext/zip/tests/oo_getnameindex_large_index.phpt new file mode 100644 index 000000000000..471dffc38d91 --- /dev/null +++ b/ext/zip/tests/oo_getnameindex_large_index.phpt @@ -0,0 +1,44 @@ +--TEST-- +ZipArchive::getNameIndex() with an index that does not fit in an int +--EXTENSIONS-- +zip +--SKIPIF-- + +--FILE-- +open($file, ZipArchive::CREATE)) { + exit('failed'); +} + +$zip->addFromString('entry1.txt', 'entry #1'); +$zip->close(); + +if (!$zip->open($file)) { + exit('failed'); +} + +var_dump($zip->getNameIndex(0)); +var_dump($zip->getNameIndex(1 << 32)); +var_dump($zip->getNameIndex((1 << 32) + 1)); +var_dump($zip->getNameIndex(PHP_INT_MAX)); +var_dump($zip->getNameIndex(-1)); + +$zip->close(); +?> +--EXPECT-- +string(10) "entry1.txt" +bool(false) +bool(false) +bool(false) +bool(false) +--CLEAN-- + From 614afe7b09fd37c57f71a02fecfac33191ea141a Mon Sep 17 00:00:00 2001 From: David Carlier Date: Sat, 29 Aug 2026 20:58:17 +0100 Subject: [PATCH 02/11] ext/zip: php_zip_ops_stat() succeeds when the archive cannot be opened. When zip_open() failed the whole stat block was skipped, yet the function still returned 0. fstat() on a zip:// stream therefore succeeded with the zeroed statbuf it was given, reporting a zero size and no file type bits, instead of failing. Return -1 on that path. Close GH-23511 --- NEWS | 2 + .../stream_fstat_unreadable_archive.phpt | 38 +++++++++++++++++++ ext/zip/zip_stream.c | 3 ++ 3 files changed, 43 insertions(+) create mode 100644 ext/zip/tests/stream_fstat_unreadable_archive.phpt diff --git a/NEWS b/NEWS index aa7862f63bb4..db1aba15a8e6 100644 --- a/NEWS +++ b/NEWS @@ -82,6 +82,8 @@ PHP NEWS on corrupted entries. (David Carlier) . Fixed ZipArchive::getNameIndex() truncating the entry index to int. (David Carlier) + . Fixed fstat() on a zip:// stream reporting success when the archive cannot + be opened. (David Carlier) - SAPI: . Fixed fuzzer targets failing to build in isolation. (Mrmaxmeier) diff --git a/ext/zip/tests/stream_fstat_unreadable_archive.phpt b/ext/zip/tests/stream_fstat_unreadable_archive.phpt new file mode 100644 index 000000000000..a81fc8fc3cfc --- /dev/null +++ b/ext/zip/tests/stream_fstat_unreadable_archive.phpt @@ -0,0 +1,38 @@ +--TEST-- +fstat() on a zip:// stream whose archive can no longer be opened +--EXTENSIONS-- +zip +--SKIPIF-- + +--FILE-- +open($file, ZipArchive::CREATE)) { + exit('failed'); +} + +$zip->addFromString('entry.txt', 'entry'); +$zip->close(); + +$fp = fopen('zip://' . $file . '#entry.txt', 'rb'); +var_dump($fp !== false); + +file_put_contents($file, 'this is not a zip archive'); + +var_dump(fstat($fp)); + +fclose($fp); +?> +--EXPECT-- +bool(true) +bool(false) +--CLEAN-- + diff --git a/ext/zip/zip_stream.c b/ext/zip/zip_stream.c index 0356863ef7ce..b70b82a415ec 100644 --- a/ext/zip/zip_stream.c +++ b/ext/zip/zip_stream.c @@ -195,6 +195,9 @@ static int php_zip_ops_stat(php_stream *stream, php_stream_statbuf *ssb) /* {{{ ssb->sb.st_blocks = -1; #endif ssb->sb.st_ino = -1; + } else { + zend_string_release_ex(file_basename, 0); + return -1; } zend_string_release_ex(file_basename, 0); return 0; From 9e5cf96095ca54543c68787092cc28fcc4bc534d Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 29 Aug 2026 08:00:20 -0400 Subject: [PATCH 03/11] [mysqlnd] Fix OK packet message length buffer over-read The OK packet message-length varint is read after the last bounds check, so a length-encoded integer at the end of a packet can advance p past header.size and even past the end of the 4096-byte command buffer. The old MIN(net_len, buf_len - (p - begin)) clamp then underflows and passes an unclamped attacker-controlled length to mnd_pestrndup(), reading heap memory beyond both the packet and its allocation. Reject a message length that extends past the payload, matching php_mysqlnd_auth_response_read() from GHSA-h35g-vwh6-m678; an audit found no further readers using the vulnerable buf_len clamp. Closes GH-23497 --- ext/mysqli/tests/fake_server.inc | 13 ++++++ .../mysqlnd_ok_packet_message_over_read.phpt | 40 +++++++++++++++++++ ext/mysqlnd/mysqlnd_wireprotocol.c | 7 +++- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 ext/mysqli/tests/mysqlnd_ok_packet_message_over_read.phpt diff --git a/ext/mysqli/tests/fake_server.inc b/ext/mysqli/tests/fake_server.inc index dad8bc52ddd1..4056b9fb78cf 100644 --- a/ext/mysqli/tests/fake_server.inc +++ b/ext/mysqli/tests/fake_server.inc @@ -721,6 +721,19 @@ function my_mysqli_test_auth_response_message_over_read(my_mysqli_fake_server_co $conn->read(); } +function my_mysqli_test_ok_packet_message_over_read(my_mysqli_fake_server_conn $conn): void +{ + $p = new my_mysqli_fake_packet(); + $p->full = "08000001" . "00" . "00" . "00" . "0200" . "0000" . "fa"; + + $conn->send_server_greetings(); + $conn->read_packets(1); + $conn->send_server_ok(); + $conn->read_packets(1); + $conn->send($p->to_bytes(), "Malicious OK Packet [message length past the packet size]"); + $conn->read(); +} + function my_mysqli_test_stmt_response_row_over_read_string(my_mysqli_fake_server_conn $conn): void { $rh = $conn->packet_generator->server_stmt_execute_items_response(); diff --git a/ext/mysqli/tests/mysqlnd_ok_packet_message_over_read.phpt b/ext/mysqli/tests/mysqlnd_ok_packet_message_over_read.phpt new file mode 100644 index 000000000000..8364251e8df4 --- /dev/null +++ b/ext/mysqli/tests/mysqlnd_ok_packet_message_over_read.phpt @@ -0,0 +1,40 @@ +--TEST-- +mysqlnd OK packet message length buffer over-read +--EXTENSIONS-- +mysqli +--FILE-- +wait(); + +try { + $conn = new mysqli( $servername, $username, $password, "", $process->getPort()); + var_dump($conn->select_db("test")); +} catch (Exception $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +$process->terminate(); + +print "done!"; +?> +--EXPECTF-- +[*] Server started on 127.0.0.1:%d +[*] Connection established +[*] Sending - Server Greeting: %s +[*] Received: %s +[*] Sending - Server OK: %s +[*] Received: %s +[*] Sending - Malicious OK Packet [message length past the packet size]: %s + +Warning: mysqli::select_db(): OK packet message length is past the packet size in %s on line %d + +Warning: mysqli::select_db(): Error while reading INIT_DB's response packet. PID=%d in %s on line %d +mysqli_sql_exception: Malformed packet +done! diff --git a/ext/mysqlnd/mysqlnd_wireprotocol.c b/ext/mysqlnd/mysqlnd_wireprotocol.c index 64c2c7969619..80b4b37591ab 100644 --- a/ext/mysqlnd/mysqlnd_wireprotocol.c +++ b/ext/mysqlnd/mysqlnd_wireprotocol.c @@ -878,7 +878,12 @@ php_mysqlnd_ok_read(MYSQLND_CONN_DATA * conn, void * _packet) /* There is a message */ if (packet->header.size > (size_t) (p - buf) && (net_len = php_mysqlnd_net_field_length(&p))) { - packet->message_len = MIN(net_len, buf_len - (p - begin)); + if ((p - buf) > packet->header.size || packet->header.size - (p - buf) < net_len) { + DBG_ERR_FMT("OK packet message length is past the packet size"); + php_error_docref(NULL, E_WARNING, "OK packet message length is past the packet size"); + DBG_RETURN(FAIL); + } + packet->message_len = net_len; packet->message = mnd_pestrndup((char *)p, packet->message_len, FALSE); } else { packet->message = NULL; From fc7a6b900704fd7710cc1027f82a3670123430e5 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 29 Aug 2026 08:00:19 -0400 Subject: [PATCH 04/11] [SimpleXML] Fix creating new attributes via attributes() dimension write sxe_prop_dim_write() overwrote the element node with the first attribute node when resolving an SXE_ITER_ATTRLIST iterator, so xmlNewProp() targeted a non-element node and was skipped entirely when no attribute existed yet. Keep the element node in place and resolve only the attribute list start, so $x->attributes()["new"] = "v" creates the attribute like the symmetric $x["new"] path; property writes on the attributes() object share the fixed path while read/exists/unset handlers are unaffected by this defect. Closes GH-23500 --- NEWS | 4 +++ ext/simplexml/simplexml.c | 3 +- .../tests/attributes_dimension_write.phpt | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 ext/simplexml/tests/attributes_dimension_write.phpt diff --git a/NEWS b/NEWS index db1aba15a8e6..546364fbc2ca 100644 --- a/NEWS +++ b/NEWS @@ -75,6 +75,10 @@ PHP NEWS an object converted to an array fails. (David Carlier) . Fixed read buffer compaction in php_stream_filter_flush(). (crystarm) +- SimpleXML: + . Fixed writing to a dimension of the object returned by attributes() not + creating the attribute. (Ilia Alshanetsky) + - Zip: . Fixed bug GH-23276 (ZipArchive subclass storing its own stream cannot be garbage collected). (Weilin Du, ndossche) diff --git a/ext/simplexml/simplexml.c b/ext/simplexml/simplexml.c index 1a346200199b..44fdef5e12d7 100644 --- a/ext/simplexml/simplexml.c +++ b/ext/simplexml/simplexml.c @@ -443,8 +443,7 @@ static zval *sxe_prop_dim_write(zend_object *object, zval *member, zval *value, if (sxe->iter.type == SXE_ITER_ATTRLIST) { attribs = 1; elements = 0; - node = php_sxe_get_first_node_non_destructive(sxe, node); - attr = (xmlAttrPtr)node; + attr = (xmlAttrPtr)php_sxe_get_first_node_non_destructive(sxe, node); test = sxe->iter.name != NULL; } else if (sxe->iter.type != SXE_ITER_CHILD) { mynode = node; diff --git a/ext/simplexml/tests/attributes_dimension_write.phpt b/ext/simplexml/tests/attributes_dimension_write.phpt new file mode 100644 index 000000000000..8721dc7dc7c2 --- /dev/null +++ b/ext/simplexml/tests/attributes_dimension_write.phpt @@ -0,0 +1,30 @@ +--TEST-- +Creating new attributes via dimension and property writes on attributes() +--FILE-- +'); +$x->attributes()['new'] = 'v'; +echo $x->asXML(); + +$a = simplexml_load_string(''); +$a->attributes()['created'] = 'yes'; +echo $a->asXML(); + +$b = simplexml_load_string(''); +$attrs = $b->attributes(); +$attrs->other = 2; +echo $b->asXML(); + +$c = simplexml_load_string(''); +$c->attributes()['a'] = '2'; +echo $c->asXML(); +?> +--EXPECT-- + + + + + + + + From 091cb333619644379ac606c38d373c45e7c2c0de Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sun, 30 Aug 2026 11:14:46 -0400 Subject: [PATCH 05/11] [intl] Size sortWithSortKeys buffers based on array size (#23504) collator_sort_with_sort_keys() ecalloc'd sortKeyBuf and sortKeyIndxBuf at DEF_SORT_KEYS_BUF_SIZE (1MiB) each on every call regardless of array size. sortKeyBuf now starts from zend_hash_num_elements() * 32 bytes, clamped to a 4KiB minimum and the previous 1MiB cap, and grows geometrically up to DEF_SORT_KEYS_BUF_INCREMENT. sortKeyIndxBuf is allocated exactly for the element count, dropping the index-buffer growth path. Sibling audit: DEF_SORT_KEYS* constants have no other users and collator_sort()/asort()/get_sort_key() already scale allocations. --- NEWS | 2 + ext/intl/collator/collator_sort.cpp | 44 ++++++++------ ...lator_sort_with_sort_keys_buffer_size.phpt | 57 +++++++++++++++++++ 3 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 ext/intl/tests/collator_sort_with_sort_keys_buffer_size.phpt diff --git a/NEWS b/NEWS index bd17cf9bf8e6..ae8edaf97c9f 100644 --- a/NEWS +++ b/NEWS @@ -7,6 +7,8 @@ PHP NEWS 100-continue flow control). (Sjoerd Langkemper) - Intl: + . Fixed Collator::sortWithSortKeys() allocating fixed 2MiB buffers + regardless of array size. (Ilia Alshanetsky) . Fixed a memory leak when iterating IntlBreakIterator::getPartsIterator() results. (iliaal) . Fixed a leak in Locale::getKeywords() when a keyword value cannot be diff --git a/ext/intl/collator/collator_sort.cpp b/ext/intl/collator/collator_sort.cpp index cb1f2aefc358..f2674b9c8ff8 100644 --- a/ext/intl/collator/collator_sort.cpp +++ b/ext/intl/collator/collator_sort.cpp @@ -44,9 +44,8 @@ ZEND_EXTERN_MODULE_GLOBALS( intl ) static const size_t DEF_SORT_KEYS_BUF_SIZE = 1048576; static const size_t DEF_SORT_KEYS_BUF_INCREMENT = 1048576; - -static const size_t DEF_SORT_KEYS_INDX_BUF_SIZE = 1048576; -static const size_t DEF_SORT_KEYS_INDX_BUF_INCREMENT = 1048576; +static const size_t MIN_SORT_KEYS_BUF_SIZE = 4096; +static const size_t SORT_KEY_LENGTH_ESTIMATE = 32; static const size_t DEF_UTF16_BUF_SIZE = 1024; @@ -427,17 +426,17 @@ U_CFUNC PHP_FUNCTION( collator_sort_with_sort_keys ) zval* hashData = nullptr; /* currently processed item of input hash */ char* sortKeyBuf = nullptr; /* buffer to store sort keys */ - uint32_t sortKeyBufSize = DEF_SORT_KEYS_BUF_SIZE; /* buffer size */ + uint32_t sortKeyBufSize = 0; /* buffer size */ ptrdiff_t sortKeyBufOffset = 0; /* pos in buffer to store sort key */ uint32_t sortKeyLen = 0; /* the length of currently processing key */ uint32_t bufLeft = 0; uint32_t bufIncrement = 0; collator_sort_key_index_t* sortKeyIndxBuf = nullptr; /* buffer to store 'indexes' which will be passed to 'qsort' */ - uint32_t sortKeyIndxBufSize = DEF_SORT_KEYS_INDX_BUF_SIZE; uint32_t sortKeyIndxSize = sizeof( collator_sort_key_index_t ); uint32_t sortKeyCount = 0; + uint32_t numElements = 0; uint32_t j = 0; UChar* utf16_buf = nullptr; /* tmp buffer to hold current processing string in utf-16 */ @@ -472,9 +471,20 @@ U_CFUNC PHP_FUNCTION( collator_sort_with_sort_keys ) if( !hash || zend_hash_num_elements( hash ) == 0 ) RETURN_TRUE; + numElements = zend_hash_num_elements( hash ); + + if( numElements > DEF_SORT_KEYS_BUF_SIZE / SORT_KEY_LENGTH_ESTIMATE ) { + sortKeyBufSize = DEF_SORT_KEYS_BUF_SIZE; + } else { + sortKeyBufSize = numElements * SORT_KEY_LENGTH_ESTIMATE; + } + if( sortKeyBufSize < MIN_SORT_KEYS_BUF_SIZE ) { + sortKeyBufSize = MIN_SORT_KEYS_BUF_SIZE; + } + /* Create buffers */ - sortKeyBuf = reinterpret_cast(ecalloc( sortKeyBufSize, sizeof( char ) )); - sortKeyIndxBuf = reinterpret_cast(ecalloc( sortKeyIndxBufSize, sizeof( uint8_t ) )); + sortKeyBuf = reinterpret_cast(ecalloc( sortKeyBufSize, sizeof( char ) )); + sortKeyIndxBuf = reinterpret_cast(ecalloc( numElements, sortKeyIndxSize )); utf16_buf = eumalloc( utf16_buf_size ); /* Iterate through input hash and create a sort key for each value. */ @@ -524,7 +534,15 @@ U_CFUNC PHP_FUNCTION( collator_sort_with_sort_keys ) /* check for sortKeyBuf overflow, increasing its size of the buffer if needed */ if( sortKeyLen > bufLeft ) { - bufIncrement = ( sortKeyLen > DEF_SORT_KEYS_BUF_INCREMENT ) ? sortKeyLen : DEF_SORT_KEYS_BUF_INCREMENT; + bufIncrement = sortKeyBufSize; + + if( bufIncrement > DEF_SORT_KEYS_BUF_INCREMENT ) { + bufIncrement = DEF_SORT_KEYS_BUF_INCREMENT; + } + + if( bufIncrement < sortKeyLen ) { + bufIncrement = sortKeyLen; + } sortKeyBufSize += bufIncrement; bufLeft += bufIncrement; @@ -534,16 +552,6 @@ U_CFUNC PHP_FUNCTION( collator_sort_with_sort_keys ) sortKeyLen = ucol_getSortKey( co->ucoll, utf16_buf, utf16_len, (uint8_t*)sortKeyBuf + sortKeyBufOffset, bufLeft ); } - /* check sortKeyIndxBuf overflow, increasing its size of the buffer if needed */ - if( ( sortKeyCount + 1 ) * sortKeyIndxSize > sortKeyIndxBufSize ) - { - bufIncrement = ( sortKeyIndxSize > DEF_SORT_KEYS_INDX_BUF_INCREMENT ) ? sortKeyIndxSize : DEF_SORT_KEYS_INDX_BUF_INCREMENT; - - sortKeyIndxBufSize += bufIncrement; - - sortKeyIndxBuf = reinterpret_cast(erealloc( sortKeyIndxBuf, sortKeyIndxBufSize )); - } - sortKeyIndxBuf[sortKeyCount].key = (char*)sortKeyBufOffset; /* remember just offset, cause address */ /* of 'sortKeyBuf' may be changed due to realloc. */ sortKeyIndxBuf[sortKeyCount].zstr = hashData; diff --git a/ext/intl/tests/collator_sort_with_sort_keys_buffer_size.phpt b/ext/intl/tests/collator_sort_with_sort_keys_buffer_size.phpt new file mode 100644 index 000000000000..ef1d68851e37 --- /dev/null +++ b/ext/intl/tests/collator_sort_with_sort_keys_buffer_size.phpt @@ -0,0 +1,57 @@ +--TEST-- +Collator::sortWithSortKeys() buffer allocation scales with array size +--EXTENSIONS-- +intl +--FILE-- +sort($a); + +$before = memory_get_peak_usage(); +$b = ['bb', 'aa', 'cc', 'ab', 'ca', 'bc', 'ac', 'ba']; +$c->sortWithSortKeys($b); +$peakDelta = memory_get_peak_usage() - $before; + +var_dump($a); +var_dump($b); +var_dump($peakDelta < 100000); + +$long = str_repeat('a', 10000); +$d = [$long . 'b', $long . 'a']; +$c->sortWithSortKeys($d); +echo $d[0] === $long . 'a' ? "long-a\n" : "fail-a\n"; +echo $d[1] === $long . 'b' ? "long-b\n" : "fail-b\n"; +?> +--EXPECT-- +array(4) { + [0]=> + string(2) "aa" + [1]=> + string(2) "bb" + [2]=> + string(2) "cc" + [3]=> + string(2) "dd" +} +array(8) { + [0]=> + string(2) "aa" + [1]=> + string(2) "ab" + [2]=> + string(2) "ac" + [3]=> + string(2) "ba" + [4]=> + string(2) "bb" + [5]=> + string(2) "bc" + [6]=> + string(2) "ca" + [7]=> + string(2) "cc" +} +bool(true) +long-a +long-b From 9c74d7d1bcd254b4ad64a9b21fe41f546e764cd4 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 29 Aug 2026 08:00:20 -0400 Subject: [PATCH 06/11] [mysqlnd] Fix result set field metadata length buffer over-read The rset_field metadata reader trusted each length-encoded string size: a hostile server could send a length marker at the end of a field packet whose value exceeds the remaining payload, advancing p past header.size and past the command buffer before the next dereference, and recording attacker-controlled lengths on pointers outside the packet that later feed memcpy() into the field memory pool. Bound each metadata string by bailing once p leaves the payload and rejecting lengths larger than the remaining bytes, matching php_mysqlnd_auth_response_read() from GHSA-h35g-vwh6-m678; an audit found no other users of the READ_RSET_FIELD macro and the trailing default-value check never dereferences its length. Closes GH-23496 --- ext/mysqli/tests/fake_server.inc | 44 +++++++++++++++++++ .../mysqlnd_rset_field_len_over_read.phpt | 42 ++++++++++++++++++ .../mysqlnd_rset_field_len_past_packet.phpt | 40 +++++++++++++++++ ext/mysqlnd/mysqlnd_wireprotocol.c | 9 +++- 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 ext/mysqli/tests/mysqlnd_rset_field_len_over_read.phpt create mode 100644 ext/mysqli/tests/mysqlnd_rset_field_len_past_packet.phpt diff --git a/ext/mysqli/tests/fake_server.inc b/ext/mysqli/tests/fake_server.inc index 4056b9fb78cf..3af5c7459159 100644 --- a/ext/mysqli/tests/fake_server.inc +++ b/ext/mysqli/tests/fake_server.inc @@ -829,6 +829,50 @@ function my_mysqli_test_stmt_response_row_read_two_fields(my_mysqli_fake_server_ } } +function my_mysqli_test_rset_field_metadata_len_over_read(my_mysqli_fake_server_conn $conn): void +{ + $rh = $conn->packet_generator->server_tabular_query_response(); + + $qr2 = new my_mysqli_fake_packet(); + $qr2->packet_length = "0c0000"; + $qr2->packet_number = "02"; + $qr2->catalog_length_plus_name = "0161"; + $qr2->db_length_plus_name = "0162"; + $qr2->table_length_plus_name = "0163"; + $qr2->original_t = "0164"; + $qr2->name_length_plus_name = "0165"; + $qr2->original_n = "fcff"; + + $conn->send_server_greetings(); + $conn->read_packets(1); + $conn->send_server_ok(); + $conn->read_packets(1); + $conn->send($conn->packets_to_bytes([$rh[0], $qr2]), "Malicious Tabular Response [metadata string length past the packet size]"); + $conn->read(); +} + +function my_mysqli_test_rset_field_metadata_len_past_packet(my_mysqli_fake_server_conn $conn): void +{ + $rh = $conn->packet_generator->server_tabular_query_response(); + + $qr2 = new my_mysqli_fake_packet(); + $qr2->packet_length = "0c0000"; + $qr2->packet_number = "02"; + $qr2->catalog_length_plus_name = "0161"; + $qr2->db_length_plus_name = "0162"; + $qr2->table_length_plus_name = "0163"; + $qr2->original_t = "0164"; + $qr2->name_length_plus_name = "0165"; + $qr2->original_n = "0561"; + + $conn->send_server_greetings(); + $conn->read_packets(1); + $conn->send_server_ok(); + $conn->read_packets(1); + $conn->send($conn->packets_to_bytes([$rh[0], $qr2]), "Malicious Tabular Response [metadata string length past the packet size]"); + $conn->read(); +} + function my_mysqli_test_query_response_row_length_overflow(my_mysqli_fake_server_conn $conn): void { $rh = $conn->packet_generator->server_query_execute_data_response('strval'); diff --git a/ext/mysqli/tests/mysqlnd_rset_field_len_over_read.phpt b/ext/mysqli/tests/mysqlnd_rset_field_len_over_read.phpt new file mode 100644 index 000000000000..274468ded63d --- /dev/null +++ b/ext/mysqli/tests/mysqlnd_rset_field_len_over_read.phpt @@ -0,0 +1,42 @@ +--TEST-- +mysqlnd result set field metadata string length buffer over-read (len clamped to packet size) +--EXTENSIONS-- +mysqli +--FILE-- +wait(); + +try { + $conn = new mysqli( $servername, $username, $password, "", $process->getPort()); + var_dump($conn->query("SELECT * from users")); +} catch (Exception $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +$conn->close(); + +$process->terminate(); + +print "done!"; +?> +--EXPECTF-- +[*] Server started on 127.0.0.1:%d +[*] Connection established +[*] Sending - Server Greeting: 580000000a352e352e352d31302e352e31382d4d6172696144420003000000473e3f6047257c6700fef7080200ff81150000000000000f0000006c6b55463f49335f686c6431006d7973716c5f6e61746976655f70617373776f7264 +[*] Received: %s +[*] Sending - Server OK: 0700000200000002000000 +[*] Received: %s +[*] Sending - Malicious Tabular Response [metadata string length past the packet size]: 01000001010c00000201610162016301640165fcff + +Warning: mysqli::query(): Premature end of data (mysqlnd_wireprotocol.c:%d) in %s on line %d + +Warning: mysqli::query(): Result set field packet %d bytes shorter than expected in %s on line %d +bool(false) +done! diff --git a/ext/mysqli/tests/mysqlnd_rset_field_len_past_packet.phpt b/ext/mysqli/tests/mysqlnd_rset_field_len_past_packet.phpt new file mode 100644 index 000000000000..020d28d95b25 --- /dev/null +++ b/ext/mysqli/tests/mysqlnd_rset_field_len_past_packet.phpt @@ -0,0 +1,40 @@ +--TEST-- +mysqlnd result set field metadata string length exceeds remaining packet bytes +--EXTENSIONS-- +mysqli +--FILE-- +wait(); + +try { + $conn = new mysqli( $servername, $username, $password, "", $process->getPort()); + var_dump($conn->query("SELECT * from users")); +} catch (Exception $e) { + echo $e::class, ": ", $e->getMessage(), PHP_EOL; +} + +$conn->close(); + +$process->terminate(); + +print "done!"; +?> +--EXPECTF-- +[*] Server started on 127.0.0.1:%d +[*] Connection established +[*] Sending - Server Greeting: %s +[*] Received: %s +[*] Sending - Server OK: %s +[*] Received: %s +[*] Sending - Malicious Tabular Response [metadata string length past the packet size]: %s + +Warning: mysqli::query(): Result set field metadata string length is past the packet size in %s on line %d +bool(false) +done! diff --git a/ext/mysqlnd/mysqlnd_wireprotocol.c b/ext/mysqlnd/mysqlnd_wireprotocol.c index 80b4b37591ab..f0f95a970899 100644 --- a/ext/mysqlnd/mysqlnd_wireprotocol.c +++ b/ext/mysqlnd/mysqlnd_wireprotocol.c @@ -1176,10 +1176,17 @@ void php_mysqlnd_rset_header_free_mem(void * _packet) /* }}} */ #define READ_RSET_FIELD(field_name) do { \ + BAIL_IF_NO_MORE_DATA; \ len = php_mysqlnd_net_field_length(&p); \ if (UNEXPECTED(len == MYSQLND_NULL_LENGTH)) { \ goto faulty_or_fake; \ } else if (len != 0) { \ + BAIL_IF_NO_MORE_DATA; \ + if (UNEXPECTED((p - begin) > packet->header.size || packet->header.size - (p - begin) < len)) { \ + DBG_ERR_FMT("Result set field metadata string length is past the packet size"); \ + php_error_docref(NULL, E_WARNING, "Result set field metadata string length is past the packet size"); \ + DBG_RETURN(FAIL); \ + } \ meta->field_name = (const char *)p; \ meta->field_name ## _length = len; \ p += len; \ @@ -1248,7 +1255,7 @@ php_mysqlnd_rset_field_read(MYSQLND_CONN_DATA * conn, void * _packet) READ_RSET_FIELD(name); READ_RSET_FIELD(org_name); - /* 1 byte length */ + BAIL_IF_NO_MORE_DATA; if (UNEXPECTED(12 != *p)) { DBG_ERR_FMT("Protocol error. Server sent false length. Expected 12 got %d", (int) *p); php_error_docref(NULL, E_WARNING, "Protocol error. Server sent false length. Expected 12"); From b7a85ee111eaaeadb7f7c52b05cd3d0625b843be Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 29 Aug 2026 08:15:41 -0400 Subject: [PATCH 07/11] [intl] Fix leak of time zone wrapper in Calendar debug info Calendar_get_debug_info() built a temporary IntlTimeZone wrapper zval via timezone_object_construct() and never released it, leaking one wrapper object per var_dump()/debug dump of an IntlCalendar. Release the wrapper with zval_ptr_dtor() after its debug info has been copied. Sibling audit: all other timezone_object_construct() call sites write into return_value and are refcount-managed; no other intl get_debug_info handler constructs temporary wrapper objects. Closes GH-23503 --- NEWS | 1 + ext/intl/calendar/calendar_class.cpp | 2 ++ .../calendar_get_debug_info_tz_leak.phpt | 26 +++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 ext/intl/tests/calendar_get_debug_info_tz_leak.phpt diff --git a/NEWS b/NEWS index 546364fbc2ca..921ca372b294 100644 --- a/NEWS +++ b/NEWS @@ -30,6 +30,7 @@ PHP NEWS . Fixed bug GH-19320 (FPM UID and GID overflow). (Pratik Bhujel) - Intl: + . Fixed a memory leak when dumping IntlCalendar instances. (Ilia Alshanetsky) . Fixed a memory leak when iterating IntlBreakIterator::getPartsIterator() results. (iliaal) . Fixed a double-free when IntlGregorianCalendar construction fails after diff --git a/ext/intl/calendar/calendar_class.cpp b/ext/intl/calendar/calendar_class.cpp index 97b21ff8f965..bacb549bdbc6 100644 --- a/ext/intl/calendar/calendar_class.cpp +++ b/ext/intl/calendar/calendar_class.cpp @@ -171,6 +171,8 @@ static HashTable *Calendar_get_debug_info(zend_object *object, int *is_temp) FREE_HASHTABLE(debug_info_tz); zend_hash_str_update(debug_info, "timeZone", sizeof("timeZone") - 1, &ztz_debug); + + zval_ptr_dtor(&ztz); } { diff --git a/ext/intl/tests/calendar_get_debug_info_tz_leak.phpt b/ext/intl/tests/calendar_get_debug_info_tz_leak.phpt new file mode 100644 index 000000000000..32b9da4368a9 --- /dev/null +++ b/ext/intl/tests/calendar_get_debug_info_tz_leak.phpt @@ -0,0 +1,26 @@ +--TEST-- +IntlCalendar get_debug_info() must not leak the time zone wrapper object +--EXTENSIONS-- +intl +--FILE-- + +--EXPECT-- +int(0) From fbca6cb2219353420490aa746151d0660a4037bc Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 29 Aug 2026 07:58:59 -0400 Subject: [PATCH 08/11] dom: invalidate node list caches on class attribute mutations Reflected attribute writes such as className and id, classList mutations, and removeAttribute()/removeAttributeNS()/removeAttributeNode() modified attributes without bumping the document cache tag, so live HTMLCollection caches like getElementsByClassName() kept serving stale lengths and items. Invalidate the node list caches at every one of these mutation points. Sibling audit: Attr:: writes, setAttribute(), setAttributeNode() and setAttributeNS() already invalidate; php_dom_ns_compat_mark_attribute() only mirrors namespace declarations during reconciliation and is not user-visible. Closes GH-23501 --- NEWS | 2 + ext/dom/element.c | 13 ++++-- ...lementsByClassName_cache_invalidation.phpt | 44 +++++++++++++++++++ ext/dom/token_list.c | 2 + 4 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 ext/dom/tests/modern/common/getElementsByClassName_cache_invalidation.phpt diff --git a/NEWS b/NEWS index ccb18766e2ae..693ff78fafd0 100644 --- a/NEWS +++ b/NEWS @@ -9,6 +9,8 @@ PHP NEWS middle generator delegates again). (Lazizbek Ergashev) - DOM: + . Fixed stale getElementsByClassName() and other node list caches after + className/classList writes and attribute removals. (Ilia Alshanetsky) . Fixed a use-after-free when cloning a DOMNameSpaceNode after DOMDocument::xinclude(). (iliaal) . Fixed a crash in DOMXPath when a php:function callback receives a nodeset diff --git a/ext/dom/element.c b/ext/dom/element.c index 2320216f8244..8dd7f3756050 100644 --- a/ext/dom/element.c +++ b/ext/dom/element.c @@ -156,6 +156,7 @@ static xmlAttrPtr dom_element_reflected_attribute_write(dom_object *obj, zval *n /* Typed property, so it is a string already */ ZEND_ASSERT(Z_TYPE_P(newval) == IS_STRING); + php_libxml_invalidate_node_list_cache(obj->document); return xmlSetNsProp(nodep, NULL, (const xmlChar *) name, (const xmlChar *) Z_STRVAL_P(newval)); } @@ -544,7 +545,7 @@ static void dom_deep_ns_redef(xmlNodePtr node, xmlNsPtr ns_to_redefine) efree(worklist); } -static bool dom_remove_attribute(xmlNodePtr thisp, xmlNodePtr attrp) +static bool dom_remove_attribute(xmlNodePtr thisp, xmlNodePtr attrp, php_libxml_ref_obj *document) { ZEND_ASSERT(thisp != NULL); ZEND_ASSERT(attrp != NULL); @@ -599,6 +600,7 @@ static bool dom_remove_attribute(xmlNodePtr thisp, xmlNodePtr attrp) return false; EMPTY_SWITCH_DEFAULT_CASE(); } + php_libxml_invalidate_node_list_cache(document); return true; } @@ -624,7 +626,7 @@ PHP_METHOD(DOMElement, removeAttribute) RETURN_FALSE; } - RETURN_BOOL(dom_remove_attribute(nodep, attrp)); + RETURN_BOOL(dom_remove_attribute(nodep, attrp, intern->document)); } PHP_METHOD(Dom_Element, removeAttribute) @@ -642,7 +644,7 @@ PHP_METHOD(Dom_Element, removeAttribute) attrp = dom_get_attribute_or_nsdecl(intern, nodep, BAD_CAST name, name_len); if (attrp != NULL) { - dom_remove_attribute(nodep, attrp); + dom_remove_attribute(nodep, attrp, intern->document); } } /* }}} end dom_element_remove_attribute */ @@ -800,6 +802,7 @@ static void dom_element_remove_attribute_node(INTERNAL_FUNCTION_PARAMETERS, zend RETURN_FALSE; } + php_libxml_invalidate_node_list_cache(intern->document); xmlUnlinkNode((xmlNodePtr) attrp); DOM_RET_OBJ((xmlNodePtr) attrp, intern); @@ -1200,6 +1203,7 @@ PHP_METHOD(DOMElement, removeAttributeNS) if (nsptr != NULL) { if (xmlStrEqual(BAD_CAST uri, nsptr->href)) { dom_eliminate_ns(nodep, nsptr); + php_libxml_invalidate_node_list_cache(intern->document); } else { return; } @@ -1214,6 +1218,7 @@ PHP_METHOD(DOMElement, removeAttributeNS) } else { xmlUnlinkNode((xmlNodePtr) attrp); } + php_libxml_invalidate_node_list_cache(intern->document); } } /* }}} end dom_element_remove_attribute_ns */ @@ -1922,7 +1927,7 @@ PHP_METHOD(DOMElement, toggleAttribute) /* Step 5 */ if (force_is_null || !force) { - retval = !dom_remove_attribute(thisp, attribute); + retval = !dom_remove_attribute(thisp, attribute, intern->document); goto out; } diff --git a/ext/dom/tests/modern/common/getElementsByClassName_cache_invalidation.phpt b/ext/dom/tests/modern/common/getElementsByClassName_cache_invalidation.phpt new file mode 100644 index 000000000000..4efdad1b59b4 --- /dev/null +++ b/ext/dom/tests/modern/common/getElementsByClassName_cache_invalidation.phpt @@ -0,0 +1,44 @@ +--TEST-- +getElementsByClassName() cache must be invalidated by class attribute mutations +--EXTENSIONS-- +dom +--FILE-- +$body"); +} + +$checks = [ + 'className' => function ($doc, $span) { $span->className = 'zzz'; }, + 'classList-remove' => function ($doc, $span) { $span->classList->remove('foo'); }, + 'classList-value' => function ($doc, $span) { $span->classList->value = 'zzz'; }, + 'setAttribute' => function ($doc, $span) { $span->setAttribute('class', 'zzz'); }, + 'removeAttribute' => function ($doc, $span) { $span->removeAttribute('class'); }, + 'removeAttributeNode' => function ($doc, $span) { $span->removeAttributeNode($span->attributes['class']); }, +]; +foreach ($checks as $label => $fn) { + $doc = mk(''); + $coll = $doc->getElementsByClassName('foo'); + if ($coll->length !== 1) { + echo "$label: unexpected initial length\n"; + continue; + } + $fn($doc, $doc->querySelector('span')); + echo "$label: ", $coll->length === 0 ? "OK" : "STALE {$coll->length}", "\n"; +} + +$doc = mk(''); +$coll = $doc->getElementsByClassName('foo'); +var_dump($coll->length); +$doc->querySelector('span')->className = 'foo'; +echo $coll->length === 1 ? "growth OK" : "growth STALE", "\n"; +?> +--EXPECT-- +className: OK +classList-remove: OK +classList-value: OK +setAttribute: OK +removeAttribute: OK +removeAttributeNode: OK +int(0) +growth OK diff --git a/ext/dom/token_list.c b/ext/dom/token_list.c index 524ff699f41b..34e2aa6b3ca3 100644 --- a/ext/dom/token_list.c +++ b/ext/dom/token_list.c @@ -184,6 +184,7 @@ static void dom_token_list_update(dom_token_list_object *intern) HashTable *token_set = TOKEN_LIST_GET_SET(intern); php_libxml_invalidate_cache_tag(&intern->cache_tag); + php_libxml_invalidate_node_list_cache(intern->dom.document); /* 1. If the associated element does not have an associated attribute and token set is empty, then return. */ if (attr == NULL && zend_hash_num_elements(token_set) == 0) { @@ -432,6 +433,7 @@ zend_result dom_token_list_value_write(dom_object *obj, zval *newval) zend_value_error("Value must not contain any null bytes"); return FAILURE; } + php_libxml_invalidate_node_list_cache(intern->dom.document); xmlSetNsProp(dom_token_list_get_element(intern), NULL, BAD_CAST "class", BAD_CAST Z_STRVAL_P(newval)); /* Note: we don't update the set here, the set is always lazily updated for performance reasons. */ return SUCCESS; From f09a81ddee3e58a8c5af5b311a101b3c1b20a637 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 29 Aug 2026 08:16:19 -0400 Subject: [PATCH 09/11] [SOAP] Fix WSDL cache corruption when header defines headerfaults sdl_serialize_soap_body() counted j headerfaults per header but then serialized body->headers instead of tmp->headerfaults, writing N header records where j fault records were expected by sdl_deserialize_soap_body(), misaligning the cache stream and crashing on load whenever a soap:header carries headerfaults. Iterate tmp->headerfaults instead; sibling audit of the other serialize/deserialize loops in php_sdl.c found no further hash-mismatched iteration. Bump WSDL_CACHE_VERSION so existing on-disk caches are discarded. Closes GH-23502 --- NEWS | 2 ++ ext/soap/php_sdl.c | 4 +-- ext/soap/tests/headerfault_cache.phpt | 45 +++++++++++++++++++++++++ ext/soap/tests/headerfault_cache.wsdl | 47 +++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 ext/soap/tests/headerfault_cache.phpt create mode 100644 ext/soap/tests/headerfault_cache.wsdl diff --git a/NEWS b/NEWS index 921ca372b294..208ccaef35fb 100644 --- a/NEWS +++ b/NEWS @@ -66,6 +66,8 @@ PHP NEWS - SOAP: . Fixed bug GH-23447 (Segfault when a class passed to SoapServer::setClass() fails to initialize). (Lazizbek Ergashev) + . Fixed WSDL cache corruption when a soap:header defines headerfaults. + (Ilia Alshanetsky) - Standard: . Fixed a segfault when a stream filter callback unsets StreamBucket::$data diff --git a/ext/soap/php_sdl.c b/ext/soap/php_sdl.c index a44fc16f9716..7aa9e9fae259 100644 --- a/ext/soap/php_sdl.c +++ b/ext/soap/php_sdl.c @@ -1155,7 +1155,7 @@ static sdlPtr load_wsdl(zval *this_ptr, char *struri) return ctx.sdl; } -#define WSDL_CACHE_VERSION 0x10 +#define WSDL_CACHE_VERSION 0x11 #define WSDL_CACHE_GET(ret,type,buf) memcpy(&ret,*buf,sizeof(type)); *buf += sizeof(type); #define WSDL_CACHE_GET_INT(ret,buf) ret = ((unsigned char)(*buf)[0])|((unsigned char)(*buf)[1]<<8)|((unsigned char)(*buf)[2]<<16)|((unsigned)(*buf)[3]<<24); *buf += 4; @@ -2066,7 +2066,7 @@ static void sdl_serialize_soap_body(const sdlSoapBindingFunctionBodyPtr body, co sdlSoapBindingFunctionHeaderPtr tmp2; const zend_string *key_inner; - ZEND_HASH_MAP_FOREACH_STR_KEY_PTR(body->headers, key_inner, tmp2) { + ZEND_HASH_MAP_FOREACH_STR_KEY_PTR(tmp->headerfaults, key_inner, tmp2) { sdl_serialize_key(key_inner, out); WSDL_CACHE_PUT_1(tmp2->use, out); if (tmp2->use == SOAP_ENCODED) { diff --git a/ext/soap/tests/headerfault_cache.phpt b/ext/soap/tests/headerfault_cache.phpt new file mode 100644 index 000000000000..6da747902d80 --- /dev/null +++ b/ext/soap/tests/headerfault_cache.phpt @@ -0,0 +1,45 @@ +--TEST-- +WSDL cache corruption when soap:header has headerfaults +--EXTENSIONS-- +soap +--INI-- +soap.wsdl_cache_enabled=1 +--FILE-- + WSDL_CACHE_DISK]; + +$c1 = new SoapClient(__DIR__ . '/headerfault_cache.wsdl', $options); +var_dump($c1->__getFunctions()); + +$c2 = new SoapClient(__DIR__ . '/headerfault_cache.wsdl', $options); +var_dump($c2->__getFunctions()); + +echo "ok\n"; +?> +--CLEAN-- + +--EXPECT-- +array(1) { + [0]=> + string(32) "string testHeader(string $param)" +} +array(1) { + [0]=> + string(32) "string testHeader(string $param)" +} +ok diff --git a/ext/soap/tests/headerfault_cache.wsdl b/ext/soap/tests/headerfault_cache.wsdl new file mode 100644 index 000000000000..8a844c0b899d --- /dev/null +++ b/ext/soap/tests/headerfault_cache.wsdl @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 027a5f8536468498a659684cf273d4f8bdd06706 Mon Sep 17 00:00:00 2001 From: Weilin Du Date: Mon, 31 Aug 2026 00:19:57 +0800 Subject: [PATCH 10/11] ext/standard: Make `str_ends_with` frameless (#23510) Since str_starts_with is frameless, str_ends_with also should be frameless. --- UPGRADING | 1 + ext/standard/basic_functions.stub.php | 5 ++++- ext/standard/basic_functions_arginfo.h | 10 ++++++++-- ext/standard/basic_functions_decl.h | 8 ++++---- ext/standard/string.c | 15 +++++++++++++++ 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/UPGRADING b/UPGRADING index a164599c6f15..5a5cafc0234f 100644 --- a/UPGRADING +++ b/UPGRADING @@ -973,6 +973,7 @@ PHP 8.6 UPGRADE NOTES . Reduced temporary allocations when iterating Phar directories. - Standard: + . Improved performance of str_ends_with(). . Improved performance of array_fill_keys(). . Improved performance of array_intersect(). . Improved performance of array_map() with multiple arrays passed. diff --git a/ext/standard/basic_functions.stub.php b/ext/standard/basic_functions.stub.php index 3e23934cbc78..8ae94d8c6d99 100644 --- a/ext/standard/basic_functions.stub.php +++ b/ext/standard/basic_functions.stub.php @@ -2451,7 +2451,10 @@ function str_contains(string $haystack, string $needle): bool {} */ function str_starts_with(string $haystack, string $needle): bool {} -/** @compile-time-eval */ +/** + * @compile-time-eval + * @frameless-function {"arity": 2} + */ function str_ends_with(string $haystack, string $needle): bool {} /** diff --git a/ext/standard/basic_functions_arginfo.h b/ext/standard/basic_functions_arginfo.h index 442085e9d6cc..4bcf008f5fdd 100644 --- a/ext/standard/basic_functions_arginfo.h +++ b/ext/standard/basic_functions_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit basic_functions.stub.php instead. - * Stub hash: c645e310c00d9f4cb3856c94ee60d06071e28de0 + * Stub hash: 31018a787ba261316941b0d88f090b9cf271aa0e * Has decl header: yes */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_set_time_limit, 0, 1, _IS_BOOL, 0) @@ -2294,6 +2294,12 @@ static const zend_frameless_function_info frameless_function_infos_str_starts_wi { 0 }, }; +ZEND_FRAMELESS_FUNCTION(str_ends_with, 2); +static const zend_frameless_function_info frameless_function_infos_str_ends_with[] = { + { ZEND_FRAMELESS_FUNCTION_NAME(str_ends_with, 2), 2 }, + { 0 }, +}; + ZEND_FRAMELESS_FUNCTION(substr, 2); ZEND_FRAMELESS_FUNCTION(substr, 3); static const zend_frameless_function_info frameless_function_infos_substr[] = { @@ -3197,7 +3203,7 @@ static const zend_function_entry ext_functions[] = { ZEND_RAW_FENTRY("strrchr", zif_strrchr, arginfo_strrchr, ZEND_ACC_COMPILE_TIME_EVAL, NULL, NULL) ZEND_RAW_FENTRY("str_contains", zif_str_contains, arginfo_str_contains, ZEND_ACC_COMPILE_TIME_EVAL, frameless_function_infos_str_contains, NULL) ZEND_RAW_FENTRY("str_starts_with", zif_str_starts_with, arginfo_str_starts_with, ZEND_ACC_COMPILE_TIME_EVAL, frameless_function_infos_str_starts_with, NULL) - ZEND_RAW_FENTRY("str_ends_with", zif_str_ends_with, arginfo_str_ends_with, ZEND_ACC_COMPILE_TIME_EVAL, NULL, NULL) + ZEND_RAW_FENTRY("str_ends_with", zif_str_ends_with, arginfo_str_ends_with, ZEND_ACC_COMPILE_TIME_EVAL, frameless_function_infos_str_ends_with, NULL) ZEND_RAW_FENTRY("chunk_split", zif_chunk_split, arginfo_chunk_split, ZEND_ACC_COMPILE_TIME_EVAL, NULL, NULL) ZEND_RAW_FENTRY("substr", zif_substr, arginfo_substr, ZEND_ACC_COMPILE_TIME_EVAL, frameless_function_infos_substr, NULL) ZEND_RAW_FENTRY("substr_replace", zif_substr_replace, arginfo_substr_replace, ZEND_ACC_COMPILE_TIME_EVAL, NULL, NULL) diff --git a/ext/standard/basic_functions_decl.h b/ext/standard/basic_functions_decl.h index f2f234f60cc2..db81e0bfc077 100644 --- a/ext/standard/basic_functions_decl.h +++ b/ext/standard/basic_functions_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit basic_functions.stub.php instead. - * Stub hash: c645e310c00d9f4cb3856c94ee60d06071e28de0 */ + * Stub hash: 31018a787ba261316941b0d88f090b9cf271aa0e */ -#ifndef ZEND_BASIC_FUNCTIONS_DECL_c645e310c00d9f4cb3856c94ee60d06071e28de0_H -#define ZEND_BASIC_FUNCTIONS_DECL_c645e310c00d9f4cb3856c94ee60d06071e28de0_H +#ifndef ZEND_BASIC_FUNCTIONS_DECL_31018a787ba261316941b0d88f090b9cf271aa0e_H +#define ZEND_BASIC_FUNCTIONS_DECL_31018a787ba261316941b0d88f090b9cf271aa0e_H typedef enum zend_enum_SortDirection { ZEND_ENUM_SortDirection_Ascending = 1, @@ -20,4 +20,4 @@ typedef enum zend_enum_RoundingMode { ZEND_ENUM_RoundingMode_PositiveInfinity = 8, } zend_enum_RoundingMode; -#endif /* ZEND_BASIC_FUNCTIONS_DECL_c645e310c00d9f4cb3856c94ee60d06071e28de0_H */ +#endif /* ZEND_BASIC_FUNCTIONS_DECL_31018a787ba261316941b0d88f090b9cf271aa0e_H */ diff --git a/ext/standard/string.c b/ext/standard/string.c index e5307a4f2d4b..af3f6a461dcf 100644 --- a/ext/standard/string.c +++ b/ext/standard/string.c @@ -1895,6 +1895,21 @@ PHP_FUNCTION(str_ends_with) } /* }}} */ +ZEND_FRAMELESS_FUNCTION(str_ends_with, 2) +{ + zval haystack_tmp, needle_tmp; + zend_string *haystack, *needle; + + Z_FLF_PARAM_STR(1, haystack, haystack_tmp); + Z_FLF_PARAM_STR(2, needle, needle_tmp); + + RETVAL_BOOL(zend_string_ends_with(haystack, needle)); + +flf_clean: + Z_FLF_PARAM_FREE_STR(1, haystack_tmp); + Z_FLF_PARAM_FREE_STR(2, needle_tmp); +} + static zend_always_inline void _zend_strpos(zval *return_value, zend_string *haystack, zend_string *needle, zend_long offset) { const char *found = NULL; From e832b5e3e7477189d7e4f33291941a1e2a00f9a6 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 29 Aug 2026 08:00:19 -0400 Subject: [PATCH 11/11] [DOM] Fix getNamedItemNS() with empty URI not matching null namespace Normalize an empty URI to NULL in spec-following mode so xmlHasNsProp() matches null-namespace attributes, and skip XML_ATTRIBUTE_DECL results which cannot be wrapped as nodes. Closes GH-23498 --- NEWS | 2 ++ ext/dom/namednodemap.c | 6 +++++ .../spec/NamedNodeMap_getNamedItemNS.phpt | 25 +++++++++++++++++++ ...medNodeMap_getNamedItemNS_dtd_default.phpt | 24 ++++++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 ext/dom/tests/modern/spec/NamedNodeMap_getNamedItemNS.phpt create mode 100644 ext/dom/tests/modern/spec/NamedNodeMap_getNamedItemNS_dtd_default.phpt diff --git a/NEWS b/NEWS index 208ccaef35fb..608bacc29109 100644 --- a/NEWS +++ b/NEWS @@ -13,6 +13,8 @@ PHP NEWS return value of php_cli_server_client_send_through()). (Lazizbek Ergashev) - DOM: + . Fixed NamedNodeMap::getNamedItemNS() with an empty URI not matching + the null namespace in spec-following mode. (Ilia Alshanetsky) . Fixed a use-after-free when cloning a DOMNameSpaceNode after DOMDocument::xinclude(). (iliaal) . Fixed bug GH-23331 (UAF when node_list_unlink() skips attribute children diff --git a/ext/dom/namednodemap.c b/ext/dom/namednodemap.c index bc867aba4384..ac368600e20e 100644 --- a/ext/dom/namednodemap.c +++ b/ext/dom/namednodemap.c @@ -213,6 +213,9 @@ PHP_METHOD(DOMNamedNodeMap, getNamedItemNS) objmap = (dom_nnodemap_object *)intern->ptr; if (objmap != NULL) { + if (urilen == 0 && objmap->baseobj != NULL && objmap->nodetype != XML_NOTATION_NODE && objmap->nodetype != XML_ENTITY_NODE && php_dom_follow_spec_intern(objmap->baseobj)) { + uri = NULL; + } if ((objmap->nodetype == XML_NOTATION_NODE) || objmap->nodetype == XML_ENTITY_NODE) { if (objmap->ht) { @@ -229,6 +232,9 @@ PHP_METHOD(DOMNamedNodeMap, getNamedItemNS) nodep = dom_object_get_node(objmap->baseobj); if (nodep) { itemnode = (xmlNodePtr)xmlHasNsProp(nodep, BAD_CAST named, BAD_CAST uri); + if (itemnode != NULL && itemnode->type == XML_ATTRIBUTE_DECL) { + itemnode = NULL; + } } } } diff --git a/ext/dom/tests/modern/spec/NamedNodeMap_getNamedItemNS.phpt b/ext/dom/tests/modern/spec/NamedNodeMap_getNamedItemNS.phpt new file mode 100644 index 000000000000..17d0659678e6 --- /dev/null +++ b/ext/dom/tests/modern/spec/NamedNodeMap_getNamedItemNS.phpt @@ -0,0 +1,25 @@ +--TEST-- +getNamedItemNS() with an empty URI must look up the null namespace +--EXTENSIONS-- +dom +--FILE-- +loadXML(''); +$a = $d->documentElement->attributes->getNamedItemNS('', 'bar'); +var_dump($a === null ? null : $a->nodeValue); +$b = $d->documentElement->attributes->getNamedItemNS('urn:q', 'bar'); +var_dump($b === null ? null : $b->nodeValue); +$d2 = Dom\XMLDocument::createFromString(''); +$a2 = $d2->documentElement->attributes->getNamedItemNS('', 'bar'); +var_dump($a2 === null ? null : $a2->nodeValue); +var_dump($d2->documentElement->hasAttributeNS('', 'bar')); +$c = $d2->documentElement->attributes->getNamedItemNS('urn:q', 'bar'); +var_dump($c === null ? null : $c->nodeValue); +?> +--EXPECT-- +NULL +string(2) "ns" +string(5) "no-ns" +bool(true) +string(2) "ns" diff --git a/ext/dom/tests/modern/spec/NamedNodeMap_getNamedItemNS_dtd_default.phpt b/ext/dom/tests/modern/spec/NamedNodeMap_getNamedItemNS_dtd_default.phpt new file mode 100644 index 000000000000..c661af974c77 --- /dev/null +++ b/ext/dom/tests/modern/spec/NamedNodeMap_getNamedItemNS_dtd_default.phpt @@ -0,0 +1,24 @@ +--TEST-- +getNamedItemNS() with empty URI must not throw on DTD default attributes +--EXTENSIONS-- +dom +--FILE-- + + + +]> + +XML; + +$el = Dom\XMLDocument::createFromString($xml)->documentElement; +$defaulted = $el->attributes->getNamedItemNS('', 'defaulted'); +var_dump($defaulted === null ? null : $defaulted->nodeValue); +$real = $el->attributes->getNamedItemNS('', 'real'); +var_dump($real === null ? null : $real->nodeValue); +?> +--EXPECT-- +NULL +string(7) "present"