Jan 27, 2026·6 min read·98 visits
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.
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 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.
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:
Monolog/RCE1)..phpt test file. Alongside it, they commit a binary file named testname.coverage containing the payload.phpunit --coverage-php..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 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.
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:
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 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:
12.5.811.5.5010.5.639.6.348.5.52If 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.
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
phpunit sebastianbergmann | < 8.5.52 | 8.5.52 |
phpunit sebastianbergmann | >= 9.0.0, < 9.6.34 | 9.6.34 |
phpunit sebastianbergmann | >= 10.0.0, < 10.5.63 | 10.5.63 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-502 |
| Attack Vector | Local (File System) |
| CVSS Score | 7.8 (High) |
| CVSS Vector | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| Impact | Remote Code Execution (RCE) |
| Context | CI/CD Pipelines / PHPT Testing |
The application deserializes untrusted data without sufficiently verifying that the resulting data will be valid.