Skip to content

JIT heap-use-after-free in zend_jit_rope_end with --repeat #21419

@EdmondDantes

Description

@EdmondDantes

Description

The following code:

<?php
// file: test_jit_repeat_uaf.php
$php = getenv('TEST_PHP_EXECUTABLE') ?: PHP_BINARY;
$output = [];
exec($php . ' -r "echo \"a\\tb\\tc\\n\";"', $output, $rc);
echo "Embedded tabs: \"$output[0]\"\n";
echo "Embedded tabs len: " . strlen($output[0]) . "\n";

Run with:

USE_ZEND_ALLOC=0 ASAN_OPTIONS=detect_leaks=0 \
php --repeat 2 \
  -d opcache.enable_cli=1 \
  -d opcache.jit_buffer_size=64M \
  -d opcache.jit=tracing \
  -d opcache.protect_memory=1 \
  -d opcache.jit_hot_func=1 \
  -d opcache.jit_hot_loop=1 \
  -d opcache.jit_hot_return=1 \
  -d opcache.jit_hot_side_exit=1 \
  -f test_jit_repeat_uaf.php

Resulted in this output:

Executing for the first time...
Embedded tabs: "a	b	c"
Embedded tabs len: 5
Finished execution, repeating...
=================================================================
==232189==ERROR: AddressSanitizer: heap-use-after-free on address 0x50300005d194 at pc 0x55d3d159ea26
READ of size 4 at 0x50300005d194 thread T0
    #0 zend_jit_rope_end         ext/opcache/jit/zend_jit_helpers.c:3450
    #1 <JIT-compiled code>       (/dev/zero (deleted))
    #2 zend_execute              Zend/zend_vm_execute.h:115483
    #3 zend_execute_script       Zend/zend.c:1983
    #4 php_execute_script_ex     main/main.c:2665
    #5 do_cli                    sapi/cli/php_cli.c:958
    #6 main                      sapi/cli/php_cli.c:1369

0x50300005d194 is located 4 bytes inside of 32-byte region [0x50300005d190,0x50300005d1b0)
freed by thread T0 here: <trace unavailable due to ASAN check failure>

But I expected this output instead:

Executing for the first time...
Embedded tabs: "a	b	c"
Embedded tabs len: 5
Finished execution, repeating...
Embedded tabs: "a	b	c"
Embedded tabs len: 5

Analysis

  • zend_jit_rope_end() iterates over rope segments and reads ZSTR_GET_COPYABLE_CONCAT_PROPERTIES(rope[i]) (line 3450). On the second --repeat iteration, one of the rope segments ($output[0] from the string interpolation "...$output[0]...") points to a zend_string that was freed during php_request_shutdown() after the first iteration.
  • JIT compiles the ROPE_END opcode during the first iteration (with jit_hot_func=1, the threshold is 1 call). On the second iteration, the JIT-compiled code reuses stale pointers from the previous request's heap.
  • The bug does not reproduce without JIT (opcache.jit=0).
  • The bug does not reproduce without --repeat (single execution).
  • The bug does not reproduce with default jit_hot_func threshold (needs low threshold like 1 to trigger on first iteration).
  • The bug is not related to fibers, coroutines, or any specific extension — plain exec() + string interpolation is sufficient.

Configure flags used

--enable-zts --enable-address-sanitizer --enable-zend-test

(Also reproduced with a full set of extensions matching the standard CI build.)

PHP Version

PHP 8.6.0-dev (cli) (built: Mar 12 2026 06:51:37) (ZTS)
Copyright (c) The PHP Group
Zend Engine v4.6.0-dev, Copyright (c) Zend Technologies
    with Zend OPcache v8.6.0-dev, Copyright (c), by Zend Technologies

Operating System

Ubuntu 24.04.1 LTS

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions