[php-src] Issue #22559: PHP 8.4–8.6 tracing-JIT miscompilation: `~` high bits leak into a masked OR stored to a typed `int`

[email protected] (millerphp) Thu, 2 Jul 2026 16:12:17 +0000
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/22559
Author: millerphp

### Description

## TL;DR

Under the tracing JIT (`opcache.jit=tracing`), a bit-masked expression that contains a bitwise-NOT (`~`) can store a value outside the mask into a typed `int` property. The interpreter and the function JIT (`opcache.jit=function`) both compute the correct, masked result - it is tracing-JIT-specific.

Concretely: an integer that is provably in `0..255` (every OR term is masked to a byte) comes out negative - the `~`'s upper 56 bits leak past a `& 0x80` mask and into the stored value.

## Code That Fails:

```php
<?php
final class Cpu
{
    /** @var int[] */
    public array $sz53 = [];
    public int $a = 0;
    public int $f = 0;

    public function __construct()
    {
        for ($i = 0; $i < 256; $i++) {
            $this->sz53[$i] = $i & 0xA8;
        }
    }

    public function add8(int $value, int $carry): void
    {
        $a = $this->a;
        $total = $a + $value + $carry;
        $result = $total & 0xFF;

        $this->f = $this->sz53[$result]
            | (($total & 0x100) ? 0x01 : 0)
            | (((($a & 0x0F) + ($value & 0x0F) + $carry) & 0x10) ? 0x10 : 0)
            | ((~($a ^ $value) & ($a ^ $result) & 0x80) ? 0x04 : 0); // <-- bit-7-masked term

        $this->a = $result;
    }
}

$c = new Cpu();
$leaks = 0;
$first = null;

for ($i = 0; $i < 30_000_000; $i++) {
    $c->a = $i & 0xFF;
    $c->add8(($i >> 8) & 0xFF, 0);
    if (($c->f & ~0xFF) !== 0) {          // any bit above the low byte set = leak
        $leaks++;
        if ($first === null) {
            $first = $c->f;
        }
    }
}

echo $leaks === 0
    ? "PASS: \$f stayed within 0..255\n"
    : "FAIL: \$f leaked high bits {$leaks} times; first = {$first}\n";

```

## Code Workaround

It is possible to work around this in PHP Source, as follows:

```php
<?php

final class Cpu
{
    /** @var int[] */
    public array $sz53 = [];
    public int $a = 0;
    public int $f = 0;

    public function __construct()
    {
        for ($i = 0; $i < 256; $i++) {
            $this->sz53[$i] = $i & 0xA8;
        }
    }

    public function add8(int $value, int $carry): void
    {
        $a = $this->a;
        $total = $a + $value + $carry;
        $result = $total & 0xFF;

        $this->f = $this->sz53[$result]
            | (($total & 0x100) ? 0x01 : 0)
            | (((($a & 0x0F) + ($value & 0x0F) + $carry) & 0x10) ? 0x10 : 0)
            | (((($a ^ $value) ^ 0x80) & ($a ^ $result) & 0x80) ? 0x04 : 0); // <-- bit-7-masked term

        $this->a = $result;
    }
}

$c = new Cpu();
$leaks = 0;
$first = null;

for ($i = 0; $i < 30_000_000; $i++) {
    $c->a = $i & 0xFF;
    $c->add8(($i >> 8) & 0xFF, 0);
    if (($c->f & ~0xFF) !== 0) {          // any bit above the low byte set = leak
        $leaks++;
        if ($first === null) {
            $first = $c->f;
        }
    }
}

echo $leaks === 0
    ? "PASS: \$f stayed within 0..255\n"
    : "FAIL: \$f leaked high bits {$leaks} times; first = {$first}\n";
```

That the one-line `~` → `^` rewrite fixes proves the problem: the defect is in codegen for `~` feeding a masked OR-expression**, not in the program's logic.

## The offending code

```php
final class Cpu
{
    /** @var int[] */ public array $sz53 = [];
    public int $a = 0;
    public int $f = 0;
    public function __construct() { for ($i = 0; $i < 256; $i++) $this->sz53[$i] = $i & 0xA8; }

    public function add8(int $value, int $carry): void
    {
        $a = $this->a;
        $total = $a + $value + $carry;
        $result = $total & 0xFF;

        $this->f = $this->sz53[$result]
            | (($total & 0x100) ? 0x01 : 0)
            | (((($a & 0x0F) + ($value & 0x0F) + $carry) & 0x10) ? 0x10 : 0)
            | ((~($a ^ $value) & ($a ^ $result) & 0x80) ? 0x04 : 0);   // <-- the term
        $this->a = $result;
    }
}
```

The driver calls `add8()` ~30M times with varied `$a`/`$value` (so a generic trace forms) and asserts `($this->f & ~0xFF) === 0` — i.e. `$f` never has bits above the low byte. That assertion holds in the interpreter and fails under the tracing JIT.

## Version coverage & relation to GH-22115

Confirmed on PHP 8.4, 8.5, and 8.6, and confirmed absent in 8.3 - so the boundary is the DynASM→IR JIT switch at 8.3→8.4. This is an IR-JIT bug. Built from `php/php-src` git and tested with opcache actually built and the JIT verified active (`opcache_get_status()['jit']['on'] === true`).

| PHP | JIT backend | interpreter | `jit=function` | `jit=tracing` |
|-----|-------------|-------------|----------------|---------------|
| 8.3 | DynASM | PASS | PASS | **PASS** |
| 8.4 | IR | PASS | PASS | **FAIL** |
| 8.5 | IR | PASS | PASS | **FAIL** |
| 8.6 | IR | PASS | PASS | **FAIL** |

## Why this must be a miscompile

Every operand of the OR is provably byte-sized:

- `$this->sz53[$result]`: table built as `$i & 0xA8`, so `0..0xA8`.
- `($total & 0x100) ? 0x01 : 0` : `0` or `0x01`.
- `(… & 0x10) ? 0x10 : 0` : `0` or `0x10`.
- `(~($a ^ $value) & ($a ^ $result) & 0x80) ? 0x04 : 0` : `0` or `0x04`.

The last term is where `~` lives. `~($a ^ $value)` is a full-width negative (e.g. `~0 == -1`), but it is immediately masked by `& 0x80`, so the ternary condition is only ever `0` or `0x80`, and the ternary itself yields only `0` or
`0x04`. Therefore `$this->f ∈ 0..0xBD`. The interpreter agrees. The tracing JIT stores a value with high bits set. the `~`'s upper bits bypass the `& 0x80` and the ternary and reach `$f`.

Rewriting only `~($a ^ $value)` to `(($a ^ $value) ^ 0x80)` — identical in bit 7, but with no full-width `~`: makes the JIT correct.

## What's required to trip it

Minimizing from the real code, all of these were necessary here; dropping any one made it stop reproducing:

1. a method on an object (a free function did not reproduce);
2. a read of an `array` property in the same expression;
3. assignment to a typed `int` property (`public int $f`);
4. roughly three ORed sub-terms - register pressure (two terms did not trip it);
5. varied inputs over a hot loop so a generic trace forms.

This points at register allocation / masking during trace codegen when a `~` result is one input to a wider OR that is narrowed and stored.

## How it was found in the wild

This came out of a cycle-exact ZX Spectrum Z80 emulator written in PHP. Because the emulator is fully deterministic (T-state counted, no wall-clock, no RNG), JIT and no-JIT **must** produce identical machine state. They did not: a real game's display state diverged only under JIT.

Bisection (per-frame state hashes → per-instruction trace of the first divergent frame) pinned it to a single `ADD A,A` instruction whose flags register `F` came out `0xFFFFFFFFFFFFFFFF` under JIT vs `0x9C` in the interpreter: the exact `add8()` overflow term above (`ADD A,A` makes `$a ^ $value == 0`, so `~0 == -1`).

## Full Trace From Emulator

Method: dump per-frame CPU+screen state hashes, JIT vs no-JIT, find first divergent frame; then dump a per-instruction trace of that frame and diff.

(1) FRAME-LEVEL: boot frames identical; first divergence at frame f159. Only BC and R (refresh) differ — R differing means a DIFFERENT NUMBER of instructions ran in that frame (a branch went the other way).

  NO-JIT  f158 pc=1600 af=005C bc=171B ... r=21 ... ram=44DD144E
  JIT     f158 pc=1600 af=005C bc=171B ... r=21 ... ram=44DD144E   <- identical
  NO-JIT  f159 pc=1600 af=005C bc=171A ... r=0B ... ram=95B898AC
  JIT     f159 pc=1600 af=005C bc=171D ... r=5B ... ram=BB22EB9E   <- diverged

(2) INSTRUCTION-LEVEL: trace of frame f159. Identical for 2062 instructions, then the state AFTER `ADD A,A` (opcode 0x87) at ROM address 0x0C2A differs. Register F (low byte of AF) is the miscompiled value.

Both, entering 0x0C2A (op=87 ADD A,A):  af=4C18   (A=0x4C, F=0x18)

  NO-JIT  pc=0C2B op=30 af=989C ...                 <- F = 0x9C  (correct)
  JIT     pc=0C2B op=30 af=98FFFFFFFFFFFFFFFF ...    <- F = 0xFFFFFFFFFFFFFFFF (!)

  ADD A,A: A = 0x4C + 0x4C = 0x98.  Flags should be S|H|F3|PV = 0x9C.
  
Under JIT, F became a full-width -1: the ~($a ^ $value) term (with $a ^ $value == 0, i.e. ~0 == -1) leaked its high bits into F despite the `& 0x80` mask. The following `JR NC` (op=30) then branched wrong -> the two runs diverge from here, and the game's screen/attribute setup is corrupted (colours collapse to black).

(3) AFTER THE FIX (replace ~($a^$value) with (($a^$value)^0x80) in add8()): JIT and no-JIT are BYTE-IDENTICAL over all 350 frames tested.
    
After the `~` → `^0x80` fix, JIT and no-JIT were byte-identical across all frames, and the Z80 conformance `prelim` suite passed under the JIT.

## Fix Provided In Associated PR

~ was the only bitwise operator the JIT never inlined (|, &, ^ all are). So it fell back to calling the ZEND_BW_NOT_SPEC VM helper. In a side trace, that helper's result was allocated to a stack slot that aliased the spilled, loop-carried CV, silently clobbering it.

The fix removes the helper path: emit ~x as the already-inlined x ^ -1 (ir_XOR_L(op1, -1)) for the LONG case, wired into the function-JIT dispatch, the tracing-JIT codegen, and the trace type-guard. No helper call → no temporary result slot → nothing to collide with the CV. It's ~55 lines across zend_jit_ir.c / zend_jit.c / zend_jit_trace.c, gated to definitely-LONG operands (any other type keeps the previous behaviour), and it's a small performance win on top of the correctness fix.

### PHP Version

```plain
PHP 8.5.0 (cli) (built: Nov 20 2025 10:49:10) (NTS x86_64-linux-musl-gcc)
Copyright (c) The PHP Group
Built by Beyond Code for php.new
Zend Engine v4.5.0, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.0, Copyright (c), by Zend Technologies

### php -i | grep -i jit
    with Zend OPcache v8.5.0, Copyright (c), by Zend Technologies
auto_globals_jit => On => On
PCRE JIT Support => enabled
PCRE JIT Target => x86 64bit (little endian + unaligned)
pcre.jit => Off => Off
Zend OPcache
JIT => Disabled
opcache.jit => disable => disable
opcache.jit_bisect_limit => 0 => 0
opcache.jit_blacklist_root_trace => 16 => 16
opcache.jit_blacklist_side_trace => 8 => 8
opcache.jit_buffer_size => 64M => 64M
```

### Operating System

Linux (Fedora 44), x86_64