Commit f9821dd1dda for php.net

commit f9821dd1dda7941a4528aef0529b2a454e6da792
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Fri Jun 26 08:15:14 2026 -0400

    Fix use-after-free in XPath php:function argument nodes

    A php:function() callback receives DOM node arguments as proxies that own
    the underlying libxml node. If the callback detaches such a node, the
    argument cleanup frees it while libxml is still evaluating the expression
    and still references it in the result node-set. Keep node and node-set
    argument proxies alive until evaluation ends, as returned nodes already are.

    Closes GH-22468

diff --git a/ext/dom/tests/xpath_php_function_removes_argument_node.phpt b/ext/dom/tests/xpath_php_function_removes_argument_node.phpt
new file mode 100644
index 00000000000..e6696617be2
--- /dev/null
+++ b/ext/dom/tests/xpath_php_function_removes_argument_node.phpt
@@ -0,0 +1,33 @@
+--TEST--
+DOMXPath: a php:function callback that removes its argument node must not free it mid-evaluation
+--EXTENSIONS--
+dom
+--FILE--
+<?php
+$doc = new DOMDocument();
+$doc->loadXML('<root><a>1</a><a>2</a><a>3</a><a>4</a></root>');
+$xp = new DOMXPath($doc);
+$xp->registerNamespace('php', 'http://php.net/xpath');
+$xp->registerPhpFunctions();
+
+function cb($nodes) {
+    foreach ($nodes as $n) {
+        if ($n->parentNode) {
+            $n->parentNode->removeChild($n);
+        }
+    }
+    return true;
+}
+
+$res = $xp->query('//a[php:function("cb", .)]');
+foreach ($res as $r) {
+    var_dump($r->nodeName);
+}
+echo "done\n";
+?>
+--EXPECT--
+string(1) "a"
+string(1) "a"
+string(1) "a"
+string(1) "a"
+done
diff --git a/ext/dom/xpath_callbacks.c b/ext/dom/xpath_callbacks.c
index 28a5272a302..53e8f344314 100644
--- a/ext/dom/xpath_callbacks.c
+++ b/ext/dom/xpath_callbacks.c
@@ -381,11 +381,19 @@ static zval *php_dom_xpath_callback_fetch_args(xmlXPathParserContextPtr ctxt, ui
 	return params;
 }

-static void php_dom_xpath_callback_cleanup_args(zval *params, uint32_t param_count)
+static void php_dom_xpath_callback_cleanup_args(php_dom_xpath_callbacks *xpath_callbacks, zval *params, uint32_t param_count)
 {
 	if (params) {
 		for (uint32_t i = 0; i < param_count; i++) {
-			zval_ptr_dtor(&params[i]);
+			zval *param = &params[i];
+			if (Z_TYPE_P(param) == IS_OBJECT || Z_TYPE_P(param) == IS_ARRAY) {
+				if (xpath_callbacks->node_list == NULL) {
+					xpath_callbacks->node_list = zend_new_array(0);
+				}
+				zend_hash_next_index_insert_new(xpath_callbacks->node_list, param);
+			} else {
+				zval_ptr_dtor(param);
+			}
 		}
 		efree(params);
 	}
@@ -483,7 +491,7 @@ PHP_DOM_EXPORT zend_result php_dom_xpath_callbacks_call_php_ns(php_dom_xpath_cal

 cleanup:
 	xmlXPathFreeObject(obj);
-	php_dom_xpath_callback_cleanup_args(params, param_count);
+	php_dom_xpath_callback_cleanup_args(xpath_callbacks, params, param_count);
 cleanup_no_obj:
 	if (UNEXPECTED(result != SUCCESS)) {
 		/* Push sentinel value */
@@ -511,7 +519,7 @@ PHP_DOM_EXPORT zend_result php_dom_xpath_callbacks_call_custom_ns(php_dom_xpath_

 	zend_result result = php_dom_xpath_callback_dispatch(xpath_callbacks, ns, ctxt, params, param_count, function_name, function_name_length);

-	php_dom_xpath_callback_cleanup_args(params, param_count);
+	php_dom_xpath_callback_cleanup_args(xpath_callbacks, params, param_count);
 	if (UNEXPECTED(result != SUCCESS)) {
 		/* Push sentinel value */
 		valuePush(ctxt, xmlXPathNewString((const xmlChar *) ""));