Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ PHP NEWS

- Opcache:
. Re-enable JIT for ZTS builds on Apple Silicon. (realFlowControl)
. Fixed bug GH-22857 (Function JIT emits wrong code for FETCH_OBJ_FUNC_ARG
on a property hook, causing heap corruption or spurious ValueError /
TypeError under opcache.jit=1205). (coderzhao)

- PDO_ODBC:
. Fixed bug GH-22667 (Heap buffer over-read when a column value exceeds the
Expand Down
101 changes: 101 additions & 0 deletions ext/opcache/jit/zend_jit.c
Original file line number Diff line number Diff line change
Expand Up @@ -2447,6 +2447,106 @@ static int zend_jit(const zend_op_array *op_array, zend_ssa *ssa, const zend_op
goto jit_failure;
}
goto done;
case ZEND_FETCH_OBJ_FUNC_ARG:
{
ce = NULL;
ce_is_instanceof = false;
on_this = false;
if (opline->op1_type == IS_UNUSED) {
op1_info = MAY_BE_OBJECT|MAY_BE_RC1|MAY_BE_RCN;
op1_addr = 0;
ce = op_array->scope;
/* scope is NULL for closures. */
if (ce) {
ce_is_instanceof = !(ce->ce_flags & ZEND_ACC_FINAL);
}
on_this = true;
} else {
op1_info = OP1_INFO();
if (!(op1_info & MAY_BE_OBJECT)) {
break;
}
op1_addr = OP1_REG_ADDR();
if (ssa->var_info && ssa->ops) {
zend_ssa_op *ssa_op = &ssa->ops[opline - op_array->opcodes];
if (ssa_op->op1_use >= 0) {
zend_ssa_var_info *op1_ssa = ssa->var_info + ssa_op->op1_use;
if (op1_ssa->ce && !op1_ssa->ce->create_object) {
ce = op1_ssa->ce;
ce_is_instanceof = op1_ssa->is_instanceof;
}
}
}
}
if (opline->op2_type != IS_CONST
|| Z_TYPE_P(RT_CONSTANT(opline, opline->op2)) != IS_STRING
|| Z_STRVAL_P(RT_CONSTANT(opline, opline->op2))[0] == '\0') {
break;
}
Comment on lines +2450 to +2485

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be worth it to merge this case block with the one below to deduplicate this part


/* FETCH_OBJ_FUNC_ARG's by-value fetch dispatches into the
* FETCH_OBJ_R handler, which may take the SIMPLE_GET hook fast
* path and push a getter frame; by-ref dispatches into
* FETCH_OBJ_W. The function JIT may keep values solely in
* registers, so we must NOT exit to the VM (stale stack slots).
* Inline the by-value path through zend_jit_fetch_obj, which runs
* the hook getter inside a helper and keeps all registers live.
* The by-ref path has no SIMPLE_GET fast path, so the generic
* handler (a full C call, safe under register allocation) is used.
* This mirrors the tracing JIT fix for GH-21006 (GH-21369); the
* runtime by-ref check is required because the passing mode is
* only known once the callee is resolved via namespace fallback.
* See GH-22857. */
{
ir_ref rx, call_info, if_by_ref, end_by_ref;

/* Both runtime paths must observe a consistent frame
* state. The delayed call chain would otherwise only
* be flushed inside the by-ref branch (by
* zend_jit_set_ip() in zend_jit_handler()), leaving
* EX(call) stale on the by-val path and after the
* merge. Flush it before branching. */
if (jit->delayed_call_level) {
if (!zend_jit_save_call_chain(jit, jit->delayed_call_level)) {
goto jit_failure;
}
}

// JIT: if (ZEND_CALL_INFO(EX(call)) & ZEND_CALL_SEND_ARG_BY_REF)
if (jit->reuse_ip) {
rx = jit_IP(jit);
} else {
rx = ir_LOAD_A(jit_EX(call));
}
call_info = ir_LOAD_U32(jit_CALL(rx, This.u1.type_info));
if_by_ref = ir_IF(ir_AND_U32(call_info, ir_CONST_U32(ZEND_CALL_SEND_ARG_BY_REF)));

/* by-ref path: the FUNC_ARG handler re-checks the flag
* and dispatches into FETCH_OBJ_W */
ir_IF_TRUE_cold(if_by_ref);
if (!zend_jit_handler(&ctx, opline,
zend_may_throw(opline, ssa_op, op_array, ssa))) {
goto jit_failure;
}
end_by_ref = ir_END();

/* zend_jit_handler() stored IP = opline + 1 on the
* by-ref path only; that compile-time knowledge is
* invalid for the by-val path and after the merge. */
zend_jit_reset_last_valid_opline(jit);

/* by-val path */
ir_IF_FALSE(if_by_ref);
if (!zend_jit_fetch_obj(&ctx, opline, op_array, ssa, ssa_op,
op1_info, op1_addr, 0, ce, ce_is_instanceof, on_this, 0, 0, NULL,
RES_REG_ADDR(), IS_UNKNOWN,
zend_may_throw(opline, ssa_op, op_array, ssa))) {
goto jit_failure;
}
ir_MERGE_WITH(end_by_ref);
}
Comment on lines +2487 to +2547

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good, but please extract this part to a new function in zend_jit_ir.c so that we keep most IR-related code in that file.

goto done;
}
case ZEND_FETCH_OBJ_R:
case ZEND_FETCH_OBJ_IS:
case ZEND_FETCH_OBJ_W:
Expand Down Expand Up @@ -2854,6 +2954,7 @@ static int zend_jit(const zend_op_array *op_array, zend_ssa *ssa, const zend_op
call_level--;
}
break;
case ZEND_FETCH_OBJ_FUNC_ARG:
case ZEND_FETCH_OBJ_R:
if (!zend_jit_handler(&ctx, opline,
zend_may_throw(opline, ssa_op, op_array, ssa))) {
Expand Down
125 changes: 125 additions & 0 deletions ext/opcache/tests/jit/gh22857.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
--TEST--
GH-22857: Function JIT emits wrong code for FETCH_OBJ_FUNC_ARG on a property hook (SIMPLE_GET fast path)
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.jit_buffer_size=64M
opcache.jit=1205
opcache.jit_hot_func=1
--EXTENSIONS--
opcache
--FILE--
<?php
namespace Test;

interface HandlerInterface { public function noop(): void; }

final class DefaultHandler implements HandlerInterface {
private static ?self $i = null;
public static function getInstance(): self { return self::$i ??= new self(); }
public function noop(): void {}
}

/* Original issue: virtual property hook read via FETCH_OBJ_FUNC_ARG under
* function JIT. The getter frame is pushed by the SIMPLE_GET fast path in the
* shared FETCH_OBJ_R handler, but the JIT-compiled FUNC_ARG opcode had no
* hook-enter guard, so the argument slot was read before the getter ran.
*
* The assertion is deterministic: the getter sets a static flag as a side
* effect, so we check whether the getter actually ran when the property is
* passed through FETCH_OBJ_FUNC_ARG. This avoids the previous data-dependent
* failure mode (relying on file_get_contents() fataling on whatever garbage
* the unguarded JIT read happened to land on), which made the regression
* test flaky. */
class Container {
private static bool $getterRan = false;

public protected(set) HandlerInterface $handler;

public string $path {
get => (self::$getterRan = true)
? self::build($this->kind, $this->id)
: self::build($this->kind, $this->id);
}

protected mixed $prev = null;

public function __construct(
public protected(set) string $kind,
public protected(set) string $id,
) {
$this->handler = DefaultHandler::getInstance();
}

public static function build(string $k, string $i): string {
return "/nonexistent/gh22857_{$k}_{$i}.dat";
}

public function step(): void {
/* Unqualified namespaced-fallback call (INIT_NS_FCALL_BY_NAME) keeps
* the FETCH_OBJ_FUNC_ARG opcode instead of letting the optimizer
* rewrite it to FETCH_OBJ_R. @ preserves the opcode shape that
* triggers the bug. */
@file_get_contents($this->path);
if (!self::$getterRan) {
throw new \RuntimeException('getter did not run via FUNC_ARG');
}
}
}

$c = new Container('alpha', 'beta');
$c->step();
$c->step();
$c->step();

/* Sibling-slot variant: a preceding plain FETCH_OBJ_R primes the
* SIMPLE_GET bit on the property cache slot; compact_literals shares the
* slot between FETCH_OBJ_R and FETCH_OBJ_FUNC_ARG for the same property,
* so the following FETCH_OBJ_FUNC_ARG consumes that bit and hits the
* SIMPLE_GET fast path. Without the hook-enter guard it passes whatever
* sits in an adjacent property slot instead of running the getter. The
* flag is reset after the priming FETCH_OBJ_R so the assertion reflects
* only whether the getter ran during the FETCH_OBJ_FUNC_ARG read. */
class Container2 {
private static bool $getterRan = false;

public protected(set) HandlerInterface $handler;

public string $path {
get => (self::$getterRan = true)
? self::build($this->kind, $this->id)
: self::build($this->kind, $this->id);
}

protected mixed $prev = null;

public function __construct(
public protected(set) string $kind,
public protected(set) string $id,
) {
$this->handler = DefaultHandler::getInstance();
}

public static function build(string $k, string $i): string {
return "/nonexistent/gh22857b_{$k}_{$i}.dat";
}

public function step(): void {
$this->prev = $this->path; // FETCH_OBJ_R primes SIMPLE_GET on shared slot
self::$getterRan = false; // reset; flag now reflects only the FUNC_ARG read below
@file_get_contents($this->path); // FETCH_OBJ_FUNC_ARG consumes the primed bit
if (!self::$getterRan) {
throw new \RuntimeException('sibling-slot variant: getter did not run via FUNC_ARG');
}
}
}

$c2 = new Container2('alpha', 'beta');
$c2->step();
$c2->step();
$c2->step();

echo "OK\n";
?>
--EXPECT--
OK
47 changes: 47 additions & 0 deletions ext/opcache/tests/jit/gh22857_002.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
--TEST--
GH-22857 (reg-alloc): FETCH_OBJ_FUNC_ARG hook read must not lose register-held vars
--ENV--
A=1
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.jit_buffer_size=64M
opcache.jit=1205
opcache.jit_hot_func=1
--FILE--
<?php

class C {
public $prop {
get { return 1; }
}
}

function f(int $a0, $obj) {
// $a lives only in a register: every use is in a supported opcode and in a
// non-entry basic block, so the register allocator never spills it to the
// VM stack. Exiting to the VM (the buggy hook-enter guard) would read a
// stale stack slot for $a.
$a = $a0;
$b = $a + 2;
$c = g($obj->prop, $a ? 1 : 2);
return $b + $c;
}

if (getenv('A')) {
function g($a, $b) {
var_dump($b);
return $a;
}
}

$c = new C();
var_dump(f(1, $c));
var_dump(f(1, $c));

?>
--EXPECT--
int(1)
int(4)
int(1)
int(4)
Loading