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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions