Repository navigation
Description
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
Fix phpGH-24763: Handle libxml entity loader destruction reentrancy