[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_