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:

<?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

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