
WaterPlum's Fake Coding Test: How a Node Take-Home Became a Persistent RAT
The fake coding test that shipped a persistent RAT
Tom's Hardware published a report on 2026-09-20 describing a campaign by a group tracked as WaterPlum. The lure was fake job interviews and coding tests. Per that report, roughly 30,000 devices ended up carrying persistent remote access trojans, and about $10.7 million in crypto was stolen. This post covers what the report actually confirms, why the take-home test itself is the vulnerability, and the concrete checks and defenses that keep an untrusted Node repository from turning into a RAT on a developer machine.
Let me be clear about where I stand before going further. The RAT is the payload, not the vulnerability. The vulnerability is that the hiring pipeline ships a default instruction — clone this repo and run it — that developers follow without thinking, because refusing reads as failing the interview. Nothing has to be exploited. It only has to be obeyed.
Everything below credits that report for the campaign facts. I did not obtain samples, and I did not verify the delivery chain myself.
What the WaterPlum report confirms, and what it leaves out
| Claim | Status |
|---|---|
| Outlet is Tom's Hardware, published 2026-09-20 | Confirmed by source |
| WaterPlum used fake job interviews and coding tests as the lure | Stated by the report |
| ~30,000 devices infected | Stated by the report |
| ~$10.7M in crypto stolen | Stated by the report |
| Persistent RAT behavior on infected machines | Stated by the report |
| The exact install chain, script, or package names | Not in the source |
| File hashes, C2 infrastructure, OS coverage | Not in the source |
| Whether 30,000 means unique machines or repeated executions | Unclear from the source |
| Whether DPRK attribution rests on a published advisory | Unknown from this source |
That gap is the important part. The report hands over the social engineering shape and the scale, not the mechanics. Anyone who tells you they know the specific hook used here is guessing.
Why developer laptops are the high-value target
I have made this point before in other contexts and it has not stopped being true: a developer workstation is the densest credential store at most companies. SSH keys for production hosts, cloud role sessions, npm and GitHub tokens with publish rights, CI secrets pulled down for local debugging, VPN profiles, and — for a meaningful slice of this audience — crypto wallets in a browser extension or a local keystore.
Put that next to a phishing attachment. A malicious .docm needs a macro bypass, an EDR miss, and a user who ignores a warning. A coding test needs none of it. You hand someone a README and they run your code because the README told them to.
The trust boundary nobody audits: the take-home prompt
Look at what a normal take-home actually says:
- "Run
npm install && npm testto verify your changes." - "Open the repo in your IDE and make sure the tests pass."
- "Use
docker compose upto start the services." - "Review this PR and leave comments."
Every one of those sentences is a grant of remote code execution on the reviewer's or candidate's machine. Not through a bug — through policy. The reviewer wrote the instruction, the candidate followed it, and nothing in that exchange crossed a trust boundary anybody had drawn.
The teams I have seen get this right treat untrusted repositories the way they treat untrusted binaries. Most teams do not, because a repo looks like source code, and source code feels inert. It is not inert. It is a configuration file for a program that runs on your machine.
npm install executes lifecycle scripts from every dependency in the tree, not just the top-level package. Reading the README tells you nothing about what those scripts do.
The three execution sinks a coding test can abuse
| Sink | What runs automatically | What it can reach | Why teams miss it |
|---|---|---|---|
Local shell (npm/yarn/pnpm install) | preinstall, install, postinstall hooks across the whole dependency tree | $HOME, SSH keys, cloud credential files, shell rc files | Hooks live in transitive deps nobody opens |
IDE (.vscode/, workspace trust) | Tasks with runOn: folderOpen, debug configs, dev-container reopen, extension auto-update | The workspace folder and everything the IDE process can read | Workspace trust is often already granted, or dismissed by habit |
| Container / hosted runner | Entrypoint scripts, build steps, bind-mounted host paths, Docker socket access | Whatever is mounted, and the host if the socket or --privileged is exposed | Isolation is assumed rather than verified |
How little a hostile repository needs to gain a foothold
Almost nothing. One hook that fires before you read a line of the source is enough to establish presence. The snippet below is an inert demonstration of the surface, not a payload — it writes a single log line so you can see when it fires.
{
"name": "take-home-stub",
"version": "1.0.0",
"scripts": {
"postinstall": "node -e "require('fs').writeFileSync('install.log','postinstall ran')""
}
}The IDE path looks the same in spirit:
{
"version": "2.0.0",
"tasks": [
{
"label": "setup",
"type": "shell",
"command": "node ./scripts/setup.js",
"runOptions": { "runOn": "folderOpen" }
}
]
}
Modern VS Code gates this behind the workspace trust prompt. The gate only works if you read the prompt. On a machine where trust was already granted once for a parent folder, it does not ask again.
Reproducible checks to run before the next untrusted take-home
The point is not to catch this specific campaign. It is to make the general case dull. This is what I run.
Pull the package without executing it and read the scripts:
npm pack some-suspicious-package
tar -xOf some-suspicious-package-*.tgz package/package.json | jq '.scripts'
npm pack downloads the tarball and does not run lifecycle hooks, so the script block you read is the one that would have run.
Then install with hooks disabled, which should be the default for any tree you did not write:
npm install --ignore-scripts
To list what declared hooks before you install anything:
npm query ':attr(scripts, [postinstall])'
Expected output is a JSON array of installed dependencies whose manifests declare a postinstall. I have not captured that output here — treat it as the shape to expect, not an observed result.
To run the whole install with no path to the network:
docker run --rm --network=none \
-v "$PWD":/src -w /src node:20-slim \
sh -c "npm install --ignore-scripts"
If you want to watch syscalls, trace the process rather than trusting the script text:
strace -f -e trace=execve,openat,connect -o install.trace npm install
On macOS, strace is not available; dtruss needs SIP disabled, and fs_usage is the lower-friction option.
Finally, diff the repo against what the task claimed it was:
git log --oneline -20
git diff <claimed-base>..HEAD --stat
A "small bug fix exercise" whose history includes shell scripts added the day the repo was shared is worth a second look.
What each check proves, and the gaps it leaves
| Command | A clean result means | What it fails to catch |
|---|---|---|
npm pack + tarball read | That package declares no hooks in its manifest | Hooks in transitive deps, or code invoked by a test script |
npm install --ignore-scripts | No lifecycle hook from any package ran | IDE tasks, Makefile targets, a Dockerfile build, or a command you paste yourself |
npm query ':attr(...)' | Which installed manifests declare hooks | Malicious code inside a normal build script you run manually |
docker run --network=none | Nothing reached the network from that container | Credential files that were mounted into it |
git diff | The history matches the stated task | Anything introduced before the base commit you chose |
The gap that catches people: --ignore-scripts does nothing about a Makefile, a VS Code task, or the npm run dev you type after the install finishes.
What I did not test
I did not obtain samples from this campaign, did not reproduce the install chain, and cannot confirm the delivery mechanics past what the report describes. The checks above are what I use for untrusted repositories in general. Whether they would have caught this specific operation I do not know, and I would rather admit that than imply coverage I do not have.
Defense: make running untrusted candidate code boring and disposable
Roughly in order of how much they buy you:
- A disposable VM or container with egress restricted to the package registry. Why it works: the payload can run, but it cannot phone home, and the machine dies afterward. Where it fails: anything mounted into it, including
.envfiles you forgot were there. - A separate OS identity and SSH key for candidate work. Why it works: a compromise does not reach your production keys. Where it fails: if the same identity can still reach internal endpoints.
--ignore-scriptsby default. Why it works: it removes the most common auto-execution path. Where it fails: everything you run manually afterward.- Refuse workspace trust for any repo you did not author. Why it works: it disables the IDE as an auto-execution sink. Where it fails: once granted, it is sticky.
- Hardware-backed MFA on cloud and registry accounts. Why it works: stolen tokens without the second factor are less useful. Where it fails: long-lived CI secrets do not care about MFA.
- A written rule that anything reachable from the test machine gets rotated after the exercise. Why it works: it bounds the blast radius in time. Where it fails: it depends on someone actually recording what was reachable.
The honest trade-off: sandboxing costs a few minutes of setup and breaks some debugger workflows. The debugger complaint is real. I would still take the sandbox.
Endpoint controls that catch RAT persistence on developer machines
| Persistence surface | What to watch for |
|---|---|
~/Library/LaunchAgents (macOS) | New plists written by anything other than a known installer |
| User systemd units (Linux) | Units created outside package management, enabled on login |
HKCU\...\Run keys (Windows) | New entries under the user hive rather than machine hive |
| Editor extensions | Auto-update paths writing outside the extension directory |
| Shell rc files | Appended `curl |
Detection ideas that map to this: node spawning curl or sh, a process writing into a launch-agent or systemd user directory, outbound connections to hosts that are not your registry, and a secrets scanner firing on a developer workstation rather than in CI.
What I would fix first, in order
The order is not arbitrary.
#1: move the exercise into a disposable, egress-limited environment. This removes the entire class of problem, not one technique. It also survives the next campaign, which will not use postinstall.
#2: disable lifecycle scripts by default. Cheap config change, catches the highest-frequency path, and forces whoever runs the install to make a decision.
#3: isolate credentials so a compromise is survivable. This is the one most teams have not built, because it requires an architectural habit — short-lived credentials, per-task cloud roles, no production keys on a laptop used for candidate code.
The first two are settings. The third is culture, and that is why it is last.
What to tell a hiring manager about fake coding tests
If you own the hiring loop, here is the short version:
- Stop asking candidates to run your private repository with real environment files.
- Generate a per-candidate repository with no secrets and no production endpoints.
- Evaluate in a hosted runner you control, not on a personal machine.
- Review candidate submissions as untrusted code. The asymmetry runs both ways — the person grading the take-home is not the only one exposed.
The uncomfortable part: a hiring loop is a supply-chain dependency
This worked because "run this to prove yourself" is a social contract a candidate cannot easily refuse, while the risk lands on whoever executes the code. A hiring loop that requires executing a stranger's repository is a supply-chain dependency nobody put on the inventory. The strongest control here is procedural: give people a way to say I will run this in a sandbox and show you the output without it costing them the interview.
Further Reading
- Tom's Hardware, "North Korea used job interviews to deploy malware on 30,000 devices during coding tests," published 2026-09-20. This is the source for every campaign figure in this post. The link I have is a Google News redirect, not the publisher's canonical article URL, so I am citing the outlet, headline, and date rather than presenting a redirect as an original source.
- npm Docs — Lifecycle scripts, for the exact ordering of
preinstall,install, andpostinstall. - VS Code Docs — Workspace Trust, for what the trust prompt does and does not gate.


