Skip to content

Assertion failure in zend_objects_store_del() when a bailout interrupts zend_fcc_dtor() in header_register_callback() #24731

Closed as duplicate

Description

@IzaiahSun

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:

  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:

/* 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):

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

  1. added 2 commits that reference this issue on Oct 26, 2026
    207dcb9
    507a487
  2. windshock commented on Nov 4, 2026

    @windshock

    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 while zend_fcc_dtor() is releasing the stored FCC): 3/3 runs under ASan with USE_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 later zend_objects_store_del() hits an inconsistent refcount. Measured: GH-24763 heap-use-after-free 3/3 on 8.4.27 (via libxml_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 potentially curl_close/curl_multi_close FCC 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.

  3. Girgias commented on Nov 5, 2026

    @Girgias
    Member

    See #20001

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions