CVE-2026-24765

The CI/CD Trojan Horse: Inside PHPUnit's Unsafe Deserialization

Amit Schendel
Amit Schendel
Senior Security Researcher

Jan 27, 2026·6 min read·2 visits

Executive Summary (TL;DR)

PHPUnit's PHPT runner blindly unserialized content from `.coverage` files without validating the class structure. By placing a malicious file in the test directory (e.g., via a Pull Request), an attacker can trigger a PHP gadget chain when the test runner executes, leading to RCE. This was fixed in versions 8.5.52, 9.6.34, 10.5.63, 11.5.50, and 12.5.8 by validating file existence before execution and whitelisting allowed classes during deserialization.

A critical insecure deserialization vulnerability in PHPUnit's PHPT test runner allows local attackers to achieve Remote Code Execution (RCE) by crafting malicious coverage files. This flaw is particularly dangerous in CI/CD environments, where it can be leveraged to compromise build pipelines via malicious Pull Requests.

The Hook: Trusting the Filesystem

We tend to view our testing frameworks as the arbiters of truth—the boring, reliable tools that tell us if our code is broken. But in the security world, "boring" usually just means "unexamined." PHPUnit, the de facto standard for PHP testing, recently reminded us that even the tools checking our security can be the source of insecurity. The vulnerability, CVE-2026-24765, isn't a complex buffer overflow or a subtle race condition. It’s the classic blunder of the PHP world: Unsafe Deserialization.

Specifically, this bug hides in the PHPT runner. For those uninitiated, PHPT is a regression testing format used primarily by PHP internal developers and extension maintainers. It’s a powerful way to test PHP itself, spawning separate processes to run code and capturing the output. When you add code coverage to the mix, these child processes need a way to report back to the parent process about which lines of code executed.

The mechanism for this reporting was simple: write a serialized object to a temporary file on disk, and have the parent read it back. It sounds efficient, but it relies on a fatal assumption: that the file on the disk was actually written by the child process and not planted by a malicious actor lurking in the repository.

The Flaw: A Classic Sink

The vulnerability resides in src/Runner/PhptTestCase.php, inside a method called cleanupForCoverage(). When PHPUnit finishes running a PHPT test, it looks for a file—typically sharing the name of the test but ending in .coverage. This file is supposed to contain a SebastianBergmann\CodeCoverage\RawCodeCoverageData object.

Here is the logic flaw in its rawest form. The code grabs the file contents and passes them directly to unserialize(). Before PHP 7, unserialize() was a loaded gun. In modern PHP, it's still a loaded gun, but we have a safety catch (allowed_classes). PHPUnit, however, was running with the safety off.

// The offending logic (simplified)
private function cleanupForCoverage(): array
{
    // ... determines filename ...
    $buffer = @file_get_contents($files['coverage']);
    if ($buffer !== false) {
        // FATAL ERROR: No filter on what classes can be instantiated
        $coverage = @unserialize($buffer);
        // ...
    }
}

Because there were no restrictions on allowed_classes, the PHP runtime would happily instantiate any class defined in the current scope. If the project includes libraries like Monolog, Guzzle, or even parts of PHPUnit itself that contain "magic methods" (like __destruct or __wakeup), an attacker can craft a "gadget chain." This chain allows them to turn a simple file read into full Remote Code Execution (RCE) purely by manipulating object properties.

The Exploit: Poisoning the Pipeline

You might be thinking, "But this is a local file vulnerability. I need write access to the server to exploit it!" In a traditional web hosting environment, you'd be right. But this is a testing framework. Where does it run? CI/CD Pipelines.

The attack vector here is "Poisoned Pipeline Execution" (PPE). An attacker doesn't need to hack the server; they just need to submit a Pull Request. Here is the kill chain:

  1. Preparation: The attacker generates a malicious serialized payload using a tool like PHPGGC. They target a gadget chain present in the vendor directory (e.g., Monolog/RCE1).
  2. The Trap: The attacker creates a new branch on the target repository. They add a harmless-looking .phpt test file. Alongside it, they commit a binary file named testname.coverage containing the payload.
  3. Execution: The attacker opens a Pull Request. The automated CI system kicks in, running phpunit --coverage-php.
  4. Detonation: PHPUnit runs the .phpt test. Even if the test fails or does nothing, the PhptTestCase runner calls cleanupForCoverage(). It sees the pre-existing .coverage file (the trap), deserializes it, and triggers the gadget chain.

The result? The attacker can execute arbitrary shell commands inside your CI runner. They can dump your AWS_SECRET_ACCESS_KEY, DATABASE_URL, or inject backdoors into the build artifacts that are about to be deployed to production.

The Code: Fixing the Leak

The remediation applied by Sebastian Bergmann (the creator of PHPUnit) was two-fold, implementing both a "Fail-Fast" check and "Defense in Depth" hardening.

1. The Fail-Fast Check: The first change was logical. A coverage file should be generated during the test. If it exists before the test starts, something is wrong. The patch adds a check in the constructor to throw an exception if the coverage file is already present.

// src/Runner/PhptTestCase.php
private function ensureCoverageFileDoesNotExist(): void
{
    $files = $this->getCoverageFiles();
    if (file_exists($files['coverage'])) {
        throw new Exception(
            sprintf('File %s exists, PHPT test %s will not be executed',
            $files['coverage'], $this->filename)
        );
    }
}

2. The Hardened Unserialize: The second fix addresses the root cause. The unserialize call now strictly whitelists the only class that should ever be in that file: RawCodeCoverageData.

$coverage = @unserialize(
    $buffer,
    [
        'allowed_classes' => [
            RawCodeCoverageData::class,
        ],
    ]
);

> [!NOTE] > Regression Drama: The initial fix attempted to set allowed_classes => false, assuming the data was simple arrays. This broke functionality because the data is actually an object. The patch had to be quickly revised (in versions like 9.6.34) to explicitly allow RawCodeCoverageData::class.

The Impact: Why You Should Panic (Just a Little)

This vulnerability scores a 7.8 (High) on the CVSS scale, and rightfully so. While it requires "Local" access, in the context of modern DevSecOps, the definition of "Local" has shifted.

If you run open-source projects or accept contributions from the public, your CI pipeline is effectively a "public-facing" application. This vulnerability turns a standard unit test run into a potential compromised server. The impact ranges from:

  • Credential Theft: Stealing environment secrets available to the CI runner.
  • Supply Chain Attacks: Modifying the build output (e.g., injecting malicious JS into a compiled frontend asset) before it gets pushed to production.
  • Lateral Movement: Using the CI runner's permissions to access internal networks or cloud resources.

It serves as a stark reminder that unserialize() on file contents is rarely safe unless you have absolute certainty about who wrote that file.

The Fix: Remediation

The fix is straightforward: Update PHPUnit. The maintainers have backported fixes to all supported release lines. You should ensure your composer.lock resolves to at least one of the following versions:

  • PHPUnit 12: 12.5.8
  • PHPUnit 11: 11.5.50
  • PHPUnit 10: 10.5.63
  • PHPUnit 9: 9.6.34
  • PHPUnit 8: 8.5.52

If you cannot upgrade immediately, you can mitigate this risk by ensuring your CI pipeline cleans the workspace aggressively before running tests. Specifically, ensure no .coverage files exist in your test directories prior to execution. However, given the nature of git (which allows checking in binary files), a code upgrade is the only robust solution.

Fix Analysis (2)

Technical Appendix

CVSS Score
7.8/ 10
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Affected Systems

PHPUnit 8.x < 8.5.52PHPUnit 9.x < 9.6.34PHPUnit 10.x < 10.5.63PHPUnit 11.x < 11.5.50PHPUnit 12.x < 12.5.8

Affected Versions Detail

Product
Affected Versions
Fixed Version
phpunit
sebastianbergmann
< 8.5.528.5.52
phpunit
sebastianbergmann
>= 9.0.0, < 9.6.349.6.34
phpunit
sebastianbergmann
>= 10.0.0, < 10.5.6310.5.63
AttributeDetail
CWE IDCWE-502
Attack VectorLocal (File System)
CVSS Score7.8 (High)
CVSS VectorCVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
ImpactRemote Code Execution (RCE)
ContextCI/CD Pipelines / PHPT Testing
CWE-502
Deserialization of Untrusted Data

The application deserializes untrusted data without sufficiently verifying that the resulting data will be valid.

Vulnerability Timeline

Initial Security Fix Committed
2026-01-26
GHSA Advisory Published
2026-01-27
Regression Identified & Fixed
2026-01-27

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.