Repository navigation
Description
Description
The following code:
<?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:
/* 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 */
}/* 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):
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:
- 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 secondzend_objects_store_del()seesIS_OBJ_FREE_CALLEDalready set, skips
free_obj, and performs theefree()that the interrupted first call never reached — so it
completes the free rather than repeating it. - What is reached is
zend_call_known_fcc()(SAPI.c:874) on a closure whosefree_objhas
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. - 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:
/* 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:
/* 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):
- User code can run after module request shutdown via the output layer #20727 "User code can run after module request shutdown via the output layer" (Verified)
- SEGV in free_subpats_table (PCRE call in header_register_callback) #21302 "SEGV in free_subpats_table (PCRE call in header_register_callback)"
PHP Version
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)
Activity
Fix phpGH-24731: Assertion failure on bailout in header_register_call…
Merge branch 'PHP-8.4' of https://github.com/php/php-src into fix/php…
windshock commentedon Nov 4, 2026
Independent confirmation, plus a root-cause note that may help triage (no action requested — just data).
Reproduction. We confirmed the assertion failure on 8.4.27 with the same setup (a
header_register_callback()callback that bails out whilezend_fcc_dtor()is releasing the stored FCC): 3/3 runs under ASan withUSE_ZEND_ALLOC=0, same assertion site —zend_objects_store_del()during the interrupted FCC release. Clean on master.Root cause — shared with GH-24763. The same defect class reproduces through
libxml_set_external_entity_loader()(GH-24763): an FCC-backed callback whose release path (zend_fcc_dtor) is not bailout-safe. In both cases the bailout interrupts the FCC release while the object is still registered in the object store, so the laterzend_objects_store_del()hits an inconsistent refcount. Measured: GH-24763 heap-use-after-free 3/3 on 8.4.27 (vialibxml_set_external_entity_loader), GH-24731 assertion 3/3 — one design flaw, two symptoms.Family observation. Every FCC-based callback setter (
header_register_callback,libxml_set_external_entity_loader, and potentiallycurl_close/curl_multi_closeFCC release paths) shares this pattern; a bailout-safe release (deferring the FCC free until request shutdown, or wrapping it in a bailout-protected context) would likely cover the whole family at once.Happy to provide the reproducers if useful.
Girgias commentedon Nov 5, 2026
See #20001