Skip to content

User opcode handlers corrupt VM state under PHP 8.6's tail-call VM (macOS arm64) #280

Description

@lisachenko

Summary

On macOS arm64 with PHP 8.6 (Homebrew/setup-php build), installing a user opcode handler via OpCode::setHandler() on EXT_STMT (with Compiler::COMPILE_EXTENDED_STMT enabled) corrupts VM state as soon as the handler's PHP closure runs: closure bound variables read back as garbage (e.g. an int counter reads as a ZEngine\Core object), and full consumers (lisachenko/zdebug) segfault (EXC_BAD_ACCESS at 0x10 inside ZEND_DO_UCALL_SPEC_RETVAL_USED_TAILCALL_HANDLER).

The same code is green on:

  • macOS arm64 + PHP 8.5 (identical probe run as control — all steps pass)
  • macOS x64 (Intel) + PHP 8.6 — same PHP source build
  • Linux x64 + PHP 8.6

z-engine's own CI is green on macOS arm64 + 8.6 because OpCodeHookTest hooks ADD inside a small probe function without COMPILE_EXTENDED_STMT; the corruption shows once EXT_STMT-instrumented code (compiled after the handler is installed) starts dispatching through the hook.

Why only arm64 + 8.6

PHP 8.6 introduced a new VM dispatch kind, ZEND_VM_KIND_TAILCALL (Zend/zend_vm_opcodes.h in php-8.6.0beta2):

#elif (defined(__GNUC__) && defined(HAVE_GCC_GLOBAL_REGS))
# define ZEND_VM_KIND  ZEND_VM_KIND_HYBRID
#elif defined(HAVE_MUSTTAIL) && defined(HAVE_PRESERVE_NONE) && (defined(__x86_64__) || defined(_M_X64) || defined(__aarch64__)) && defined(__clang__)
# define ZEND_VM_KIND  ZEND_VM_KIND_TAILCALL
#endif
  • Linux (gcc) and macOS x64 (clang with global register support) build the HYBRID VM — unaffected.
  • macOS arm64 clang has no HAVE_GCC_GLOBAL_REGS, so the build selects TAILCALL: opcode handlers become const zend_op *(*)(zend_execute_data *, const zend_op *) with the preserve_none calling convention, chained via musttail. The crash frames (*_TAILCALL_HANDLER) confirm the failing build runs this VM.

zend_user_opcode_handlers[opline->opcode](execute_data) is still called with the classic int (*)(zend_execute_data *) signature from ZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER, so the trampoline installation itself works (a trivial handler on ADD fires fine). The breakage appears when the handler re-enters PHP execution (the FFI callback runs a userland closure) while the interrupted frame's opcode came from EXT_STMT-instrumented code — pointing at an interaction between libffi callback re-entry, nested execute_ex, and the preserve_none handler chain. It may ultimately be a php-src beta bug rather than z-engine's, but z-engine is where consumers hit it, and where a repro/workaround can live.

Minimal repro (pure z-engine, no consumer code)

probe.php:

<?php
require __DIR__ . '/vendor/autoload.php';

use ZEngine\Core;
use ZEngine\System\Compiler;
use ZEngine\System\OpCode;

Core::init();
Core::$compiler->setOptions(Core::$compiler->getOptions() | Compiler::COMPILE_EXTENDED_STMT);

$fires = 0;
OpCode::setHandler(OpCode::EXT_STMT, function ($scope) use (&$fires): int {
    $fires++;
    return Core::ZEND_USER_OPCODE_DISPATCH;
});

require __DIR__ . '/payload.php'; // any file: a class, new, a method call, a function call
echo "EXT_STMT fires: {$fires}\n";

Run with php -d ffi.enable=1 -d opcache.jit=off probe.php on macOS arm64 + PHP 8.6.0-dev:

PHP Warning:  Uncaught TypeError: Cannot increment ZEngine\Core in probe.php:12
PHP Fatal error:  Throwing from FFI callbacks is not allowed in payload.php on line 15

The by-ref use (&$fires) int reads back as a ZEngine\Core object on the very first handler invocation. Adding RETURN/THROW handlers fails the same way (Cannot use object of type ZEngine\Core as array on an array counter). On PHP 8.5 (same machine, same script) everything passes.

Crash signature from a full consumer (zdebug)

exception: EXC_BAD_ACCESS / SIGSEGV, KERN_INVALID_ADDRESS at 0x0000000000000010
frame 0: ZEND_DO_UCALL_SPEC_RETVAL_USED_TAILCALL_HANDLER + 116
frame 1: execute_ex + 156
frame 2: zend_execute.cold.1 / zend_execute / zend_execute_script / php_execute_script_ex

(reading offset 0x10 of a NULL zend_function, i.e. a call frame whose func was clobbered). Downstream CI evidence: lisachenko/zdebug#24 — the diagnostic job Diagnose arm64 (PHP 8.5/8.6) in run https://github.com/lisachenko/zdebug/actions/runs/33254290174 shows the layered probes (L1 Core::init ✅, L2 COMPILE_EXTENDED_STMT ✅, L3 EXT_STMT handler ❌ on 8.6 / ✅ on 8.5, L5 module registration ✅, L6 full boot ❌ SIGSEGV) plus the lldb backtrace and the macOS .ips crash report.

Suggested directions

  • Reproduce with a C user opcode handler (no FFI) to split "TAILCALL VM vs user opcode handlers re-entering the VM" (a php-src bug worth reporting upstream while 8.6 is in beta) from "TAILCALL VM vs FFI callback trampolines" (z-engine-side).
  • If it is php-src's: report before 8.6.0 GA; the tail-call VM is new in this cycle.
  • Until resolved, consumers on Apple Silicon can be pointed at a HYBRID/CALL-VM PHP build; z-engine could also detect ZEND_VM_KIND_TAILCALL at Core::init() (e.g. via php -i/Reflection on the build metadata or a runtime probe) and fail fast with a clear message instead of corrupting the debuggee.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions