You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 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-Fuzz php-fuzz-function-jit build from master
(2026-07-09).
Reported by Andrew Chin, security researcher from Team Atlanta / Georgia Tech.
Reproduction
<?phpclass 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 = newEvil(); $o->arr_ref = true; $a[] = $o; }
$GLOBALS['a'] = &$a;
array_multisort($a, SORT_STRING); // comparator frees $a mid-sortecho"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/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.)
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 */
...
}
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 --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 UAF in asort #16648 (commit 2bdce613, "Fix array going away
during sorting", Ilija Tovilo, 2024-11) by routing through a new zend_array_sort_ex() that GC_ADDREFs the HashTable for the duration of the sort.
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.
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()underSORT_STRING). If that callbackunsets or reassigns the array being sorted, its backing store and elements are freed while the
sort is still reading and rewriting them.
Summary
array_multisort()inext/standard/array.c.snapshot-without-refcount pattern predates the UAF in asort #16648 sort hardening and was not touched by it.
c3f1015e5655ac5d3ddb06f6a7c222886890bc83(php-src master,2026-07-10). Also reproduced on the OSS-Fuzz
php-fuzz-function-jitbuild from master(2026-07-09).
Reported by Andrew Chin, security researcher from Team Atlanta / Georgia Tech.
Reproduction
Reproduces on OSS-Fuzz
php-fuzz-function-jitharness, or building CLI with ASAN.Then run the PoV through the interpreter:
(
USE_ZEND_ALLOC=0 USE_TRACKED_ALLOC=1makes Zend's memory manager route allocations throughASan-visible
malloc/freeso the use-after-free is observed rather than silently reading recycledZend-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):
The bug
ext/standard/array.c,PHP_FUNCTION(array_multisort). The function snapshots each input array'sbuckets into an
indirectscratch array by value, without taking any reference, then sortsusing a comparator that can run user code:
The comparator itself:
Safety assumption being violated. The snapshot in
indirectholds borrowed zvals — it relieson 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(andSORT_REGULAR/SORT_LOCALE_STRINGon objects) invokesStringable::__toString()from inside thecomparator. That callback can:
unset()every key of the array and/or reassign it ($GLOBALS['a'] = []), dropping the array'slast reference so its
HashTableand all its elements are freed — after which the borrowedzvals in
indirectand in the objects'zend_objectare dangling (heap-use-after-free readin the next comparison), and the post-sort restructure loop writes through the freed/replaced
HashTable(crash atarray.c:6121);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_multisortwas overlooked.Fix
Mirror the exact remedy the engine already applies in
zend_array_sort_ex()(GH-16648): take astrong reference on each input
HashTablefor the duration of the sort, so a reentrant comparatorthat 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 thearrays 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-srcatc3f1015e:Notes:
clean_upis only reachable afterhashesis allocated (thearray_size < 1and argument-errorpaths return earlier), so the release loop is always balanced.
zend_array_sort_ex()(which rehasheswith the temporary refcount still held).
Deduplication Check
Why
array_multisortis genuinely novel and worth upstreaming: the "sort comparator mutates/freesthe 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 awayduring sorting", Ilija Tovilo, 2024-11) by routing through a new
zend_array_sort_ex()thatGC_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 swapit 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 annApplyCountguard.array_multisort()never received any of these guards because it does not usezend_hash_sort_ex;it builds its own indirection array and calls
zend_sort()directly. It remains vulnerable.PHP Version
Operating System
Ubuntu 24.04.4 LTS