Skip to content

Function JIT emits wrong code for FETCH_OBJ_FUNC_ARG on a virtual property hook when calling an unqualified namespaced-fallback function #22857

Description

@zhaohao19941221

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:

  1. ValueError: file_get_contents(): Argument #1 ($filename) must not contain any null bytes
    (deterministic — 20/20 in the reproducer below).
  2. 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).
  3. zend_mm_heap corruptedSIGABRT (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:

<?phpnamespaceApp\ConfigCenter;
interface HandlerInterface { publicfunctionnoop(): void; }
finalclass DefaultHandler implements HandlerInterface {
privatestatic ?self$i = null;
publicstaticfunctiongetInstance(): self { returnself::$i ??= newself(); }
publicfunctionnoop(): void {}
}
class Container {
publicprotected(set)HandlerInterface$handler;
// Virtual property hook — no backing storage.publicstring$path { get => self::build($this->kind, $this->id); }
protectedmixed$prev = null;
publicfunction__construct(
public protected(set) string$kind,
public protected(set) string$id,
) {
$this->handler = DefaultHandler::getInstance();
}
publicstaticfunctionbuild(string$k, string$i): string {
return"/tmp/nonexistent_{$k}_{$i}.dat";
}
publicfunctionstep(): void {
@file_get_contents($this->path); // ← triggers the bug
}
}
$c = newContainer('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.phpDONE: 5000 iterations, throwable errors=0
# BUG: functionJIT — 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:33DONE: 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.phpDONE: 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.

#ConditionVerified by
1opcache.jit=1205 (function JIT)disable and tracing are 20/20 OK.
2Class is inside a namespaceRemoving namespace App\ConfigCenter; yields OK.
3The 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.
4The argument is a virtual property hook (get-only, no backing storage)Replacing with a regular public string $path; initialised in the ctor yields OK.
5The 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.
7Class 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 parametersSimplifying the layout (e.g. removing $handler, $prev, or the second promoted string) yields OK.
8Hook'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

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