php / php/php-src

PHP 8.4–8.6 tracing-JIT miscompilation: `~` high bits leak into a masked OR stored to a typed `int`

Ouverte
#22,559 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub

Personne n'a encore pris cette issue.

Bug Category: JIT Extension: opcache
Langage dominant
C
Étoiles
40.4k
Forks
8.1k
Merge moyen
2 j 13 h
PR mergées (30 j)
96

Description

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, 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
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

Guide de contribution

Ouvrir le guide de contribution

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Piste de recherche

Commencez par le reproducteur minimal de l’issue et comparez les résultats de l’interpréteur, de function-JIT et de tracing-JIT. Lisez la gestion de ~ dans zend_jit_ir.c, zend_jit.c et zend_jit_trace.c ; le travail est terminé lorsque tracing JIT ne laisse plus fuir les bits de poids fort et correspond à l’interpréteur pour la boucle fournie et les vérifications de l’émulateur.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
c, php
Domaine
compilers, performance
Type d'issue
Bug
Difficulté
4/5
Temps estimé
3-5 jours
Activité
Calme
Clarté
Clairement spécifiée
Accessibilité débutants
48/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.