[php-src] Issue #24731: Assertion failure in zend_objects_store_del() when a bailout interrupts zend_fcc_dtor() in header_register_callback()
| From: | IzaiahSun | Date: | Mon, 26 Oct 2026 08:50:57 +0000 |
| Subject: | [php-src] Issue #24731: Assertion failure in zend_objects_store_del() when a bailout interrupts zend_fcc_dtor() in header_register_callback() | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-253209@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24731
Author: IzaiahSun
### Description
The following code:
```php
<?php
class Slow {
public function __destruct() { $i = 0; while (true) { $i++; } }
}
$slow = new Slow;
$cl = function () use ($slow) { };
unset($slow);
header_register_callback($cl);
unset($cl);
header_register_callback('str_repeat');
echo "x";
```
Resulted in this output:
```
Assertion failed: ((!(((uintptr_t)((executor_globals.objects_store).object_buckets[handle])) &
(1<<0)))), function zend_objects_store_del, file zend_objects_API.c, line 191.
```
But I expected this output instead:
```
(no assertion; "x" on stdout)
```
---
Run on a debug build as
php -n -d max_execution_time=1 poc.php. The time limit is the
trigger: it is the bailout source that still calls zend_bailout().
## What happens
header_register_callback() releases the previously registered callable before storing
the new
one, and zend_fcc_dtor() clears the slot only on its last line:
```c
/* main/SAPI.c, PHP_FUNCTION(header_register_callback) */
if (ZEND_FCC_INITIALIZED(SG(send_header_fcc))) {
zend_fcc_dtor(&SG(send_header_fcc)); /* main/SAPI.c:119 */
}
```
```c
/* Zend/zend_API.h */
static zend_always_inline void zend_fcc_dtor(zend_fcall_info_cache *fcc)
{
ZEND_ASSERT(fcc->function_handler);
if (fcc->object) { OBJ_RELEASE(fcc->object); }
zend_release_fcall_info_cache(fcc);
if (fcc->closure) { OBJ_RELEASE(fcc->closure); } /* user code runs under here */
*fcc = empty_fcall_info_cache; /* only reached on the happy path */
}
```
Releasing the closure destroys its captured variables, which can run a userland
__destruct().
If that destructor is interrupted by a bailout — max_execution_time is the easiest
source —
the longjmp skips *fcc = empty_fcall_info_cache, so
SG(send_header_fcc) keeps pointing at
an object that zend_objects_store_del() has already invalidated.
The fatal-error message is then printed through the output layer, which re-enters
sapi_send_headers() *while the first zend_fcc_dtor() is still unwinding*.
sapi_send_headers() sees the stale slot as initialised, calls through it, and calls
zend_fcc_dtor() on it a second time.
## Backtrace
```
frame #1: zend_objects_store_del(object=0x109a72180) at zend_objects_API.c:188
frame #2: zend_object_release(obj=0x109a72180) at zend_objects_API.h:83
frame #3: zend_fcc_dtor(fcc=0x16fdf88d0) at zend_API.h:851
frame #4: sapi_send_headers at SAPI.c:875
frame #5: php_header at head.c:70
frame #6: php_output_header at output.c:119
frame #7: php_output_op(op=0, str="\nFatal error: Maximum execution time ...")
frame #8: php_output_write(...) at output.c:242
frame #9: php_printf_unchecked(...) at main.c:1060
frame #10: php_error_cb(orig_type=1, ...) at main.c:1478
frame #11: zend_error_zstr_at(...) at zend.c:1547
frame #12: zend_error_va_list(...) at zend.c:1647
frame #13: zend_error_noreturn(type=1, format="Maximum execution time of %lld second%s
exceeded")
frame #14: zend_timeout at zend_execute_API.c:1517
frame #15: zend_interrupt_helper_SPEC_TAILCALL(...)
...
frame #18: zend_call_known_function_ex(fn=..., this_ptr=...) at zend_execute_API.c:1185
frame #19: zend_call_known_function(...) at zend_API.h:895
```
Frames 14–19 are the interrupted destructor; frames 4–9 are the fatal-error printer re-entering
the header-sending path; frame 3 is the *second* zend_fcc_dtor() on the same fcc.
## Why the bucket is already invalid
zend_objects_store_del() invalidates the bucket **before** calling
free_obj
(Zend/zend_objects_API.c):
```c
EG(objects_store).object_buckets[handle] = SET_OBJ_INVALID(object);
if (!(OBJ_FLAGS(object) & IS_OBJ_FREE_CALLED)) {
GC_ADD_FLAGS(object, IS_OBJ_FREE_CALLED);
GC_SET_REFCOUNT(object, 1);
object->handlers->free_obj(object); /* <- bailout happens in here */
}
ptr = ((char*)object) - object->handlers->offset;
GC_REMOVE_FROM_BUFFER(object);
efree(ptr);
ZEND_OBJECTS_STORE_ADD_TO_FREE_LIST(handle);
```
The bailout skips efree(ptr) and the free-list add, but the bucket is already marked
invalid,
so the second entry trips ZEND_ASSERT(IS_OBJ_VALID(...)).
## What is not demonstrated
Being explicit, because the assertion text invites a stronger reading than the evidence supports:
1. **No use-after-free was observed.** On a non-debug ASan build of the same commit
(--disable-debug --enable-address-sanitizer) the PoC produces no ASan report.
Without the
assertion, the second zend_objects_store_del() sees IS_OBJ_FREE_CALLED
already set, skips
free_obj, and performs the efree() that the interrupted first call
never reached — so it
completes the free rather than repeating it.
2. What *is* reached is zend_call_known_fcc() (SAPI.c:874) on a closure whose
free_obj has
already run and whose captured variables have already been destroyed. In this PoC the call
returns normally; whether a payload can observe the destroyed state has not been tested.
3. The practical impact therefore looks like an engine invariant violation rather than a
demonstrated memory-safety bug. It may still be worth fixing as such.
## Suggested fix
sapi_send_headers() already uses the correct pattern 750 lines further down the same
file —
copy, clear the global, then act:
```c
/* main/SAPI.c:870-876 */
if (ZEND_FCC_INITIALIZED(SG(send_header_fcc))) {
zend_fcall_info_cache fcc = SG(send_header_fcc);
/* Prevent triggering the callback multiple times */
SG(send_header_fcc) = empty_fcall_info_cache;
zend_call_known_fcc(&fcc, NULL, 0, NULL, NULL);
zend_fcc_dtor(&fcc);
}
```
PHP_FUNCTION(header_register_callback) does not:
```c
/* main/SAPI.c:118-120, current */
if (ZEND_FCC_INITIALIZED(SG(send_header_fcc))) {
zend_fcc_dtor(&SG(send_header_fcc));
}
/* proposed */
if (ZEND_FCC_INITIALIZED(SG(send_header_fcc))) {
zend_fcall_info_cache old = SG(send_header_fcc);
SG(send_header_fcc) = empty_fcall_info_cache;
zend_fcc_dtor(&old);
}
```
Every caller that releases an fcc held in a global has the same requirement, so clearing inside
zend_fcc_dtor() is not enough — the slot must be detached before the release begins.
## Possibly related, but a different defect
are in the same area but look like a different defect
(the callback running after module shutdown, with extension globals already torn down):
- php/php-src#20727 "User code can run after module request shutdown via the output layer"
(Verified)
- php/php-src#21302 "SEGV in free_subpats_table (PCRE call in header_register_callback)"
### PHP Version
```plain
PHP 8.4.28-dev (cli) (built: Oct 25 2026 01:02:13) (NTS DEBUG)
Copyright (c) The PHP Group
Zend Engine v4.4.28-dev, Copyright (c) Zend Technologies
Also reproduced on master (29b5e168bf):
HP 8.7.0-dev (cli) (built: Oct 24 2026 21:13:17) (NTS DEBUG)
Copyright © The PHP Group and Contributors
Zend Engine v4.7.0-dev, Copyright © Zend by Perforce
with Zend OPcache v8.7.0-dev, Copyright ©, by Zend by Perforce
```
### Operating System
macOS 27.0.1 (arm64)