Summary
The NumPower PHP extension fails to validate dimensions, axis keys, slice
descriptors, and vector lengths at several public NumPower/NDArray entry
points. Carefully shaped PHP arrays can therefore make the extension perform
heap out-of-bounds writes or reads during ordinary CPU operations.
The six independent triggers in this report were checked against the local
NumPower source and executed in an AddressSanitizer PHP 8.4.20 container. The
three write bugs corrupt heap memory directly.
Details
- Affected component: NumPower PHP extension (RubixNumPower, module
ndarray.so), including the checked sheunl/numpower 0.7.4 tree and the
same logic in upstream NumPower/numpower commit 51e6add / tag 0.6.0.
- Affected entry points: NumPower::correlate2d(),
NumPower::transpose(), NDArray::slice(), NumPower::dot(),
NumPower::diagonal(), and NumPower::quantile().
- Affected source: local source tree
php_extension/pie/sheunl__numpower-0.7.4/; the built paths in the
sanitizer reports are /build/numpower/.
- Root cause: caller-controlled shape and index values reach fixed-size
allocations or native loops without validating the corresponding extent.
- Trigger condition: each condition is shown separately below. The PHP
arrays are ordinary extension inputs and do not require a malformed native
object or a custom allocator.
1. correlate2d() FULL mode heap out-of-bounds write
NDArray_Correlate2D() allocates the FULL result using the input dimensions,
but _convolve2d() iterates over the larger full convolution extent. With a
3x3 input and a 2x2 kernel, the result allocation is 9 floats while the loop
writes 16 positions.
/* src/ndmath/signal.c */
if (outsize == FULL) {
Os[0] = Ns[0] + Nwin[0] - 1;
Os[1] = Ns[1] + Nwin[1] - 1;
}
for (int64_t m = 0; m < Os[0]; m++) {
for (int64_t n = 0; n < Os[1]; n++) {
char *sum = out + m * outstr[0] + n * outstr[1];
memset(sum, 0, type_size);
}
}
The allocation path instead copies only SHAPE(a) for FULL:
/* src/ndmath/signal.c */
case FULL:
for (i = 0; i < NDArray_NDIM(a); i++) {
aout_dimens[i] = NDArray_SHAPE(a)[i];
}
break;
The PHP wrapper maps the first character of "full" to FULL and "fill"
to the padding boundary. The captured report identifies the invalid write in
_convolve2d() at signal.c:161, immediately after the 36-byte result
buffer.
2. transpose() heap out-of-bounds write through a sparse PHP key
The wrapper allocates dims->ptr based on the number of elements in the PHP
axes array, but uses each raw numeric key as the destination index. For
[7 => 0], one integer is allocated and the wrapper writes dims->ptr[7].
/* numpower.c */
dims->len = (int) zend_array_count(axes_ht);
dims->ptr = emalloc(sizeof(int) * dims->len);
ZEND_HASH_FOREACH_KEY_VAL(axes_ht, idx, key, val) {
/* ... */
dims->ptr[(int) idx] = (int) zval_get_long(val);
} ZEND_HASH_FOREACH_END();
The captured report identifies a heap-buffer-overflow WRITE at
numpower.c:1668. This trigger uses a one-dimensional NDArray so that the
single supplied axis has the correct element count while its PHP key remains
out of bounds.
3. NDArray::slice() memcpy heap out-of-bounds write
NDArray_Slice() decrements new_dim for every one-element index descriptor,
but then copies the original number of dimensions into allocations sized using
the reduced value. The two-dimensional trigger has one scalar index and one
range index, so new_dim is one while both memcpy() calls copy two integers.
/* src/manipulation.c */
if (NDArray_NUMELEMENTS(indexes[i]) == 1) {
new_dim--;
new_dim_step--;
}
int *strides_ptr = emalloc(sizeof(int) * new_dim);
memcpy(strides_ptr, NDArray_STRIDES(array),
sizeof(int) * NDArray_NDIM(array));
int *shape_ptr = emalloc(sizeof(int) * new_dim);
memcpy(shape_ptr, NDArray_SHAPE(array),
sizeof(int) * NDArray_NDIM(array));
The captured report identifies an 8-byte heap write in the first memcpy() at
manipulation.c:255, where the destination allocation is only four bytes.
4. dot() matrix/vector inner-dimension mismatch and heap out-of-bounds read
The matrix-by-vector branch passes the matrix's last dimension as n to
cblas_sgemv() without checking the vector length. A 2x5 matrix and a
two-element vector therefore make the BLAS call read five vector elements from
an allocation containing only two.
/* src/ndmath/linalg.c */
cblas_sgemv(CblasRowMajor, CblasNoTrans,
NDArray_SHAPE(nda)[NDArray_NDIM(nda) - 2],
NDArray_SHAPE(nda)[NDArray_NDIM(nda) - 1],
1.0f, NDArray_F32DATA(nda),
NDArray_SHAPE(nda)[NDArray_NDIM(nda) - 1],
NDArray_F32DATA(ndb), 1, 0.0f,
NDArray_F32DATA(rtn), 1);
The normal container run links the system OpenBLAS shared library, which was
not built with ASan and exits normally even though the invalid length is
passed to it. To verify the actual access, the supplied record uses an
ASan-instrumented CBLAS shim implementing the same row-major, no-transpose
sgemv access pattern. The shim log reports a read immediately after the
two-float vector buffer and returns to NDArray_Dot() at linalg.c:396. The
shim is a verification aid; it does not change the NumPower source or the
shape mismatch supplied to the extension.
5. diagonal() rectangular matrix heap out-of-bounds read
NDArray_Diagonal() sizes and iterates the output using the column count. It
should stop at the smaller of the row and column counts. For a 2x5 matrix,
iterations 2 through 4 advance beyond the input data allocation.
/* src/indexing.c */
new_shape[0] = NDArray_SHAPE(target)[NDArray_NDIM(target) - 1];
rtn = NDArray_Empty(new_shape, 1, NDARRAY_TYPE_FLOAT32,
NDArray_DEVICE_CPU);
for (i = 0; i < NDArray_SHAPE(target)[NDArray_NDIM(target) - 1]; i++) {
NDArray_F32DATA(rtn)[i] = ((float *) (
NDArray_DATA(target)
+ i * NDArray_STRIDES(target)[NDArray_NDIM(target) - 2]
+ i * NDArray_STRIDES(target)[NDArray_NDIM(target) - 1]))[0];
}
The captured report identifies a 4-byte heap read eight bytes to the right of
the 40-byte input data region at indexing.c:34.
6. quantile() q=1.0 one-past-end heap out-of-bounds read
The wrapper accepts a scalar quantile in the inclusive range [0, 1]. At
q == 1.0, the interpolation code sets lower_index to n - 1 and
upper_index to n, then reads temp[n] from an allocation containing only n
elements.
/* src/ndmath/statistics.c */
float index = (float) (num_elements - 1) * quantile;
int lower_index = (int) index;
int upper_index = lower_index + 1;
float lower_value = temp[lower_index];
float upper_value = temp[upper_index];
The captured report identifies a 4-byte read exactly at the end of the 20-byte
temporary allocation at statistics.c:45.
PoC
Environment and configuration
- Container/image: php-asan-8420-release, based on php-asan:8.4.20
- PHP: PHP 8.4.20 CLI, NTS, built Jul 13 2026 11:49:40
- PHP source revision: source tree /src/php; the container does not
retain a PHP Git commit identifier
- PHP configure options:
./configure \
--enable-soap --enable-mbstring --disable-cgi --disable-phpdbg
- PHP compiler/linker flags:
CFLAGS=-fsanitize=address -g -O0
LDFLAGS=-fsanitize=address
- Extension: local sheunl/numpower 0.7.4 tree, configured with
--enable-ndarray; recorded module path
/build/numpower/modules/ndarray.so
- NumPower build flags:
CFLAGS=-fsanitize=address -g -O0 -I/usr/include/x86_64-linux-gnu -mavx2 -march=native -lopenblas -lpthread -llapack -llapacke
LDFLAGS=
- Native dependencies: system libopenblas.so.0, liblapacke.so.3, and
liblapack.so.3; CUDA was not enabled
- 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 complete environment record is in environment.txt. The complete raw
logs are retained as the six *_asan.txt files in this directory.
Reproducer
Run each PHP file in a separate process because the sanitizer aborts on the
first invalid access:
for poc in NUMPOWER-NEW-001_*_oob_*.php; do
env 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 -n \
-d extension=/build/numpower/modules/ndarray.so "$poc"
done
The six individual PoCs are:
- NUMPOWER-NEW-001_correlate2d_full_oob_write.php
- NUMPOWER-NEW-001_transpose_sparse_key_oob_write.php
- NUMPOWER-NEW-001_slice_scalar_index_oob_write.php
- NUMPOWER-NEW-001_dot_short_vector_oob_read.php
- NUMPOWER-NEW-001_diagonal_rectangular_oob_read.php
- NUMPOWER-NEW-001_quantile_one_oob_read.php
The dot() trigger's ASan evidence is reproduced with the supplied
cblas_sgemv_asan_shim.c:
cc -shared -fPIC -fsanitize=address -fno-omit-frame-pointer -g -O0 \
-o /tmp/libcblas_sgemv_asan_shim.so cblas_sgemv_asan_shim.c
env 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 \
LD_PRELOAD=/lib/x86_64-linux-gnu/libasan.so.6:/tmp/libcblas_sgemv_asan_shim.so \
/src/php/sapi/cli/php -n \
-d extension=/build/numpower/modules/ndarray.so \
NUMPOWER-NEW-001_dot_short_vector_oob_read.php
Sanitizer report
The following are the relevant lines from the actual captured logs; each full
process log is included in the directory.
correlate2d_full_oob_write_asan.txt:
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 4
#1 in _convolve2d /build/numpower/src/ndmath/signal.c:161
#2 in NDArray_Correlate2D /build/numpower/src/ndmath/signal.c:289
0 bytes to the right of 36-byte region
transpose_sparse_key_oob_write_asan.txt:
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 4
#0 in zim_NumPower_transpose /build/numpower/numpower.c:1668
SUMMARY: heap-buffer-overflow in zim_NumPower_transpose
slice_scalar_index_oob_write_asan.txt:
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 8
#1 in NDArray_Slice /build/numpower/src/manipulation.c:255
#2 in zim_NDArray_slice /build/numpower/numpower.c:5119
SUMMARY: heap-buffer-overflow in __interceptor_memcpy
dot_short_vector_oob_read_asan.txt:
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 4
#0 in cblas_sgemv /tmp/cblas_sgemv_asan_shim.c:25
#1 in NDArray_Dot /build/numpower/src/ndmath/linalg.c:396
0 bytes to the right of 8-byte region
diagonal_rectangular_oob_read_asan.txt:
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 4
#0 in NDArray_Diagonal /build/numpower/src/indexing.c:34
8 bytes to the right of 40-byte region
quantile_one_oob_read_asan.txt:
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 4
#0 in calculate_quantile /build/numpower/src/ndmath/statistics.c:45
0 bytes to the right of 20-byte region
cblas_sgemv_asan_shim.c
NUMPOWER-NEW-001_correlate2d_full_oob_write.php
NUMPOWER-NEW-001_correlate2d_full_oob_write_asan.txt
NUMPOWER-NEW-001_diagonal_rectangular_oob_read.php
NUMPOWER-NEW-001_diagonal_rectangular_oob_read_asan.txt
NUMPOWER-NEW-001_dot_short_vector_oob_read.php
NUMPOWER-NEW-001_dot_short_vector_oob_read_asan.txt
NUMPOWER-NEW-001_quantile_one_oob_read.php
NUMPOWER-NEW-001_quantile_one_oob_read_asan.txt
NUMPOWER-NEW-001_slice_scalar_index_oob_write.php
NUMPOWER-NEW-001_slice_scalar_index_oob_write_asan.txt
NUMPOWER-NEW-001_transpose_sparse_key_oob_write.php
NUMPOWER-NEW-001_transpose_sparse_key_oob_write_asan.txt
Summary
The NumPower PHP extension fails to validate dimensions, axis keys, slice
descriptors, and vector lengths at several public NumPower/NDArray entry
points. Carefully shaped PHP arrays can therefore make the extension perform
heap out-of-bounds writes or reads during ordinary CPU operations.
The six independent triggers in this report were checked against the local
NumPower source and executed in an AddressSanitizer PHP 8.4.20 container. The
three write bugs corrupt heap memory directly.
Details
ndarray.so), including the checked sheunl/numpower 0.7.4 tree and the
same logic in upstream NumPower/numpower commit 51e6add / tag 0.6.0.
NumPower::transpose(), NDArray::slice(), NumPower::dot(),
NumPower::diagonal(), and NumPower::quantile().
php_extension/pie/sheunl__numpower-0.7.4/; the built paths in the
sanitizer reports are /build/numpower/.
allocations or native loops without validating the corresponding extent.
arrays are ordinary extension inputs and do not require a malformed native
object or a custom allocator.
1. correlate2d() FULL mode heap out-of-bounds write
NDArray_Correlate2D() allocates the FULL result using the input dimensions,
but _convolve2d() iterates over the larger full convolution extent. With a
3x3 input and a 2x2 kernel, the result allocation is 9 floats while the loop
writes 16 positions.
The allocation path instead copies only SHAPE(a) for FULL:
The PHP wrapper maps the first character of "full" to FULL and "fill"
to the padding boundary. The captured report identifies the invalid write in
_convolve2d() at signal.c:161, immediately after the 36-byte result
buffer.
2. transpose() heap out-of-bounds write through a sparse PHP key
The wrapper allocates dims->ptr based on the number of elements in the PHP
axes array, but uses each raw numeric key as the destination index. For
[7 => 0], one integer is allocated and the wrapper writes dims->ptr[7].
The captured report identifies a heap-buffer-overflow WRITE at
numpower.c:1668. This trigger uses a one-dimensional NDArray so that the
single supplied axis has the correct element count while its PHP key remains
out of bounds.
3. NDArray::slice() memcpy heap out-of-bounds write
NDArray_Slice() decrements new_dim for every one-element index descriptor,
but then copies the original number of dimensions into allocations sized using
the reduced value. The two-dimensional trigger has one scalar index and one
range index, so new_dim is one while both memcpy() calls copy two integers.
The captured report identifies an 8-byte heap write in the first memcpy() at
manipulation.c:255, where the destination allocation is only four bytes.
4. dot() matrix/vector inner-dimension mismatch and heap out-of-bounds read
The matrix-by-vector branch passes the matrix's last dimension as n to
cblas_sgemv() without checking the vector length. A 2x5 matrix and a
two-element vector therefore make the BLAS call read five vector elements from
an allocation containing only two.
The normal container run links the system OpenBLAS shared library, which was
not built with ASan and exits normally even though the invalid length is
passed to it. To verify the actual access, the supplied record uses an
ASan-instrumented CBLAS shim implementing the same row-major, no-transpose
sgemv access pattern. The shim log reports a read immediately after the
two-float vector buffer and returns to NDArray_Dot() at linalg.c:396. The
shim is a verification aid; it does not change the NumPower source or the
shape mismatch supplied to the extension.
5. diagonal() rectangular matrix heap out-of-bounds read
NDArray_Diagonal() sizes and iterates the output using the column count. It
should stop at the smaller of the row and column counts. For a 2x5 matrix,
iterations 2 through 4 advance beyond the input data allocation.
The captured report identifies a 4-byte heap read eight bytes to the right of
the 40-byte input data region at indexing.c:34.
6. quantile() q=1.0 one-past-end heap out-of-bounds read
The wrapper accepts a scalar quantile in the inclusive range [0, 1]. At
q == 1.0, the interpolation code sets lower_index to n - 1 and
upper_index to n, then reads temp[n] from an allocation containing only n
elements.
The captured report identifies a 4-byte read exactly at the end of the 20-byte
temporary allocation at statistics.c:45.
PoC
Environment and configuration
retain a PHP Git commit identifier
--enable-ndarray; recorded module path
/build/numpower/modules/ndarray.so
liblapack.so.3; CUDA was not enabled
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 complete environment record is in environment.txt. The complete raw
logs are retained as the six *_asan.txt files in this directory.
Reproducer
Run each PHP file in a separate process because the sanitizer aborts on the
first invalid access:
The six individual PoCs are:
The dot() trigger's ASan evidence is reproduced with the supplied
cblas_sgemv_asan_shim.c:
Sanitizer report
The following are the relevant lines from the actual captured logs; each full
process log is included in the directory.
cblas_sgemv_asan_shim.c
NUMPOWER-NEW-001_correlate2d_full_oob_write.php
NUMPOWER-NEW-001_correlate2d_full_oob_write_asan.txt
NUMPOWER-NEW-001_diagonal_rectangular_oob_read.php
NUMPOWER-NEW-001_diagonal_rectangular_oob_read_asan.txt
NUMPOWER-NEW-001_dot_short_vector_oob_read.php
NUMPOWER-NEW-001_dot_short_vector_oob_read_asan.txt
NUMPOWER-NEW-001_quantile_one_oob_read.php
NUMPOWER-NEW-001_quantile_one_oob_read_asan.txt
NUMPOWER-NEW-001_slice_scalar_index_oob_write.php
NUMPOWER-NEW-001_slice_scalar_index_oob_write_asan.txt
NUMPOWER-NEW-001_transpose_sparse_key_oob_write.php
NUMPOWER-NEW-001_transpose_sparse_key_oob_write_asan.txt