Skip to content

# Use-after-free in array_multisort() — SORT_STRING comparator frees the array being sorted (ext/standard/array.c) #22678

Description

@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 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

<?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/php

Then 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 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:

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions