Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions demos/supply-chain/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
reqeusts/__pycache__/
99 changes: 99 additions & 0 deletions demos/supply-chain/README.md
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +94 to +99
85 changes: 85 additions & 0 deletions demos/supply-chain/bare-metal/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/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" \
SUPPLY_CHAIN_DEMO=1 \
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 ""
57 changes: 57 additions & 0 deletions demos/supply-chain/c2_server.py
Original file line number Diff line number Diff line change
@@ -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(("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()
except KeyboardInterrupt:
print("\n[C2] Stopped.")


if __name__ == "__main__":
main()
26 changes: 26 additions & 0 deletions demos/supply-chain/hl-unikraft/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 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
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

# --- 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
55 changes: 55 additions & 0 deletions demos/supply-chain/hl-unikraft/Justfile
Original file line number Diff line number Diff line change
@@ -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}} }
64 changes: 64 additions & 0 deletions demos/supply-chain/hl-unikraft/kraft.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading