[php-src] Issue #23400: Possibly stale op_array extension cache slot on ZTS
[email protected] (ptondereau)
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <[email protected]> |
Issue: https://github.com/php/php-src/issues/23400
Author: ptondereau
### Description
I'm not that familiar with this part of the engine, so apologies if I'm misreading the intent of what I'm exposing here. If it's the intended behavior or wrong assumptions, feel free to close or correct me.
[`zend_get_op_array_extension_handle()`](https://github.com/php/php-src/blob/php-8.4.24/Zend/zend_extensions.c#L290) gives an extension a per-op_array cache slot, read/written through [`RUN_TIME_CACHE()` / `ZEND_OP_ARRAY_EXTENSION()`](https://github.com/php/php-src/blob/php-8.4.24/Zend/zend_compile.h#L827-L831), which resolve through `ZEND_MAP_PTR_GET()`. For an immutable (opcache-shared) op_array, that macro resolves an offset against `CG(map_ptr_base)`.
[`compiler_globals_ctor()`](https://github.com/php/php-src/blob/php-8.4.24/Zend/zend.c#L712-L753) allocates `CG(map_ptr_base)` once per OS thread; `compiler_globals_dtor()` frees it once per OS thread. Nothing resets it per request.
As far as I can tell, that works fine when a thread's lifetime and a "session" are the same thing.
I'm less sure it holds for a SAPI that reuses a ZTS thread across independent script executions without tearing the thread down in between, which is what FrankenPHP appears to do when a worker restarts on a reused thread.
If I'm following this correctly, an extension that caches something in this slot for an immutable `op_array`, then frees the backing storage once it believes its own request/session has ended, wouldn't have a way to also clear the slot it wrote on the `op_array`...
I couldn't find anything in the engine that would signal "a new session just started on this thread, that slot is stale," though I may well have missed it.
Minimal extension:
```c
// MINIT
handle = zend_get_op_array_extension_handle("repro");
// called from inside the target function via a hook
void **cache = &((void **) RUN_TIME_CACHE(op_array))[handle];
if (!*cache) {
*cache = zend_arena_calloc(&my_arena, 1, sizeof(my_data));
}
```
```php
<?php
// preload.php, loaded via opcache.preload so target_fn() is ZEND_ACC_IMMUTABLE
function target_fn() { repro_probe(); }
```
```php
<?php
// main.php
target_fn(); // populates the cache slot
repro_destroy_generation(); // zend_arena_destroy(my_arena); my_arena = NULL;
target_fn(); // same thread, same op_array, no recompile in between
```
Resulted in this output:
```
[cache MISS] allocated at 0x70041c673018, magic=0xaaaa
[cache HIT] slot has pointer 0x70041c673018, reading magic => 0xaaaa (still valid)
[generation end] destroying arena 0x70041c673000
[cache HIT] slot has pointer 0x70041c673018, reading magic => 0xaaaa (still valid)
```
The second `target_fn()` call still sees `*cache` as non-NULL and reads through it, even though the arena backing it was already destroyed.
It happens to print `0xaaaa` here only because `zend_arena_create()` got handed back the exact same address by the allocator and the pointer itself is already dangling.
But I expected this instead (and I maybe this is wrong):
```
[cache MISS] allocated at <new address>, magic=0xaaaa
```
i.e. the second call should not trust a cache slot populated before the backing arena was destroyed.
I couldn't find an API to invalidate an `op_array`'s extension slot, or any documentation of this lifetime boundary near `zend_get_op_array_extension_handle()`.
I'm filing this mainly because I ran into what looks like real corruption from it (stale reads at best, a use-after-free at worst) while debugging a FrankenPHP worker-mode memory issue in an extension.
For what it's worth, this doesn't seem to be a purely hypothetical SAPI behavior. FrankenPHP's [`RestartWorkers()`](https://github.com/php/frankenphp/blob/31223416ebb62d0a5708d41980a2a333106662e7/worker.go#L168-L178) calls [`(*phpThread).reboot()`](https://github.com/php/frankenphp/blob/31223416ebb62d0a5708d41980a2a333106662e7/phpthread.go#L81-L96), which reuses the same underlying C thread for the next worker generation rather than tearing it down.
`compiler_globals_ctor`/`_dtor` never run again for that thread, so `CG(map_ptr_base)` from the dead generation is exactly what the new generation inherits.
This might be the same family as a couple of issues I found while checking whether this was already reported:
- [GH-10473](https://github.com/php/php-src/issues/10473)
- [GH-23355](https://github.com/php/php-src/issues/23355)
### PHP Version
```plain
PHP 8.4.22 (cli) (built: Aug 21 2026 12:17:52) (ZTS DEBUG)
Copyright (c) The PHP Group
Zend Engine v4.4.22, Copyright (c) Zend Technologies
```
### Operating System
Linux (Debian-based container). Reproduced with plain PHP CLI and opcache enabled, no container-specific setup required.