PHP 8.4–8.6 tracing-JIT miscompilation: `~` high bits leak into a masked OR stored to a typed `int`
Chưa có ai nhận issue này.
- Ngôn ngữ chính
- C
- Star
- 40.4k
- Fork
- 8.2k
- Merge trung bình
- 2 ngày 13 giờ
- Pull request đã merge (30 ngày)
- 96
Mô tả
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
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
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
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, so0..0xA8.($total & 0x100) ? 0x01 : 0:0or0x01.(… & 0x10) ? 0x10 : 0:0or0x10.(~($a ^ $value) & ($a ^ $result) & 0x80) ? 0x04 : 0:0or0x04.
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:
- a method on an object (a free function did not reproduce);
- a read of an
arrayproperty in the same expression; - assignment to a typed
intproperty (public int $f); - roughly three ORed sub-terms - register pressure (two terms did not trip it);
- 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
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
Hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Hướng nghiên cứu
Bắt đầu với trình tái hiện tối thiểu trong issue và so sánh kết quả của interpreter, function-JIT và tracing-JIT. Đọc cách xử lý ~ trong zend_jit_ir.c, zend_jit.c và zend_jit_trace.c; công việc được xem là hoàn tất khi tracing JIT không còn làm rò rỉ các bit cao và khớp với interpreter trên vòng lặp được cung cấp cũng như các kiểm tra của emulator.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- c, php
- Lĩnh vực
- compilers, performance
- Loại issue
- Lỗi
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức độ hoạt động
- Ít trao đổi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức phù hợp với người mới
- 48/100