The CI/CD Trojan Horse: Inside PHPUnit's Unsafe Deserialization
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:
- 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). - The Trap: The attacker creates a new branch on the target repository. They add a harmless-looking
.phpttest file. Alongside it, they commit a binary file namedtestname.coveragecontaining the payload. - Execution: The attacker opens a Pull Request. The automated CI system kicks in, running
phpunit --coverage-php. - Detonation: PHPUnit runs the
.phpttest. Even if the test fails or does nothing, thePhptTestCaserunner callscleanupForCoverage(). It sees the pre-existing.coveragefile (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.
Official Patches
Fix Analysis (2)
Technical Appendix
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HAffected Systems
Affected Versions Detail
| 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 |
MITRE ATT&CK Mapping
The application deserializes untrusted data without sufficiently verifying that the resulting data will be valid.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.