Skip to content

Hash table use-after-destroy in function table during stream wrapper cleanup (Zend/zend_hash.c) #20286

Closed

Description

@vi3tL0u1s

Description

The following code:

<?php
class lib {
  function stream_open() {
    return true;
  }

  function stream_read($count) {
    function a() {}
    include('lib://');
  }

  function stream_close() {
    include('lib://');
  }
}
stream_wrapper_register('lib', lib::class);
include('lib://test.php');

Resulted in this output:

/path/to/php-src/Zend/zend_hash.c(2681) : ht=0x7fc36de8e048 is already destroyed
php: /path/to/php-src/Zend/zend_hash.c:73: void _zend_is_inconsistent(const HashTable *, const char *, int): Assertion `0' failed.
Aborted

Commit:

7455f6a5b0f

Configuration:

CC="clang" CXX="clang++" CFLAGS="-fsanitize=address -g -O0" CXXFLAGS="-fsanitize=address -g -O0" ./configure --enable-debug --enable-address-sanitizer --disable-shared --with-pic

PHP Version

PHP 8.6.0-dev (cli) (built: Oct 25 2025 17:19:26) (NTS DEBUG)
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 22.04

Activity

  1. devnexen commented on Oct 25, 2025

    @devnexen
    Member
    #0  __pthread_kill_implementation (threadid=<optimized out>, signo=6, no_tid=0) at ./nptl/pthread_kill.c:44
    #1  __pthread_kill_internal (threadid=<optimized out>, signo=6) at ./nptl/pthread_kill.c:89
    #2  __GI___pthread_kill (threadid=<optimized out>, signo=signo@entry=6) at ./nptl/pthread_kill.c:100
    #3  0x00007ffff6845e2e in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
    #4  0x00007ffff6828888 in __GI_abort () at ./stdlib/abort.c:77
    #5  0x00007ffff68287f0 in __assert_fail_base (fmt=<optimized out>, assertion=<optimized out>, file=<optimized out>, line=<optimized out>, function=<optimized out>) at ./assert/assert.c:118
    #6  0x000055555e1e8edd in _zend_is_inconsistent (ht=0x7bfff2890048, file=0x55555eae7c61 "/home/dcarlier/Contribs/php-src/Zend/zend_hash.c", line=2681) at /home/dcarlier/Contribs/php-src/Zend/zend_hash.c:73
    #7  0x000055555e230965 in zend_hash_find (ht=0x7bfff2890048, key=0x7bfff28890a0) at /home/dcarlier/Contribs/php-src/Zend/zend_hash.c:2681
    #8  0x000055555d164b9f in zend_is_callable_check_func (callable=0x7bfff3b0e020, frame=0x0, fcc=0x7bfff3b0e040, strict_class=false, error=0x0, suppress_deprecation=true)
        at /home/dcarlier/Contribs/php-src/Zend/zend_API.c:3940
    #9  0x000055555d15e751 in zend_is_callable_at_frame (callable=0x7bfff3b0e020, object=0x7bfff2802000, frame=0x0, check_flags=2, fcc=0x7bfff3b0e040, error=0x0)
        at /home/dcarlier/Contribs/php-src/Zend/zend_API.c:4160
    #10 0x000055555d16c077 in zend_is_callable_ex (callable=0x7bfff3b0e020, object=0x7bfff2802000, check_flags=2, callable_name=0x0, fcc=0x7bfff3b0e040, error=0x0)
        at /home/dcarlier/Contribs/php-src/Zend/zend_API.c:4244
    #11 0x000055555d421436 in zend_call_method_if_exists (object=0x7bfff2802000, method_name=0x7bfff28890a0, retval=0x7bfff39526e0, param_count=0, params=0x0)
        at /home/dcarlier/Contribs/php-src/Zend/zend_execute_API.c:1140
    #12 0x000055555cc88e19 in php_userstreamop_close (stream=0x7bfff2884600, close_handle=1) at /home/dcarlier/Contribs/php-src/main/streams/userspace.c:699
    #13 0x000055555cc5515b in _php_stream_free (stream=0x7bfff2884600, close_options=11) at /home/dcarlier/Contribs/php-src/main/streams/streams.c:480
    #14 0x000055555cc6eb86 in stream_resource_regular_dtor (rsrc=0x7bfff3a84aa0) at /home/dcarlier/Contribs/php-src/main/streams/streams.c:1856
    #15 0x000055555e4fa54f in zend_resource_dtor (res=0x7bfff2801cc0) at /home/dcarlier/Contribs/php-src/Zend/zend_list.c:73
    #16 0x000055555e4fb70d in list_entry_destructor (zv=0x7bfff39526a0) at /home/dcarlier/Contribs/php-src/Zend/zend_list.c:180
    #17 0x000055555e213d6f in _zend_hash_packed_del_val (ht=0x7e8ff4de88d0, idx=8, zv=0x7bfff288a808) at /home/dcarlier/Contribs/php-src/Zend/zend_hash.c:1467
    #18 0x000055555e22710c in zend_hash_graceful_reverse_destroy (ht=0x7e8ff4de88d0) at /home/dcarlier/Contribs/php-src/Zend/zend_hash.c:2036
    #19 0x000055555e4fcd15 in zend_destroy_rsrc_list (ht=0x7e8ff4de88d0) at /home/dcarlier/Contribs/php-src/Zend/zend_list.c:244
    #20 0x000055555e67a0cd in zend_deactivate () at /home/dcarlier/Contribs/php-src/Zend/zend.c:1361
    #21 0x000055555cb45343 in php_request_shutdown (dummy=0x0) at /home/dcarlier/Contribs/php-src/main/main.c:2020
    #22 0x000055555e6a022e in do_cli (argc=2, argv=0x7c2ff4de1bd0) at /home/dcarlier/Contribs/php-src/sapi/cli/php_cli.c:1158
    #23 0x000055555e697952 in main (argc=2, argv=0x7c2ff4de1bd0) at /home/dcarlier/Contribs/php-src/sapi/cli/php_cli.c:1362
    
  2. ndossche commented on Oct 25, 2025

    @ndossche
    Member

    Ouch! This code causes the stream_close call to execute so late that EG(active) is false already! This should be disallowed. zend_call_function & friends prevent this by checking EG(active), but zend_call_method_if_exists doesn't.
    This issue should only be triggerable via resources, so perhaps just fixing this in php_userstreamop_close instead of adding this extra bloat to the Zend API is enough.

  3. devnexen commented on Oct 27, 2025

    @devnexen
    Member

    would something like this works ?

    diff --git a/main/streams/userspace.c b/main/streams/userspace.c
    index 8d15172ef13..13e26289b95 100644
    --- a/main/streams/userspace.c
    +++ b/main/streams/userspace.c
    @@ -695,12 +695,16 @@ static int php_userstreamop_close(php_stream *stream, int close_handle)
     
            assert(us != NULL);
     
    -       ZVAL_STRINGL(&func_name, USERSTREAM_CLOSE, sizeof(USERSTREAM_CLOSE)-1);
    +       if (EG(active)) {
    +               ZVAL_STRINGL(&func_name, USERSTREAM_CLOSE, sizeof(USERSTREAM_CLOSE)-1);
     
    -       call_method_if_exists(&us->object, &func_name, &retval, 0, NULL);
    +               call_method_if_exists(&us->object, &func_name, &retval, 0, NULL);
     
    -       zval_ptr_dtor(&retval);
    -       zval_ptr_dtor(&func_name);
    +               zval_ptr_dtor(&retval);
    +               zval_ptr_dtor(&func_name);
    +       } else {
    +               stream->wrapper = 0;
    +       }
     
            zval_ptr_dtor(&us->object);
            ZVAL_UNDEF(&us->object);

    let me know if you prefer to work on it yourself though.

  4. ndossche commented on Oct 27, 2025

    @ndossche
    Member

    I haven't deeply looked into it. But that seems to go in the right direction although I don't immediately know why you'd need to NULL the wrapper

  5. devnexen commented on Oct 27, 2025

    @devnexen
    Member

    Because it will attempt to destroy the resource otherwise, leading to UB.

  6. iluuu1994 commented on Oct 28, 2025

    @iluuu1994
    Member

    Related to #19849. Normally, resources are closed in zend_close_rsrc_list() before shutting down the executor. However, in this example, another resource is opened when closing the resources, which will be left unclosed. zend_destroy_rsrc_list(&EG(regular_list)); (in zend_deactivate()) will then attempt to close it again, which includes the call to userland after the executor has shut down.

    We could simply keep closing the resources again in zend_close_rsrc_list(), but there's some concern for infinite recursion. This is not an isolated incidence (there have been at least a handful similar issues), so IMO skipping function calls in zend_call_method_if_exists() when EG(active) is false is reasonable.

  7. arnaud-lb commented on Oct 30, 2025

    @arnaud-lb
    Member

    There are similar considerations for objects: Destructors can create new objects, that need to be destroyed. For objects we chose to keep calling destructors.

    The advantage is that it doesn't add an exception to the rule "object destructors are always called".

    This does loop infinitely if destructors keep creating new objects (until growing the object store exceeds the memory limit), but doesn't appear to be an issue in practice. Programs that trigger this behavior are broken anyway.

    I'm in favor of continuing to close the resources in zend_close_rsrc_list() while new ones are created, unless the chances of infinite loops are higher in the case of resources.

  8. iluuu1994 commented on Oct 30, 2025

    @iluuu1994
    Member

    Okay, if you prefer this solution it works for me. Regardless, it may be a good idea to add the check to zend_call_method_if_exists() so we can avoid triggering callbacks when the VM is shut down. We can keep a ZEND_UNREACHABLE() in debug builds so such cases can still be caught through fuzzing.

  9. ndossche commented on Oct 30, 2025

    @ndossche
    Member

    I agree with Arnaud that we should keep closing in zend_close_rsrc_list until the list is empty.
    I still think we should add an assert to zend_call_method_if_exists as well though, at least.

  10. added a commit that references this issue on Nov 15, 2025
    7ab6d4c
  11. added 3 commits that reference this issue on Nov 23, 2025
    27f17c3
    eb733a3
    d13b5eb
  12. added a commit that references this issue on Nov 23, 2025
    ca914ee
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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