Skip to content

Fix GH-22678: array_multisort() use-after-free on mutating comparator#22680

Open
iliaal wants to merge 1 commit into
php:PHP-8.4from
iliaal:fix/gh-22678-multisort-uaf
Open

Fix GH-22678: array_multisort() use-after-free on mutating comparator#22680
iliaal wants to merge 1 commit into
php:PHP-8.4from
iliaal:fix/gh-22678-multisort-uaf

Conversation

@iliaal

@iliaal iliaal commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

array_multisort() borrows each input array's buckets without a refcount, then runs the comparator; under SORT_STRING that reaches Stringable::__toString(), so a callback that unsets or reassigns the array frees its backing store and elements mid-sort (use-after-free in the comparator, and a write-back through the freed table in the restructure). Holding a reference on each input HashTable for the duration of the sort fixes it, mirroring zend_array_sort_ex() (GH-16648); HT_ALLOW_COW_VIOLATION lets the in-place restructure repack the still-referenced table. Fixes #22678

@devnexen

Copy link
Copy Markdown
Member

it seems almost co-authored by the issue reporter no ?

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() (phpGH-16648).
HT_ALLOW_COW_VIOLATION allows the in-place repack under the held reference.

Fixes phpGH-22678
@iliaal iliaal force-pushed the fix/gh-22678-multisort-uaf branch from 87034b5 to 076d09e Compare July 10, 2026 20:30
@iliaal

iliaal commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

The reporter's issue included a proposed patch, so the approach follows it (and the zend_array_sort_ex protection it mirrors, GH-16648). Theirs was incomplete though: it aborts on debug builds for a non-packed integer-keyed multisort, because the restructure's zend_hash_to_packed asserts RC1 while the added reference is held. This adds HT_ALLOW_COW_VIOLATION for that, plus a test covering the repack and exception paths. Credited the reporter in NEWS.

Comment thread ext/standard/array.c
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]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Array may be interned I believe, so you should use GC_TRY_ADDREF

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hashes[i] is separated to a mutable array before this (SEPARATE_ARRAY in the parse loop) and restructured in place afterwards, so it's never interned here. An interned class-const array passed in sorts a separated copy and leaves the original intact (checked under opcache.enable_cli). This matches the plain GC_ADDREF in zend_array_sort_ex.

Comment thread ext/standard/array.c
for (i = 0; i < num_arrays; i++) {
hashes[i] = Z_ARRVAL_P(arrays[i]);
GC_ADDREF(hashes[i]);
HT_ALLOW_COW_VIOLATION(hashes[i]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same array, so it's mutable here too. The flag is needed because the restructure's zend_hash_to_packed asserts RC1 under the held reference; php_splice() sets HT_ALLOW_COW_VIOLATION right after its own GC_ADDREF for the same reason.

@iliaal iliaal requested a review from ndossche July 11, 2026 11:11

@ndossche ndossche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay but I'm not a fan of the complication

Comment thread ext/standard/array.c
for (i = 0; i < array_size; i++) {
indirect[i] = indirects + (i * (num_arrays + 1));
}
hashes = (HashTable **)safe_emalloc(num_arrays, sizeof(HashTable *), 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cast is not necessary

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants