[php-src] Issue #24731: Assertion failure in zend_objects_store_del() when a bailout interrupts zend_fcc_dtor() in header_register_callback()

From: 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)

« previous php.bugs (#253209) next »