[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