[php-src] Issue #24763: ext/libxml: bailout during external entity loader destruction leaves stale FCC and can cause use-after-free
| From: | thediex | 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