Repository navigation
# Use-after-free in array_multisort() — SORT_STRING comparator frees the array being sorted (ext/standard/array.c) #22678
Description
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()inext/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 UAF in asort #16648 sort hardening and was not touched by it. - Git revision reproduced:
c3f1015e5655ac5d3ddb06f6a7c222886890bc83(php-src master,
2026-07-10). Also reproduced on the OSS-Fuzzphp-fuzz-function-jitbuild from master
(2026-07-09).
Reported by Andrew Chin, security researcher from Team Atlanta / Georgia Tech.
Reproduction
<?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.
# 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/phpThen run the PoV through the interpreter:
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:
/* 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:
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 itsHashTableand all its elements are freed — after which the borrowed
zvals inindirectand in the objects'zend_objectare dangling (heap-use-after-free read
in the next comparison), and the post-sort restructure loop writes through the freed/replaced
HashTable(crash atarray.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 --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_upis only reachable afterhashesis allocated (thearray_size < 1and 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 UAF in asort #16648 (commit2bdce613, "Fix array going away
during sorting", Ilija Tovilo, 2024-11) by routing through a newzend_array_sort_ex()that
GC_ADDREFs theHashTablefor the duration of the sort.Collator::sort()was fixed in Fix use-after-free in Collator::sort() with a mutating comparator #22467 (commit5fa74db1, 2026-06) — "sort a copy and swap
it back, matching usort()".ArrayObject's sort handlers were fixed in Fix use-after-free when ArrayObject sort comparator replaces backing store #22310 (commit2210fda0, 2026-06) via an
nApplyCountguard.
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
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
Activity
Merge branch 'PHP-8.5'
Fix phpGH-22678: array_multisort() use-after-free on mutating comparator
Merge branch 'PHP-8.4' into PHP-8.5