Skip to content

Fix zend_analyze_calls() call_stack buffer overrun - #23454

Open
Mrmaxmeier wants to merge 1 commit into
php:PHP-8.4from
Mrmaxmeier:fix-call-graph-stack-overflow
Open

Fix zend_analyze_calls() call_stack buffer overrun #23454
Mrmaxmeier wants to merge 1 commit into
php:PHP-8.4from
Mrmaxmeier:fix-call-graph-stack-overflow

Conversation

@Mrmaxmeier

Copy link
Copy Markdown
Contributor

Hi,

we ran into an out-of-bounds write with the php-fuzz-function-jit fuzzing target:

<?php
function test() {
    new A(new B(new C(new D(match ([]) { 1 => 2 }))));
}
ASAN backtrace for reproducer
/out/php-fuzz-function-jit: Running 1 inputs 100 time(s) each.
Running: /testcase
=================================================================
==14==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7b62989e2e60 at pc 0x556927643788 bp 0x7ffca0c77cd0 sp 0x7ffca0c77cc8
WRITE of size 8 at 0x7b62989e2e60 thread T0
SCARINESS: 42 (8-byte-write-heap-buffer-overflow)
    #0 0x556927643787 in zend_analyze_calls /src/php-src/Zend/Optimizer/zend_call_graph.c:100:22
    #1 0x5569276440fb in zend_analyze_call_graph /src/php-src/Zend/Optimizer/zend_call_graph.c:253:3
    #2 0x5569276c28c2 in zend_optimize_script /src/php-src/Zend/Optimizer/zend_optimizer.c:1631:6
    #3 0x556926e37b79 in cache_script_in_shared_memory /src/php-src/ext/opcache/ZendAccelerator.c:1598:2
    #4 0x556926e39b6d in persistent_compile_file /src/php-src/ext/opcache/ZendAccelerator.c:2399:24
    #5 0x556927d1538e in fuzzer_do_request_from_buffer /src/php-src/sapi/fuzzer/fuzzer-sapi.c:289:29
    #6 0x556927d13954 in LLVMFuzzerTestOneInput /src/php-src/sapi/fuzzer/fuzzer-function-jit.c:32:2
    [..]

DEDUP_TOKEN: zend_analyze_calls--zend_analyze_call_graph--zend_optimize_script
0x7b62989e2e60 is located 0 bytes after 16-byte region [0x7b62989e2e50,0x7b62989e2e60)
allocated by thread T0 here:
    #0 0x5569269a9df4 in malloc /src/llvm-project/compiler-rt/lib/asan/asan_malloc_linux.cpp:67:3
    #1 0x5569276e0231 in tracked_malloc /src/php-src/Zend/zend_alloc.c:3016:14
    #2 0x556927642767 in zend_analyze_calls /src/php-src/Zend/Optimizer/zend_call_graph.c:53:15
    #3 0x5569276440fb in zend_analyze_call_graph /src/php-src/Zend/Optimizer/zend_call_graph.c:253:3
    #4 0x5569276c28c2 in zend_optimize_script /src/php-src/Zend/Optimizer/zend_optimizer.c:1631:6
    #5 0x556926e37b79 in cache_script_in_shared_memory /src/php-src/ext/opcache/ZendAccelerator.c:1598:2
    #6 0x556926e39b6d in persistent_compile_file /src/php-src/ext/opcache/ZendAccelerator.c:2399:24
    #7 0x556927d1538e in fuzzer_do_request_from_buffer /src/php-src/sapi/fuzzer/fuzzer-sapi.c:289:29
    #8 0x556927d13954 in LLVMFuzzerTestOneInput /src/php-src/sapi/fuzzer/fuzzer-function-jit.c:32:2
    [..]

DEDUP_TOKEN: __interceptor_malloc--tracked_malloc--zend_analyze_calls
SUMMARY: AddressSanitizer: heap-buffer-overflow /src/php-src/Zend/Optimizer/zend_call_graph.c:100:22 in zend_analyze_calls

(Note: The buffer is a do_alloca(), so this can either be a stack or heap buffer overflow depending on allocator behaviour. In this setup, ASAN reports it as a heap overflow.)

zend_analyze_calls() assumes that the call_stack is at most op_array->last / 2 deep:

call_stack = do_alloca((op_array->last / 2) * sizeof(zend_call_info*), use_heap);

The implicit assumption here is that every call needs at least two opcodes, an INIT_* that pushes an entry and a DO_FCALL that pops it again. That assumption stops holding when the optimizer removes DO_FCALL opcodes as dead code.

This PR allocates op_array->last entries for call_stack instead, assuming that each opcode pushes at most one call stack entry.

Thanks!


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

The call stack was sized as op_array->last / 2, on the assumption that every
call needs at least an INIT and a DO_FCALL opcode. That assumption does not
hold after the optimizer has removed the DO_FCALL opcodes as dead code, in
which case nothing pops the stack again:

    function test() {
        new A(new B(new C(new D(match ([]) { 1 => 2 }))));
    }

The match arm never matches, so everything behind the ZEND_MATCH_ERROR is
removed and the optimized op_array is just four ZEND_NEWs followed by the
ZEND_MATCH_ERROR. The buffer then holds two entries while four are pushed.

Size the stack by op_array->last instead, which is the only safe upper bound
once the pushes and pops are no longer guaranteed to be balanced.

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