Description
Originally reported by @waseem-cve.
The following code:
<?php
$doc = new DOMDocument; $doc->loadXML('<root/>');
$xp = new DOMXPath($doc);
$xp->registerNamespace('my','my.ns');
$xp->registerPHPFunctionNS('my.ns','include', function() {
$d = new DOMDocument;
$d->loadXML('<r><uaf/></r>');
return $d->documentElement;
});
$xp->registerPHPFunctionNS('my.ns','process', function($arg) {
return 'x';
});
$xp->query('my:process(my:include()/uaf)');
unset($xp);
Resulted in a UAF. The argument-node proxy is created against the DOMXPath's own dom_object instead of the node's document, so it holds no reference on the foreign document. When node_list is torn down at unset($xp), that document is freed while the proxy still points into it.
Similar issue: #22077
<?php
$doc = new DOMDocument();
$doc->loadXML('<root><a><x>1</x></a><b><x>2</x></b><c><x>3</x></c></root>');
$root = $doc->documentElement;
$xpath = new DOMXPath($doc);
$xpath->registerNamespace('php', 'http://php.net/xpath');
$xpath->registerPHPFunctions(['trigger_uaf']);
$call_count = 0;
function trigger_uaf($nodes) {
global $root, $call_count;
$call_count++;
// On the 2nd call (while XPath is still evaluating other nodes),
// remove <a> which frees its <x> child that XPath already found
if ($call_count == 2) {
$a = $root->firstChild;
if ($a && $a->nodeType === XML_ELEMENT_NODE) {
$root->removeChild($a);
// $a goes out of scope -> refcount drops to 0
// -> dom_objects_free_storage -> php_libxml_node_free_resource
// -> php_libxml_node_free_list frees <x> child (no PHP ref)
// BUT libxml2 XPath already has <x> in its nodeTab!
}
}
return "1";
}
// //x selects all <x> elements. The predicate calls trigger_uaf() for each.
// During filtering, the callback frees an <x> node -> UAF when processing results
$result = $xpath->query('//x[php:functionString("trigger_uaf", .)]');
echo "Result: " . $result->length . "\n";
<?php
$doc = new DOMDocument();
$doc->loadXML('<root><aaa>A</aaa><bbb>B</bbb><ccc>C</ccc></root>');
$xpath = new DOMXPath($doc);
$xpath->registerNamespace('php', 'http://php.net/xpath');
$xpath->registerPhpFunctions();
$call = 0;
function cb($nodes) {
global $doc, $call;
$call++;
if ($call == 2) {
// 2nd callback: context node is <aaa>.
// <bbb>, <ccc>, text nodes have _private==NULL
// (wrappers from previous callbacks already destroyed).
// textContent setter frees them via dom_remove_all_children.
$doc->documentElement->textContent = "X";
}
return true;
}
// XPath predicate calls cb() for each node.
// After cb() frees <bbb>, XPath engine reads freed <bbb> -> UAF.
$xpath->query('//node()[php:function("cb", .)]');
<?php
class DOMKiller {
public $doc;
public function __destruct() {
if ($this->doc && $this->doc->documentElement) {
while ($this->doc->documentElement->firstChild) {
$this->doc->documentElement->removeChild(
$this->doc->documentElement->firstChild
);
}
}
}
}
$doc = new DOMDocument();
$doc->loadXML('<root><item>1</item><item>2</item><item>3</item></root>');
$killer = new DOMKiller();
$killer->doc = $doc;
$xpath = new DOMXPath($doc);
$xpath->registerNamespace("php", "http://php.net/xpath");
$xpath->registerPhpFunctions();
function gc_nuke($nodes) {
global $killer;
$killer = null; // Make DOMKiller garbage
gc_collect_cycles(); // Force GC -> __destruct -> remove all children -> free xmlNodes
return "ok";
}
// Crashes: GC-triggered destructor frees xmlNodes while XPath is
// still iterating the nodeset to fetch callback arguments
$xpath->query('//item[php:function("gc_nuke", .)]');
$doc = new DOMDocument();
$doc->loadXML('<root><item>1</item><item>2</item><item>3</item></root>');
$xpath = new DOMXPath($doc);
$xpath->registerNamespace("php", "http://php.net/xpath");
$xpath->registerPhpFunctions();
function trigger_uaf($nodes) {
if (is_array($nodes)) {
foreach ($nodes as $node) {
if ($node instanceof DOMNode && $node->parentNode) {
$new = $node->ownerDocument->createElement("replaced");
$node->parentNode->replaceChild($new, $node);
}
}
}
return "ok";
}
// Crashes: callback replaces nodes (freeing xmlNode) -> XPath result
// still references freed memory -> READ of freed node->type at line 351
$xpath->query('//item[php:function("trigger_uaf", .)]');
PHP Version
Operating System
No response
Description
Originally reported by @waseem-cve.
The following code:
Resulted in a UAF. The argument-node proxy is created against the DOMXPath's own
dom_objectinstead of the node's document, so it holds no reference on the foreign document. Whennode_listis torn down atunset($xp), that document is freed while the proxy still points into it.Similar issue: #22077
Variation 2 (by @calysteon)
Variation 3 (by @ExPatch-LLC)
Variation 4 (by @ExPatch-LLC)
Variation 5 (by @ExPatch-LLC)
PHP Version
Operating System
No response