Commit 1e9f56cdb41 for php.net
commit 1e9f56cdb4126fe9dc693f9fed2385dda31fa4a8
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date: Fri Jul 10 12:48:49 2026 -0400
Fix GH-22678: array_multisort() use-after-free on mutating comparator
array_multisort() snapshotted each input array's buckets without holding a
reference, then ran a comparator that under SORT_STRING invokes
Stringable::__toString(); a callback that unset or reassigned the array freed
its backing store and elements mid-sort, a use-after-free read in the
comparator and a write-back through the freed table during the restructure.
Hold a reference on each input HashTable for the duration of the sort and
restructure the cached tables, mirroring zend_array_sort_ex() (GH-16648).
HT_ALLOW_COW_VIOLATION allows the in-place repack under the held reference.
Fixes GH-22678
Closes GH-22680
diff --git a/NEWS b/NEWS
index 7324e5c591a..ee23c0ec7a1 100644
--- a/NEWS
+++ b/NEWS
@@ -87,6 +87,8 @@ PHP NEWS
(Weilin Du)
. Fixed integer overflow in getimagesize() and getimagesizefromstring()
when parsing an IFF chunk with a size of INT_MAX. (David Carlier, Weilin Du)
+ . Fixed bug GH-22678 (Use-after-free in array_multisort() when the comparator
+ mutates the array being sorted). (azchin, iliaal)
- Zip:
. Fixed bug GH-22649 (ZipArchive::setCommentName() and setCommentIndex()
diff --git a/ext/standard/array.c b/ext/standard/array.c
index 13731592d83..4527d9a80df 100644
--- a/ext/standard/array.c
+++ b/ext/standard/array.c
@@ -6046,6 +6046,7 @@ PHP_FUNCTION(array_multisort)
{
zval* args;
zval** arrays;
+ HashTable** hashes;
Bucket** indirect;
uint32_t idx;
HashTable* hash;
@@ -6167,11 +6168,17 @@ PHP_FUNCTION(array_multisort)
for (i = 0; i < array_size; i++) {
indirect[i] = indirects + (i * (num_arrays + 1));
}
+ hashes = 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]);
+ HT_ALLOW_COW_VIOLATION(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;
@@ -6179,8 +6186,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++;
@@ -6200,7 +6207,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;
@@ -6229,6 +6236,14 @@ PHP_FUNCTION(array_multisort)
RETVAL_TRUE;
clean_up:
+ 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);
diff --git a/ext/standard/tests/array/gh22678.phpt b/ext/standard/tests/array/gh22678.phpt
new file mode 100644
index 00000000000..c1b71b59252
--- /dev/null
+++ b/ext/standard/tests/array/gh22678.phpt
@@ -0,0 +1,48 @@
+--TEST--
+GH-22678 (Use-after-free in array_multisort() when the comparator mutates the array)
+--FILE--
+<?php
+class Evil {
+ public function __toString(): string {
+ // Runs from the SORT_STRING comparator; drop the array being sorted.
+ foreach (array_keys($GLOBALS['a']) as $k) {
+ unset($GLOBALS['a'][$k]);
+ }
+ $GLOBALS['a'] = [];
+ return "x";
+ }
+}
+
+$a = [];
+for ($i = 0; $i < 10; $i++) {
+ $a[] = new Evil();
+}
+$GLOBALS['a'] = &$a;
+
+echo "freed mid-sort: ";
+var_dump(array_multisort($a, SORT_STRING));
+unset($GLOBALS['a']);
+
+// Non-packed integer keys reach the mixed-to-packed restructure while the sort
+// holds its reference on the array.
+$b = [5 => 'c', 3 => 'a', 1 => 'b'];
+array_multisort($b, SORT_STRING);
+echo "repacked: ", implode(',', $b), "\n";
+
+class Boom {
+ public function __toString(): string {
+ throw new Exception("boom");
+ }
+}
+
+$c = [new Boom(), new Boom()];
+try {
+ array_multisort($c, SORT_STRING);
+} catch (Exception $e) {
+ echo $e::class, ": ", $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+freed mid-sort: bool(true)
+repacked: a,b,c
+Exception: boom