[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_