[php-src] Issue #22857: Function JIT emits wrong code for `FETCH_OBJ_FUNC_ARG` on a virtual property hook when calling an unqualified namespaced-fallback function

[email protected] (zhaohao19941221) Wed, 22 Jul 2026 11:28:01 +0000
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/22857
Author: 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 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
<?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

```console
# 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](https://github.com/user-attachments/files/30266371/min_D.php)

### PHP Version

```plain
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_