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

From: Date: Mon, 26 Oct 2026 21:50:03 +0000
Subject: [php-src] Issue #24763: ext/libxml: bailout during external entity loader destruction leaves stale FCC and can cause use-after-free
Groups: php.bugs 
Request: Send a blank email to php-bugs+get-253224@lists.php.net to get a copy of this message
Issue: https://github.com/php/php-src/issues/24763 Author: thediex ### Description ### Description libxml_set_external_entity_loader() destroys the currently stored zend_fcall_info_cache directly: ```c 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 <?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: ```sh 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: ```text 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: ```c 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: ```text PHP 8.5.12: ASan heap-use-after-free: 10/10 php-src master: ASan heap-use-after-free: 10/10 ``` After the patch: ```text ASan heap-use-after-free: 0/10 libxml_get_external_entity_loader(): null 10/10 ``` I also added a regression PHPT. On unpatched master: ```text BAILOUT_REACHED=yes LOADER=Closure FAIL ``` On patched master: ```text BAILOUT_REACHED=yes LOADER=null PASS ``` The complete ext/libxml test suite with the regression test gives: ```text 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 ```plain 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

« previous php.bugs (#253224) next »