Skip to content

ext/libxml: bailout during external entity loader destruction leaves stale FCC and can cause use-after-free #24763

Closed as duplicate

Description

@thediex

Description

libxml_set_external_entity_loader() destroys the currently stored zend_fcall_info_cache directly:

zend_fcc_dtor(&LIBXML(entity_loader_callback));

If destruction of the callback triggers a bailout, zend_fcc_dtor() may be interrupted before the FCC is reset.

As a result, LIBXML(entity_loader_callback) can remain initialized while referencing a partially destroyed Closure.

libxml_get_external_entity_loader() can then retrieve the stale callback.

With AddressSanitizer, invoking the retrieved Closure produces a reproducible heap-use-after-free.

I reproduced this on:

  • PHP 8.5.12
  • php-src master (7d3a57a85864f4ce482247cbfc66e7b7ec635c55)
  • Linux x86_64
  • libxml 2.15.4
  • Debug build with Clang + AddressSanitizer

The ASan UAF reproduced 10/10 times on both PHP 8.5.12 and php-src master.

Minimal reproducer

<?php

set_time_limit(1);

class Victim
{
    public string $marker = 'ALIVE';
}

class Slow
{
    public function __destruct()
    {
        while (true) {
        }
    }
}

$victim = new Victim();
$slow = new Slow();

$loader = function () use ($victim, $slow) {
    echo $victim->marker, "\n";
};

unset($victim, $slow);

register_shutdown_function(function () {
    $cb = libxml_get_external_entity_loader();

    var_dump(get_debug_type($cb));

    if ($cb) {
        $cb();
    }
});

libxml_set_external_entity_loader($loader);
unset($loader);

libxml_set_external_entity_loader(null);

Run with an ASan build:

USE_ZEND_ALLOC=0 \
ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:symbolize=1:fast_unwind_on_malloc=0" \
php -n reproducer.php

ASan reports:

ERROR: AddressSanitizer: heap-use-after-free
READ of size 4
...
zend_gc_addref
...
zend_closure_free_storage
zend_object_release
zend_fcc_dtor
zif_libxml_set_external_entity_loader

Root cause

The stored FCC remains in LIBXML(entity_loader_callback) while zend_fcc_dtor() releases the Closure/object references.

A bailout during that process prevents zend_fcc_dtor() from reaching its final FCC reset.

The global therefore remains observable as initialized even though the underlying callback has already been partially destroyed.

The same direct destruction pattern also exists in libxml RSHUTDOWN.

Proposed fix

Detach the FCC from module-global state before destroying it:

if (ZEND_FCC_INITIALIZED(LIBXML(entity_loader_callback))) {
    zend_fcall_info_cache old_fcc = LIBXML(entity_loader_callback);
    LIBXML(entity_loader_callback) = empty_fcall_info_cache;
    zend_fcc_dtor(&old_fcc);
}

I applied this in both:

  • PHP_RSHUTDOWN_FUNCTION(libxml)
  • libxml_set_external_entity_loader()

Validation

Before the patch:

PHP 8.5.12:
ASan heap-use-after-free: 10/10

php-src master:
ASan heap-use-after-free: 10/10

After the patch:

ASan heap-use-after-free: 0/10
libxml_get_external_entity_loader(): null 10/10

I also added a regression PHPT.

On unpatched master:

BAILOUT_REACHED=yes
LOADER=Closure

FAIL

On patched master:

BAILOUT_REACHED=yes
LOADER=null

PASS

The complete ext/libxml test suite with the regression test gives:

Number of tests : 34
Tests skipped   : 8
Tests failed    : 0
Tests passed    : 26

git diff --check reports no whitespace errors.

I have a patch containing both the ext/libxml/libxml.c fix and the regression test.

PHP Version

PHP 8.7.0-dev (cli) (NTS DEBUG), built from php-src master commit
7d3a57a85864f4ce482247cbfc66e7b7ec635c55 with Clang + AddressSanitizer.

Also reproduced on PHP 8.5.12 built from the official source tarball with AddressSanitizer.

Operating System

Kali Linux x86_64, kernel 6.17.10+kali-amd64

Activity

  1. assigned and unassigned on Oct 26, 2026
  2. added a commit that references this issue on Oct 27, 2026
    e173e6d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions