Description
[Bug] Function JIT: virtual property hook read as argument to unqualified namespaced-fallback function call returns garbage / corrupts heap
Version: PHP 8.4.23 (NTS, Alpine, docker)
OS: Alpine Linux inside a container (Zend OPcache v8.4.23)
Package: OPcache / JIT (function mode)
Description
Under function JIT (opcache.jit=1205), reading a virtual
property hook as the first argument of an unqualified call to a
global builtin from inside a namespace (opcode
INIT_NS_FCALL_BY_NAME + FETCH_OBJ_FUNC_ARG) causes the callee to
receive a value taken from some other memory — not the value produced
by the hook's get => expression.
Depending on which memory is aliased, the symptom is one of:
ValueError: file_get_contents(): Argument #1 ($filename) must not contain any null bytes
(deterministic — 20/20 in the reproducer below).
TypeError: ...(): Argument #N must be of type ?string, <adjacent object class> given
(observed in the real-world code that led to this report, where the neighbouring object slot happens to contain a Psr\Log\LoggerInterface).
zend_mm_heap corrupted → SIGABRT (heap metadata destroyed;
also observed in the real-world code).
The bug only affects function JIT (opcache.jit=1205). Both
opcache.jit=disable and opcache.jit=tracing (1254) are unaffected.
Reproducer (30 lines, no dependencies)
repro.php:
<?php
namespace App\ConfigCenter;
interface HandlerInterface { public function noop(): void; }
final class DefaultHandler implements HandlerInterface {
private static ?self $i = null;
public static function getInstance(): self { return self::$i ??= new self(); }
public function noop(): void {}
}
class Container {
public protected(set) HandlerInterface $handler;
// Virtual property hook — no backing storage.
public string $path { get => self::build($this->kind, $this->id); }
protected mixed $prev = null;
public function __construct(
public protected(set) string $kind,
public protected(set) string $id,
) {
$this->handler = DefaultHandler::getInstance();
}
public static function build(string $k, string $i): string {
return "/tmp/nonexistent_{$k}_{$i}.dat";
}
public function step(): void {
@file_get_contents($this->path); // ← triggers the bug
}
}
$c = new Container('alpha', 'beta');
$errors = 0; $first = null;
for ($i = 0; $i < 5000; $i++) {
try { $c->step(); }
catch (\Throwable $e) {
$errors++;
if ($first === null) {
$first = sprintf("[iter=%d] %s: %s at %s:%d\n",
$i, $e::class, $e->getMessage(), $e->getFile(), $e->getLine());
}
}
}
if ($first !== null) fwrite(STDERR, $first);
echo "DONE: 5000 iterations, throwable errors=$errors\n";
Actual vs expected
# CONTROL: JIT off — expected & actual: no errors
$ php -d opcache.enable_cli=1 -d opcache.jit=disable repro.php
DONE: 5000 iterations, throwable errors=0
# BUG: function JIT — throws on the very first iteration
$ php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M \
-d opcache.jit=1205 repro.php
[iter=1] ValueError: file_get_contents(): Argument #1 ($filename) must not contain any null bytes at repro.php:33
DONE: 5000 iterations, throwable errors=1
# CONTROL: tracing JIT — unaffected
$ php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M \
-d opcache.jit=tracing -d opcache.jit_hot_func=1 -d opcache.jit_hot_loop=1 \
repro.php
DONE: 5000 iterations, throwable errors=0
The error appears on the very first iteration — the loop only exists
so the failure is easy to count. A single $c->step() also throws.
Reproduction rate (20 runs per configuration)
jit=disable ok=20/20 trigger= 0/20 crash= 0/20
jit=1205 ok= 0/20 trigger=20/20 crash= 0/20 ← BUG
jit=tracing ok=20/20 trigger= 0/20 crash= 0/20
Necessary conditions
Established by systematic delta debugging (each variant tested 20 runs).
Removing any one of these makes the bug disappear (20/20 OK).
With all present, the bug is 20/20 triggered.
| # |
Condition |
Verified by |
| 1 |
opcache.jit=1205 (function JIT) |
disable and tracing are 20/20 OK. |
| 2 |
Class is inside a namespace |
Removing namespace App\ConfigCenter; yields OK. |
| 3 |
The function call is unqualified (no leading \, no use function) → emitted as INIT_NS_FCALL_BY_NAME "App\\ConfigCenter\\file_get_contents" |
\file_get_contents(...) or use function file_get_contents; yields OK. |
| 4 |
The argument is a virtual property hook (get-only, no backing storage) |
Replacing with a regular public string $path; initialised in the ctor yields OK. |
| 5 |
The hook is passed directly (opcode FETCH_OBJ_FUNC_ARG) |
$p = $this->path; @file_get_contents($p); (i.e. FETCH_OBJ_R) yields OK. |
| 6 |
@ error-suppression around the call (BEGIN_SILENCE/END_SILENCE) |
Removing @ yields OK in a single-call test. |
| 7 |
Class layout: a public protected(set) X $handler field before the hook, protected mixed $prev = null after it, and two promoted public protected(set) string $kind, $id constructor parameters |
Simplifying the layout (e.g. removing $handler, $prev, or the second promoted string) yields OK. |
| 8 |
Hook's get => expression calls a static method reading the promoted asymmetric properties (self::build($this->kind, $this->id)) |
Inlining a literal or "..._{$this->kind}_..." directly in get => yields OK. |
Opcode dump of the failing site
Under -d opcache.jit_debug=0x1FF:
0027 INIT_NS_FCALL_BY_NAME 1 string("App\\ConfigCenter\\file_get_contents")
0028 CHECK_FUNC_ARG 1
0029 #28.V3 [ind, ref, rc1, rcn, any] = FETCH_OBJ_FUNC_ARG (ref) THIS string("path")
0030 SEND_FUNC_ARG #28.V3 [ind, ref, rc1, rcn, any] 1
0031 #29.V3 [ref, rc1, rcn, any] = DO_FCALL_BY_NAME
The (ref) marker on FETCH_OBJ_FUNC_ARG reflects that the target
function's arginfo is unknown at compile time (delayed until
DO_FCALL_BY_NAME because namespace fallback resolution has to happen
at runtime). It appears the function-JIT fast path for this opcode
does not check whether the cached property offset is a hooked-property
sentinel (ZEND_HOOKED_PROPERTY_OFFSET, a small integer < ZEND_FIRST_PROPERTY_OFFSET)
before dereferencing obj + offset. Instead of dispatching to the hook
getter, the JIT-emitted code reads a few bytes of an adjacent property
slot — hence NUL-poisoned strings, "adjacent object" TypeErrors, or
heap corruption depending on what happens to live there.
The FETCH_OBJ_R fast path (used e.g. via a local-variable rebind)
apparently does handle the hooked sentinel correctly, which is why
condition #5 above hides the bug.
Workarounds (all zero performance cost)
Any of the following at the call site fixes the crash without
disabling JIT:
- Add a leading backslash:
@\file_get_contents($this->path);
- Add
use function file_get_contents; to the file and keep the call unchanged.
- Assign the hook to a local variable first:
$p = $this->path; @file_get_contents($p);
- Convert
$path from a virtual property hook to an ordinary getter
method (public function getPath(): string { ... }).
opcache.jit=disable or opcache.jit=tracing.
Additional notes
- The bug does not require Swoole. The reproducer above is pure CLI.
- No composer / autoloader / warmup is needed — the error appears on
iteration 1 of a single-instance loop, so long-running heap
corruption / GC pressure is not the cause.
opcache.file_cache and opcache.preload are both unset in my env.
- This was originally discovered in a Swoole/Hyperf application where
the neighbouring slot contained a Psr\Log\LoggerInterface, producing
the "adjacent object" TypeError symptom, and eventually corrupting
allocator metadata (zend_mm_heap corrupted + SIGABRT).
- Please let me know if you'd like the
opcache.jit_debug=0x1FF output
around ActConfig::saveConfig and ActConfig::$filename::get — I've
kept it and can attach.
Full reproducer: https://gist.github.com/zhaohao19941221/c1a0d3e0ccb1be3218c9ed6d4807b4f5
min_D.php
PHP Version
PHP 8.4.23 (cli) (built: Jul 3 2026 15:02:10) (NTS)
Copyright (c) The PHP Group
Built by Alpine Linux aports
Zend Engine v4.4.23, Copyright (c) Zend Technologies
with Zend OPcache v8.4.23, Copyright (c), by Zend Technologies
Operating System
No response
Description
[Bug] Function JIT: virtual property hook read as argument to unqualified namespaced-fallback function call returns garbage / corrupts heap
Version: PHP 8.4.23 (NTS, Alpine, docker)
OS: Alpine Linux inside a container (
Zend OPcache v8.4.23)Package: OPcache / JIT (function mode)
Description
Under function JIT (
opcache.jit=1205), reading a virtualproperty hook as the first argument of an unqualified call to a
global builtin from inside a namespace (opcode
INIT_NS_FCALL_BY_NAME+FETCH_OBJ_FUNC_ARG) causes the callee toreceive a value taken from some other memory — not the value produced
by the hook's
get =>expression.Depending on which memory is aliased, the symptom is one of:
ValueError: file_get_contents(): Argument #1 ($filename) must not contain any null bytes(deterministic — 20/20 in the reproducer below).
TypeError: ...(): Argument #N must be of type ?string, <adjacent object class> given(observed in the real-world code that led to this report, where the neighbouring object slot happens to contain a
Psr\Log\LoggerInterface).zend_mm_heap corrupted→SIGABRT(heap metadata destroyed;also observed in the real-world code).
The bug only affects function JIT (
opcache.jit=1205). Bothopcache.jit=disableandopcache.jit=tracing(1254) are unaffected.Reproducer (30 lines, no dependencies)
repro.php:Actual vs expected
The error appears on the very first iteration — the loop only exists
so the failure is easy to count. A single
$c->step()also throws.Reproduction rate (20 runs per configuration)
Necessary conditions
Established by systematic delta debugging (each variant tested 20 runs).
Removing any one of these makes the bug disappear (20/20 OK).
With all present, the bug is 20/20 triggered.
opcache.jit=1205(function JIT)disableandtracingare 20/20 OK.namespacenamespace App\ConfigCenter;yields OK.\, nouse function) → emitted asINIT_NS_FCALL_BY_NAME "App\\ConfigCenter\\file_get_contents"\file_get_contents(...)oruse function file_get_contents;yields OK.public string $path;initialised in the ctor yields OK.FETCH_OBJ_FUNC_ARG)$p = $this->path; @file_get_contents($p);(i.e.FETCH_OBJ_R) yields OK.@error-suppression around the call (BEGIN_SILENCE/END_SILENCE)@yields OK in a single-call test.public protected(set) X $handlerfield before the hook,protected mixed $prev = nullafter it, and two promotedpublic protected(set) string $kind, $idconstructor parameters$handler,$prev, or the second promoted string) yields OK.get =>expression calls a static method reading the promoted asymmetric properties (self::build($this->kind, $this->id))"..._{$this->kind}_..."directly inget =>yields OK.Opcode dump of the failing site
Under
-d opcache.jit_debug=0x1FF:The
(ref)marker onFETCH_OBJ_FUNC_ARGreflects that the targetfunction's arginfo is unknown at compile time (delayed until
DO_FCALL_BY_NAMEbecause namespace fallback resolution has to happenat runtime). It appears the function-JIT fast path for this opcode
does not check whether the cached property offset is a hooked-property
sentinel (
ZEND_HOOKED_PROPERTY_OFFSET, a small integer< ZEND_FIRST_PROPERTY_OFFSET)before dereferencing
obj + offset. Instead of dispatching to the hookgetter, the JIT-emitted code reads a few bytes of an adjacent property
slot — hence NUL-poisoned strings, "adjacent object" TypeErrors, or
heap corruption depending on what happens to live there.
The
FETCH_OBJ_Rfast path (used e.g. via a local-variable rebind)apparently does handle the hooked sentinel correctly, which is why
condition #5 above hides the bug.
Workarounds (all zero performance cost)
Any of the following at the call site fixes the crash without
disabling JIT:
@\file_get_contents($this->path);use function file_get_contents;to the file and keep the call unchanged.$p = $this->path; @file_get_contents($p);$pathfrom a virtual property hook to an ordinary gettermethod (
public function getPath(): string { ... }).opcache.jit=disableoropcache.jit=tracing.Additional notes
iteration 1 of a single-instance loop, so long-running heap
corruption / GC pressure is not the cause.
opcache.file_cacheandopcache.preloadare both unset in my env.the neighbouring slot contained a
Psr\Log\LoggerInterface, producingthe "adjacent object" TypeError symptom, and eventually corrupting
allocator metadata (
zend_mm_heap corrupted+SIGABRT).opcache.jit_debug=0x1FFoutputaround
ActConfig::saveConfigandActConfig::$filename::get— I'vekept it and can attach.
Full reproducer: https://gist.github.com/zhaohao19941221/c1a0d3e0ccb1be3218c9ed6d4807b4f5
min_D.php
PHP Version
Operating System
No response