[php-src] Issue #22678: # Use-after-free in array_multisort() — SORT_STRING comparator frees the array being sorted (ext/standard/array.c)
[email protected] (azchin) Fri, 10 Jul 2026 15:59:14 +0000
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <[email protected]> |
Issue: https://github.com/php/php-src/issues/22678
Author: azchin
### Description
# Use-after-free in array_multisort() — SORT_STRING comparator frees the array being sorted (ext/standard/array.c)
A memory-safety use-after-free in the Zend engine reachable from pure PHP: `array_multisort()`
snapshots the input arrays' buckets **without holding a reference on them**, then runs a
user-controlled comparator (`Stringable::__toString()` under `SORT_STRING`). If that callback
unsets or reassigns the array being sorted, its backing store and elements are freed while the
sort is still reading and rewriting them.
## Summary
- **Product / version:** PHP, `array_multisort()` in `ext/standard/array.c`.
- **Affected:** master (8.6.0-dev) and all maintained branches (8.1–8.5). The vulnerable
snapshot-without-refcount pattern predates the GH-16648 sort hardening and was not touched by it.
- **Git revision reproduced:** `c3f1015e5655ac5d3ddb06f6a7c222886890bc83` (php-src master,
2026-07-10). Also reproduced on the OSS-Fuzz `php-fuzz-function-jit` build from master
(2026-07-09).
Reported by Andrew Chin, security researcher from [Team Atlanta](https://team-atlanta.github.io/) / Georgia Tech.
## Reproduction
```php
<?php
class Evil {
public $arr_ref;
function __toString() {
// Called by the SORT_STRING comparator. Free the array being sorted.
if ($this->arr_ref !== null) {
foreach (array_keys($GLOBALS['a']) as $k) unset($GLOBALS['a'][$k]);
$GLOBALS['a'] = []; // drop the array (aliased to $a by reference)
gc_collect_cycles();
}
return "x";
}
}
$a = [];
for ($i = 0; $i < 10; $i++) { $o = new Evil(); $o->arr_ref = true; $a[] = $o; }
$GLOBALS['a'] = &$a;
array_multisort($a, SORT_STRING); // comparator frees $a mid-sort
echo "done\n";
```
Reproduces on OSS-Fuzz `php-fuzz-function-jit` harness, or building CLI with ASAN.
```sh
# in a php-src checkout (master)
./buildconf --force
CC=clang CXX=clang++ \
CFLAGS='-fsanitize=address -g -O1 -fno-omit-frame-pointer' \
LDFLAGS='-fsanitize=address' \
./configure --disable-all --enable-cli --without-sqlite3 --without-pdo-sqlite
make -j"$(nproc)" sapi/cli/php
```
Then run the PoV through the interpreter:
```sh
USE_ZEND_ALLOC=0 USE_TRACKED_ALLOC=1 ASAN_OPTIONS=detect_leaks=0 \
./sapi/cli/php array_multisort-uaf.php
```
(`USE_ZEND_ALLOC=0 USE_TRACKED_ALLOC=1` makes Zend's memory manager route allocations through
ASan-visible `malloc`/`free` so the use-after-free is observed rather than silently reading recycled
Zend-MM heap — PHP's standard memory-debugging configuration. It is not required for the bug to
exist, only for ASan to see it.)
Observed (php-src master, unpatched, standalone CLI — verified 2026-07-10, deterministic 6/6):
```
==…==ERROR: AddressSanitizer: heap-use-after-free ... READ
#0 __zval_get_string_func Zend/zend_operators.c:1084
#1 zval_get_string_func Zend/zend_operators.c:1105
#2 zval_get_tmp_string Zend/zend_operators.h:347
#3 string_compare_function Zend/zend_operators.c:2193
#4 php_multisort_compare ext/standard/array.c:5934
#5 zend_insert_sort Zend/zend_sort.c:115
#6 zif_array_multisort ext/standard/array.c:6113
#7 ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_HANDLER
...
#12 do_cli / main sapi/cli/php_cli.c <-- plain CLI, no harness
freed by:
#1 zend_objects_store_del Zend/zend_objects_API.c:197
#2 _zend_hash_packed_del_val / zend_hash_index_del
#4 ZEND_UNSET_DIM_SPEC_VAR_CV_HANDLER (the unset() inside __toString)
```
## The bug
`ext/standard/array.c`, `PHP_FUNCTION(array_multisort)`. The function snapshots each input array's
buckets into an `indirect` scratch array **by value, without taking any reference**, then sorts
using a comparator that can run user code:
```c
/* Build the indirection array: a snapshot of the input buckets. */
for (i = 0; i < num_arrays; i++) {
k = 0;
if (HT_IS_PACKED(Z_ARRVAL_P(arrays[i]))) {
zval *zv = Z_ARRVAL_P(arrays[i])->arPacked;
for (idx = 0; idx < Z_ARRVAL_P(arrays[i])->nNumUsed; idx++, zv++) {
if (Z_TYPE_P(zv) == IS_UNDEF) continue;
ZVAL_COPY_VALUE(&indirect[k][i].val, zv); /* <-- copy, NO GC_ADDREF: borrows the element */
indirect[k][i].h = idx;
indirect[k][i].key = NULL;
k++;
}
} else {
Bucket *p = Z_ARRVAL_P(arrays[i])->arData;
for (idx = 0; idx < Z_ARRVAL_P(arrays[i])->nNumUsed; idx++, p++) {
if (Z_TYPE(p->val) == IS_UNDEF) continue;
indirect[k][i] = *p; /* <-- ditto: borrows the element */
k++;
}
}
}
/* zend_sort calls php_multisort_compare, which for SORT_STRING converts each
* element to a string -> runs Stringable::__toString() -> arbitrary PHP. */
zend_sort(indirect, array_size, sizeof(Bucket *),
php_multisort_compare, (swap_func_t)array_bucket_p_sawp);
...
/* After sorting, write the snapshot back into the (assumed still-live) arrays. */
for (i = 0; i < num_arrays; i++) {
hash = Z_ARRVAL_P(arrays[i]); /* re-derefs the caller var, which may now be a DIFFERENT array */
hash->nNumUsed = array_size; /* <-- array.c:6121: write through freed/shrunken HashTable */
...
}
```
The comparator itself:
```c
PHPAPI int php_multisort_compare(const void *a, const void *b) {
Bucket *ab = *(Bucket **)a;
Bucket *bb = *(Bucket **)b;
...
result = ARRAYG(multisort_func)[r](&ab[r], &bb[r]); /* string_compare_function -> zval_get_string
* -> Evil::__toString() -> frees $a */
...
}
```
**Safety assumption being violated.** The snapshot in `indirect` holds *borrowed* zvals — it relies
on the input arrays (and therefore the elements) staying alive for the whole duration of the sort.
That assumption holds only if no user code runs during the sort. But `SORT_STRING` (and
`SORT_REGULAR`/`SORT_LOCALE_STRING` on objects) invokes `Stringable::__toString()` from inside the
comparator. That callback can:
- `unset()` every key of the array and/or reassign it (`$GLOBALS['a'] = []`), dropping the array's
last reference so its `HashTable` and all its elements are **freed** — after which the borrowed
zvals in `indirect` and in the objects' `zend_object` are dangling (heap-use-after-free **read**
in the next comparison), and the post-sort restructure loop writes through the freed/replaced
`HashTable` (crash at `array.c:6121`);
- or grow the array, reallocating `arData` (a different but related dangling-pointer path).
This is the same defect already fixed for `sort/asort/usort` (GH-16648), `Collator::sort`
(GH-22467), and `ArrayObject` (GH-22310); `array_multisort` was overlooked.
## Fix
Mirror the exact remedy the engine already applies in `zend_array_sort_ex()` (GH-16648): take a
strong reference on each input `HashTable` for the duration of the sort, so a reentrant comparator
that drops the last user-visible reference only decrements the count instead of freeing the store.
Additionally cache the `HashTable*` snapshots so the post-sort restructure writes back into the
arrays we actually sorted, never a differently-sized array swapped in by the callback.
Verified to mitigate the sanitizer crash given the reproduction PoC.
Made in a local checkout of `php/php-src` at `c3f1015e`:
```diff
diff --git a/ext/standard/array.c b/ext/standard/array.c
index a233e6c4..4af9a105 100644
--- a/ext/standard/array.c
+++ b/ext/standard/array.c
@@ -5963,6 +5963,7 @@ PHP_FUNCTION(array_multisort)
{
zval* args;
zval** arrays;
+ HashTable** hashes;
Bucket** indirect;
uint32_t idx;
HashTable* hash;
@@ -6084,11 +6085,26 @@ PHP_FUNCTION(array_multisort)
for (i = 0; i < array_size; i++) {
indirect[i] = indirects + (i * (num_arrays + 1));
}
+ /* Hold a strong reference on each input array for the duration of the sort.
+ * The comparator may run user code (e.g. Stringable::__toString() under
+ * SORT_STRING) that unsets or reassigns the array being sorted; without an
+ * extra reference that would free the backing store (and its elements) while
+ * the sort is still reading and swapping the buckets snapshotted in
+ * `indirect`, giving a use-after-free. This mirrors the protection
+ * zend_array_sort_ex() already applies to sort()/asort()/usort() (GH-16648).
+ * We also cache the HashTable pointers so the restructure phase writes back
+ * into the arrays we actually snapshotted, even if a reentrant comparator
+ * swapped a differently-sized array into the caller's variable. */
+ hashes = (HashTable **)safe_emalloc(num_arrays, sizeof(HashTable *), 0);
+ for (i = 0; i < num_arrays; i++) {
+ hashes[i] = Z_ARRVAL_P(arrays[i]);
+ GC_ADDREF(hashes[i]);
+ }
for (i = 0; i < num_arrays; i++) {
k = 0;
- if (HT_IS_PACKED(Z_ARRVAL_P(arrays[i]))) {
- zval *zv = Z_ARRVAL_P(arrays[i])->arPacked;
- for (idx = 0; idx < Z_ARRVAL_P(arrays[i])->nNumUsed; idx++, zv++) {
+ if (HT_IS_PACKED(hashes[i])) {
+ zval *zv = hashes[i]->arPacked;
+ for (idx = 0; idx < hashes[i]->nNumUsed; idx++, zv++) {
if (Z_TYPE_P(zv) == IS_UNDEF) continue;
ZVAL_COPY_VALUE(&indirect[k][i].val, zv);
indirect[k][i].h = idx;
@@ -6096,8 +6112,8 @@ PHP_FUNCTION(array_multisort)
k++;
}
} else {
- Bucket *p = Z_ARRVAL_P(arrays[i])->arData;
- for (idx = 0; idx < Z_ARRVAL_P(arrays[i])->nNumUsed; idx++, p++) {
+ Bucket *p = hashes[i]->arData;
+ for (idx = 0; idx < hashes[i]->nNumUsed; idx++, p++) {
if (Z_TYPE(p->val) == IS_UNDEF) continue;
indirect[k][i] = *p;
k++;
@@ -6117,7 +6133,7 @@ PHP_FUNCTION(array_multisort)
/* Restructure the arrays based on sorted indirect - this is mostly taken from zend_hash_sort() function. */
for (i = 0; i < num_arrays; i++) {
- hash = Z_ARRVAL_P(arrays[i]);
+ hash = hashes[i];
hash->nNumUsed = array_size;
hash->nNextFreeElement = array_size;
hash->nInternalPointer = 0;
@@ -6146,6 +6162,17 @@ PHP_FUNCTION(array_multisort)
RETVAL_TRUE;
clean_up:
+ /* Release the references taken above. If a reentrant comparator dropped the
+ * last user-visible reference, the array is destroyed here instead of during
+ * the sort. */
+ for (i = 0; i < num_arrays; i++) {
+ if (UNEXPECTED(GC_DELREF(hashes[i]) == 0)) {
+ zend_array_destroy(hashes[i]);
+ } else {
+ gc_check_possible_root((zend_refcounted *)hashes[i]);
+ }
+ }
+ efree(hashes);
efree(indirects);
efree(indirect);
efree(func);
```
Notes:
- `clean_up` is only reachable after `hashes` is allocated (the `array_size < 1` and argument-error
paths return earlier), so the release loop is always balanced.
- The refcount is released *after* the restructure, matching `zend_array_sort_ex()` (which rehashes
with the temporary refcount still held).
## Deduplication Check
Why `array_multisort` is genuinely novel and worth upstreaming: the "sort comparator mutates/frees
the array being sorted" class is well known and has been **hardened function-by-function** by the
maintainers, but `array_multisort()` was missed:
- `sort()`/`asort()`/`usort()` were fixed in **GH-16648** (commit `2bdce613`, "Fix array going away
during sorting", Ilija Tovilo, 2024-11) by routing through a new `zend_array_sort_ex()` that
`GC_ADDREF`s the `HashTable` for the duration of the sort.
- `Collator::sort()` was fixed in **GH-22467** (commit `5fa74db1`, 2026-06) — "sort a copy and swap
it back, matching usort()".
- `ArrayObject`'s sort handlers were fixed in **GH-22310** (commit `2210fda0`, 2026-06) via an
`nApplyCount` guard.
`array_multisort()` never received any of these guards because it does not use `zend_hash_sort_ex`;
it builds its own indirection array and calls `zend_sort()` directly. It remains vulnerable.
### PHP Version
```plain
PHP 8.6.0-dev (cli) (built: Jul 9 2026 18:10:11) (NTS)
Copyright © The PHP Group and Contributors
Zend Engine v4.6.0-dev, Copyright © Zend by Perforce
with Zend OPcache v8.6.0-dev, Copyright ©, by Zend by Perforce
```
### Operating System
Ubuntu 24.04.4 LTS