Commit 0857fdc252a for php

commit 0857fdc252af319c8cf139bedaf1d4d935a37aca
Author: Jakub Zelenka <bukka@php.net>
Date:   Sun Nov 1 14:45:14 2026 +0100

    Fix UAF in reflected attribute write paths (#25120)

    Reflected className/id writes and TokenList mutations call
    xmlSetNsProp(), which frees an existing attribute child list.
    Unlink live child wrappers before replacement, following GH-23348.

diff --git a/NEWS b/NEWS
index ea8935bd64a..b2d2ef47260 100644
--- a/NEWS
+++ b/NEWS
@@ -68,6 +68,8 @@ PHP                                                                        NEWS
     NULL deref on first use after a failed open). (Girgias)

 - DOM:
+  . Fixed a use-after-free when reflected attribute setters replace an
+    attribute whose child still has a live wrapper. (Jakub Zelenka)
   . Fixed bug GH-23352 (UAF reading an attribute value node retained across
     DOMDocument::adoptNode()). (David Carlier)
   . Fixed bug GH-24260 (querySelectorAll() returns duplicate elements for
diff --git a/ext/dom/element.c b/ext/dom/element.c
index 8af5d54db33..f370b0e958d 100644
--- a/ext/dom/element.c
+++ b/ext/dom/element.c
@@ -156,7 +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);
-	return xmlSetNsProp(nodep, NULL, (const xmlChar *) name, (const xmlChar *) Z_STRVAL_P(newval));
+	return dom_set_attribute_value(nodep, NULL, (const xmlChar *) name, (const xmlChar *) Z_STRVAL_P(newval));
 }

 /* {{{ className	string
@@ -1030,11 +1030,7 @@ static void dom_set_attribute_ns_modern(dom_object *intern, xmlNodePtr elemp, ze
 	if (errorcode == 0) {
 		php_dom_libxml_ns_mapper *ns_mapper = php_dom_get_ns_mapper(intern);
 		xmlNsPtr ns = php_dom_libxml_ns_mapper_get_ns_raw_prefix_string(ns_mapper, prefix, xmlStrlen(prefix), uri);
-		xmlNodePtr existing = (xmlNodePtr) xmlHasNsProp(elemp, localname, ns == NULL ? NULL : ns->href);
-		if (existing != NULL && existing->type != XML_ATTRIBUTE_DECL) {
-			node_list_unlink(existing->children);
-		}
-		xmlAttrPtr attr = xmlSetNsProp(elemp, ns, localname, BAD_CAST value);
+		xmlAttrPtr attr = dom_set_attribute_value(elemp, ns, localname, BAD_CAST value);
 		if (UNEXPECTED(attr == NULL)) {
 			php_dom_throw_error(INVALID_STATE_ERR, /* strict */ true);
 		} else {
diff --git a/ext/dom/php_dom.c b/ext/dom/php_dom.c
index 7bc99e68794..445eacdfa19 100644
--- a/ext/dom/php_dom.c
+++ b/ext/dom/php_dom.c
@@ -2408,6 +2408,15 @@ void dom_remove_all_children(xmlNodePtr nodep)
 	}
 }

+xmlAttrPtr dom_set_attribute_value(xmlNodePtr element, xmlNsPtr ns, const xmlChar *name, const xmlChar *value)
+{
+	xmlAttrPtr existing = xmlHasNsProp(element, name, ns == NULL ? NULL : ns->href);
+	if (existing != NULL && existing->type != XML_ATTRIBUTE_DECL) {
+		node_list_unlink(existing->children);
+	}
+	return xmlSetNsProp(element, ns, name, value);
+}
+
 void php_dom_get_content_into_zval(const xmlNode *nodep, zval *return_value, bool null_on_failure)
 {
 	ZEND_ASSERT(nodep != NULL);
diff --git a/ext/dom/php_dom.h b/ext/dom/php_dom.h
index d399d745084..2e6f6ef7c45 100644
--- a/ext/dom/php_dom.h
+++ b/ext/dom/php_dom.h
@@ -140,6 +140,7 @@ zend_object *dom_xpath_objects_new(zend_class_entry *class_type);
 #endif
 bool dom_get_strict_error(php_libxml_ref_obj *document);
 void node_list_unlink(xmlNodePtr node);
+xmlAttrPtr dom_set_attribute_value(xmlNodePtr element, xmlNsPtr ns, const xmlChar *name, const xmlChar *value);
 int dom_check_qname(char *qname, char **localname, char **prefix, int uri_len, int name_len);
 xmlNsPtr dom_get_ns(xmlNodePtr node, char *uri, int *errorcode, char *prefix);
 xmlNsPtr dom_get_ns_unchecked(xmlNodePtr nodep, char *uri, char *prefix);
diff --git a/ext/dom/tests/reflected_attribute_write_live_child.phpt b/ext/dom/tests/reflected_attribute_write_live_child.phpt
new file mode 100644
index 00000000000..b387784a32c
--- /dev/null
+++ b/ext/dom/tests/reflected_attribute_write_live_child.phpt
@@ -0,0 +1,63 @@
+--TEST--
+Reflected attribute writes keep attribute children with live wrappers
+--EXTENSIONS--
+dom
+--FILE--
+<?php
+
+function check(string $label, $doc, Closure $write): void {
+    $element = $doc->documentElement;
+    $text = $element->getAttributeNode('class')->firstChild;
+
+    $write($element);
+
+    echo $label, ": detached: ";
+    var_dump($text->parentNode === null);
+    echo $label, ": text: ";
+    var_dump($text->textContent);
+    echo $label, ": new value: ";
+    var_dump($element->getAttribute('class'));
+}
+
+$doc = new DOMDocument();
+$doc->loadXML('<root class="old"/>');
+check('legacy className', $doc, static fn ($element) => $element->className = 'new');
+
+$doc = Dom\XMLDocument::createFromString('<root class="old"/>');
+check('modern className', $doc, static fn ($element) => $element->className = 'new');
+
+$doc = Dom\XMLDocument::createFromString('<root class="old"/>');
+check('classList value', $doc, static fn ($element) => $element->classList->value = 'new');
+
+$doc = Dom\XMLDocument::createFromString('<root class="old"/>');
+check('classList update', $doc, static fn ($element) => $element->classList->add('new'));
+
+$doc = Dom\XMLDocument::createFromString('<root class="old"/>');
+$element = $doc->documentElement;
+$element->id = 'old';
+$text = $element->getAttributeNode('id')->firstChild;
+$element->id = 'new';
+echo "id: detached: ";
+var_dump($text->parentNode === null);
+echo "id: text: ";
+var_dump($text->textContent);
+echo "id: new value: ";
+var_dump($element->id);
+
+?>
+--EXPECT--
+legacy className: detached: bool(true)
+legacy className: text: string(3) "old"
+legacy className: new value: string(3) "new"
+modern className: detached: bool(true)
+modern className: text: string(3) "old"
+modern className: new value: string(3) "new"
+classList value: detached: bool(true)
+classList value: text: string(3) "old"
+classList value: new value: string(3) "new"
+classList update: detached: bool(true)
+classList update: text: string(3) "old"
+classList update: new value: string(7) "old new"
+id: detached: bool(true)
+id: text: string(3) "old"
+id: new value: string(3) "new"
diff --git a/ext/dom/token_list.c b/ext/dom/token_list.c
index 14d310cd5a7..b9f3b330b4f 100644
--- a/ext/dom/token_list.c
+++ b/ext/dom/token_list.c
@@ -143,7 +143,7 @@ static void dom_token_list_update(dom_token_list_object *intern)
 	/* 2. Set an attribute value for the associated element using associated attribute’s local name and the result of
 	 *    running the ordered set serializer for token set. */
 	char *value = dom_ordered_set_serializer(token_set);
-	xmlSetNsProp(dom_token_list_get_element(intern), NULL, BAD_CAST "class", BAD_CAST value);
+	dom_set_attribute_value(dom_token_list_get_element(intern), NULL, BAD_CAST "class", BAD_CAST value);
 	efree(intern->cached_string);
 	intern->cached_string = value;
 }
@@ -382,7 +382,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;
 	}
-	xmlSetNsProp(dom_token_list_get_element(intern), NULL, BAD_CAST "class", BAD_CAST Z_STRVAL_P(newval));
+	dom_set_attribute_value(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;
 }