Skip to content

unserialize() teardown should reset freed var entries before running destructors #24532

Open

Description

@alexandre-daubois

Description

The following code:

<?php

class Reenter
{
    public function __destruct()
    {
        @unserialize('a:1:{i:0;');
    }
}

$count = 1015;
$payload = 'a:' . ($count + 3) . ':{';

for ($i = 0; $i < $count; $i++) {
    $payload .= "i:$i;i:$i;";
}

$payload .= 'i:88888;i:7;i:99999;O:7:"Reenter":0:{}i:99999;i:42;}';

@unserialize($payload);
echo "done\n";

Resulted in an AddressSanitizer heap-use-after-free in php_var_unserialize(). var_destroy() frees the extended var_entries chain before releasing values that may invoke destructors, but leaves last and entries.next pointing to the freed chain; as a hardening measure, the context should be reset to its embedded entries block before invoking userland code.

PHP Version

PHP 8.5.12 (cli, NTS, ASan build)

Operating System

No response

Activity

  1. alexandre-daubois commented on Oct 22, 2026

    @alexandre-daubois
    MemberAuthor
  2. omaraltamimi1 commented on Oct 22, 2026

    @omaraltamimi1

    The write side of this reproduces standalone on stock 8.2.35 (NTS, unpatched) — a ~20 line payload, no harness: the re-entrant var_push() write lands at a wild index derived from the reused var_entries chunk and the process dies with SIGSEGV (1016 elements clean, 1017 fatal, with USE_ZEND_ALLOC=0).

    I put the reproducer and a proposed regression test for the write path in a comment on #24538, since gh24532.phpt covers the lifetime side and the re-entrant write is the part that turns this into memory corruption rather than a crash.

    One thing worth having in the scoping: there is a remote delivery path for this that needs no gadget class — two unauthenticated requests via session.upload_progress.enabled=1 plus session.serialize_handler=php_serialize, where the multipart payload is written into the session store verbatim and is then unserialised by any endpoint that reads session data. php_serialize is the default in several frameworks. I can share the request/response capture.

    The ASAN trace, the trigger-threshold sweep across 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 / 7.4.33, and the gdb capture of the free, the re-entrant read and the wild write are in my comments on #24481.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions