Skip to content

Fix block pass dropping a JMP across an unreachable_free block - #23538

Open
Mrmaxmeier wants to merge 1 commit into
php:PHP-8.4from
Mrmaxmeier:fix-unreachable-free-jmp
Open

Fix block pass dropping a JMP across an unreachable_free block#23538
Mrmaxmeier wants to merge 1 commit into
php:PHP-8.4from
Mrmaxmeier:fix-unreachable-free-jmp

Conversation

@Mrmaxmeier

Copy link
Copy Markdown
Contributor

Hi,

we ran into an assertion failure with the fuzzer-function-jit fuzzing target:

<?php
function test($a, $b, $c) {
    do {
        if ($a) {
            switch ($b[0]) {
                case 'x':
                    switch ($c[0]) {
                        default:
                            return "returned";
                    }
                default:
                    continue 2;
            }
        }
    } while (false);
    return $b[0];
}

test(true, ["y"], ["z"]);

(On release builds this might turn into a double-free or break program semantics)

Assertion and backtrace for reproducer
/out/php-fuzz-function-jit: Running 1 inputs 100 time(s) each.
Running: /testcase
php-fuzz-function-jit: /src/php-src/Zend/zend_opcode.c:754: void emit_live_range_raw(zend_op_array *, uint32_t, uint32_t, uint32_t, uint32_t): Assertion `start < end' failed.
AddressSanitizer:DEADLYSIGNAL
=================================================================
==14==ERROR: AddressSanitizer: ABRT on unknown address 0x00000000000e (pc 0x7fdce9bc200b bp 0x7fdce9d37588 sp 0x7fff78b89ab0 T0)
SCARINESS: 10 (signal)
    #0 0x7fdce9bc200b in raise (/lib/x86_64-linux-gnu/libc.so.6+0x4300b) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
    #1 0x7fdce9ba1858 in abort (/lib/x86_64-linux-gnu/libc.so.6+0x22858) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
    #2 0x7fdce9ba1728  (/lib/x86_64-linux-gnu/libc.so.6+0x22728) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
    #3 0x7fdce9bb2fd5 in __assert_fail (/lib/x86_64-linux-gnu/libc.so.6+0x33fd5) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
    #4 0x55d2e3aacdf7 in emit_live_range_raw /src/php-src/Zend/zend_opcode.c:754:2
    #5 0x55d2e3aa91db in zend_calc_live_ranges /src/php-src/Zend/zend_opcode.c:1027:6
    #6 0x55d2e34c4c31 in zend_optimize_script /src/php-src/Zend/Optimizer/zend_optimizer.c
    #7 0x55d2e2c37b79 in cache_script_in_shared_memory /src/php-src/ext/opcache/ZendAccelerator.c:1598:2
    #8 0x55d2e2c39b6d in persistent_compile_file /src/php-src/ext/opcache/ZendAccelerator.c:2399:24
    #9 0x55d2e3b1538e in fuzzer_do_request_from_buffer /src/php-src/sapi/fuzzer/fuzzer-sapi.c:289:29
    #10 0x55d2e3b13954 in LLVMFuzzerTestOneInput /src/php-src/sapi/fuzzer/fuzzer-function-jit.c:32:2
    [..]

DEDUP_TOKEN: raise--abort--
SUMMARY: AddressSanitizer: ABRT (/lib/x86_64-linux-gnu/libc.so.6+0x4300b) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d) in raise
==14==ABORTING

The block pass marks an unreachable block that frees a loop variable created in a reachable block as ZEND_BB_UNREACHABLE_FREE, and still emits it: the FREE opcodes it contains determine where the live range of that variable ends.

Two places in the block pass drop a ZEND_JMP when it targets the block that physically follows it. Both walk forward over the block list and skip every block that is not ZEND_BB_REACHABLE. That skips the ZEND_BB_UNREACHABLE_FREE blocks as well, even though those do get emitted:

while (next < end && !(next->flags & ZEND_BB_REACHABLE)) {
    next++;
}

So the pass removes the jump while an unreachable_free block still physically sits between the two, and control falls straight into a FREE opcode that is never meant to run. In the reproducer, the continue 2 path frees the switch subject twice and leaves an inverted live range behind.

The fix teaches both walks that a non-empty ZEND_BB_UNREACHABLE_FREE block still separates a block from its successor, so the jump stays.

Thanks!


Found by the CISPA Fandango team while triaging findings in oss-fuzz harnesses.

An unreachable block that frees a loop variable created in a reachable block is
marked ZEND_BB_UNREACHABLE_FREE and is still emitted, because the FREE opcodes
it contains determine where the live range of that variable ends. Two places
that decide whether a ZEND_JMP can be dropped because it targets the block that
physically follows skipped over every block that is not ZEND_BB_REACHABLE,
including those.

The jump was therefore removed even though an unreachable_free block still sat
between the two, and control fell straight into a FREE opcode that was never
meant to be executed:

    function test($a, $b, $c) {
        do {
            if ($a) {
                switch ($b[0]) {
                    case 'x':
                        switch ($c[0]) {
                            default:
                                return "returned";
                        }
                    default:
                        continue 2;
                }
            }
        } while (false);
        return $b[0];
    }

Taking the "continue 2" path freed the switch subject twice, and the resulting
live range was inverted, which also tripped the start < end assertion in
emit_live_range_raw().

Assisted-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant