[php-src] Issue #20818: Segfault in Tracing JIT with Object Reference

[email protected] (lasernoises)
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/20818
Author: lasernoises

### Description

With this PHP code I get a segfault when tracing JIT is enabled:

```php
<?php

function process($data) {
    foreach ($data as &$v) {}
}

$data = [
    (object) ["" => 1],
    (object) ["" => 1],
    (object) [],
];

for ($i = 0; $i < 200; $i += 1) {
    foreach ($data as $entry) {
        process($entry);
    }
}
```

Sometimes I had to run it twice or three times in a row to get it to happen.

After quite a few hours spent in gdb, I think I have a bit of an idea of what's going on. This is the first time I've looked at PHP internals, so it's possible I've misunderstood some of this.

The segfault happens in `zend_objects_store_del` called from generated code for the return of the `process` function.

```
#0  zend_objects_store_del (object=0x5555577c6b00) at /home/flo/Projects/php-src/Zend/zend_objects_API.c:178
#1  0x0000555515400e1a in TRACE-5$process$4 () at unknown:1
#2  0x0000555555e8d9cf in zend_execute (op_array=0xdb11980b754e6600, return_value=0x7fffffff7af0) at /home/flo/Projects/php-src/Zend/zend_vm_execute.h:121924
Backtrace stopped: frame did not save the PC
```

`zend_objects_store_del` accepts a pointer to a `zend_object`, but a pointer to a `zend_reference` is provided.

The opcode dump for the `process` function looks like this:

```
process:
     ; (lines=6, args=1, vars=2, tmps=1)
     ; (after optimizer)
     ; /home/flo/Projects/php-src/repro.php:3-5
0000 CV0($data) = RECV 1
0001 V2 = FE_RESET_RW CV0($data) 0004
0002 FE_FETCH_RW V2 CV1($v) 0004
0003 JMP 0002
0004 FE_FREE V2
0005 RETURN null
LIVE RANGES:
     2: 0002 - 0004 (loop)
```

Initially `$data` is provided as an object, but `FE_RESET_RW` turns it into a reference.

The problem then appears in trace 5:

```
---- TRACE 5 start (side trace 3/5) process() /home/flo/Projects/php-src/repro.php:4
0004 FE_FREE V2 ; op1(&object of class stdClass)
0005 RETURN null
---- TRACE 5 stop (return)
---- TRACE 5 TSSA start (side trace 3/5) process() /home/flo/Projects/php-src/repro.php:4
     ;#2.X2 [ref, rc1, rcn, any]
0004 FE_FREE #2.V2 [ref, rc1, rcn, any] ; op1(&object of class stdClass)
0005 RETURN null
---- TRACE 5 TSSA stop (return)
```

I appears to happen when compiling the `RETURN`.
This is the relevant stack where it gets generated:

```
#0  jit_ZVAL_DTOR (jit=0x7ffffffeaf20, ref=63, op_info=3221225728, opline=0x0) at ext/opcache/jit/zend_jit_ir.c:1808
#1  0x00005555559b1ea4 in jit_ZVAL_PTR_DTOR (jit=0x7ffffffeaf20, addr=20537, op_info=3221225728, gc=true, opline=0x0) at ext/opcache/jit/zend_jit_ir.c:1845
#2  0x00005555559d4cc4 in zend_jit_free_cv (jit=0x7ffffffeaf20, info=3221225728, var=0) at ext/opcache/jit/zend_jit_ir.c:11022
#3  0x0000555555a1e6b6 in zend_jit_trace (trace_buffer=0x7fffffff3620, parent_trace=3, exit_num=5) at ext/opcache/jit/zend_jit_trace.c:5625
#4  0x0000555555a30728 in zend_jit_compile_side_trace (trace_buffer=0x7fffffff3620, parent_num=3, exit_num=5, polymorphism=0) at ext/opcache/jit/zend_jit_trace.c:8371
#5  0x0000555555a31760 in zend_jit_trace_hot_side (execute_data=0x55555776a5e0, parent_num=3, exit_num=5) at ext/opcache/jit/zend_jit_trace.c:8616
#6  0x0000555555a32e3d in zend_jit_trace_exit (exit_num=5, regs=0x7fffffff7790) at ext/opcache/jit/zend_jit_trace.c:8894
#7  0x0000555515400569 in JIT$$trace_exit () at unknown:1
```

`jit_ZVAL_DTOR` uses `has_concrete_type` and `concrete_type` on `(op_info) & (MAY_BE_STRING|MAY_BE_ARRAY|MAY_BE_OBJECT|MAY_BE_RESOURCE)`.
This does not include `MAY_BE_REF`, which seemed to me to be the problem at one point.

Interestingly the `MAY_BE_REF` flag is not actually set there, so that can't be the main problem.

```
(gdb) print op_info & (1 << 10)
$2 = 0
(gdb) print op_info & (1 << 8)
$3 = 256
```

`op_info` gets generated earlier in `zend_jit_trace_build_tssa`.
In [`ext/opcache/jit/zend_jit_trace.c:1766`](https://github.com/php/php-src/blob/241e43f2d896fd4db0574a8980c1fc32e391599d/ext/opcache/jit/zend_jit_trace.c#L1766) the type gets set based on the parent trace, which in this case is trace 3.

```
---- TRACE 3 start (loop) $main() /home/flo/Projects/php-src/repro.php:14
0010 FE_FETCH_R V3 CV2($entry) 0015 ; op1(packed array) op2(object of class stdClass)
0011 INIT_FCALL 1 128 string("process")
     >init process
0012 SEND_VAR CV2($entry) 1 ; op1(object of class stdClass)
0013 DO_UCALL
     >enter process
0001  V2 = FE_RESET_RW CV0($data) 0004 ; op1(object of class stdClass)
---- TRACE 3 stop (link to 1)
```

I'm not sure if the type inferred in trace 3 is wrong since it only becomes a reference in `FE_RESET_RW`.
It's also possible that there is missing handling for `FE_RESET_RW` in the switch [here](https://github.com/php/php-src/blob/241e43f2d896fd4db0574a8980c1fc32e391599d/ext/opcache/jit/zend_jit_trace.c#L1850).
Maybe it should set the `MAY_BE_REF` flag on there when processing trace 3?

I also haven't figured out yet what the difference between `type` and `mem_type` is.
That might also be relevant.

I hope some of this helpful in figuring out the actual cause.

### Configuration

I used variations of this `php.ini`:

```
opcache.enable=true
opcache.enable_cli=true
# CRTO. Happens with any setting here where T = 5 (tracing) and O >= 2. 
opcache.jit=0052
opcache.jit_buffer_size=1024M
opcache.jit_debug=4294967295
opcache.opt_debug_level=0x20000
```

I was able to reproduce this problem with the following versions (all on x86_64 Linux):

- The latest master built from source
- PHP 8.4.15 on NixOS
- PHP 8.3.28 from the Remi RPM Repository on AlmaLinux 9
- PHP 8.3.28 built from source
- PHP 8.2.29 on NixOS
- PHP 8.1.32 on NixOS
- PHP 8.0.29 on NixOS

Since I could reproduce it in all those cases I didn't include the full `--version` output for all of them.

### Security Considerations

As far as I can tell this is not exploitable.
On my machine the offset of `handlers` on `zend_object` corresponds to `sources` on `zend_reference`, which seems to always be null in this case.

If not the `dtor` field on `zend_object_handlers` corresponds to `doc_comment` on `zend_property_info` which points to a `zend_string`, which shouldn't point to executable code and should thus also segfault.

On 32 bit systems `handlers` should correspond to `u2` on the `zval` of the reference (I hope my offset math is right) and I don't see any possible way an attacker could control that to become a valid pointer.

### PHP Version

```plain
PHP 8.4.15 (cli) (built: Nov 18 2025 17:26:05) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.4.15, Copyright (c) Zend Technologies
    with Zend OPcache v8.4.15, Copyright (c), by Zend Technologies
```

### Operating System

_No response_
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.