The coding challenge banned AI assistants
Kaspersky published Mirage Kitten's recruiter campaign on September 1. The lure is a take-home coding test, and its README makes three requests: finish in three hours, do not modify server.js because it is already bug-free, and do not use an AI assistant. Line one of server.js imports a trojanized npm package that was never published to npm. It shipped inside the zip, already sitting in node_modules. Every supply-chain control ever built watches a registry request this attack does not make. Bromure Agentic Coding argues the other half: the machine the code runs on.
The attacker asked you not to use a coding agent. A tool that opens every file in the repository whether or not the instructions say to was the one thing standing between this campaign and a backdoor, and the README talked you out of it.
A recruiter messages you on LinkedIn. The role is real enough, the company name is one you have heard of, and the next step is a take-home technical challenge. You get a zip. Inside is a project management app called TaskFlow, built with Express, React and Vite: a few hundred files and a README at the root. Fix the defects in the frontend. You have three hours.
This is the most ordinary thing that happens to a working engineer. It is also,
according to Kaspersky's September 1 write-up,
how an Iranian espionage crew that the report calls Mirage Kitten, tracked
elsewhere as UNC1549, Smoke Sandstorm or Nimbus Manticore, has been dropping
cross-platform backdoors on developers in Egypt, Ethiopia and Afghanistan, with
further submissions from India, Turkey, Israel, Iraq, Germany and Ireland. The
archives arrive with names like Front-Technical-Challenge.zip and
Task-FullStack.zip, hosted on AWS so the download looks like infrastructure
rather than a payload. The Hacker News
covered the campaign on September 9.
Three rules, all about reading
Kaspersky records three instructions in the README. Look at what each one buys the attacker.
The README also imposed a three-hour time limit and prohibited the use of AI assistants.
It also claimed that
server.jswas bug-free and should not be modified.
The clock is the obvious one: three hours to fix an unfamiliar codebase is a schedule that leaves no room for curiosity. The second rule aims your attention at the frontend and away from one specific file. The third rule is the interesting one, because it is the attacker telling you which tool they are afraid of.
A coding agent handed this repository does not honor the README's sense of
scope. Point Claude Code or Codex at the project and ask it to fix the frontend,
and it still reads server.js, because working out how the app boots is how it
works out what the frontend talks to. It reads the whole tree. It has no
three-hour clock and no reason to skip a file someone declared fine. Banning the
assistant removes the one reader in the room that reads everything, which is a
strange thing for a hiring process to insist on.
And what it would have read is line one.
The dependency was never on npm
The first line of server.js imports a package called colorized_terminal, at
version 2.1.0. A sibling sample uses pretty-log, also 2.1.0. Neither is a
typosquat of anything, and neither is a hijacked release, because neither is on
the registry at all:
The attackers bundled the package directly in the challenge task archive's
node_modulesdirectory rather than publishing it to the npm registry.
When imported, the package silently launched an implant from
node_modules/.cache/.320697f1/index.jsas a detached background process.
No npm install runs here, and no postinstall script. There is no lockfile
entry to verify, no integrity hash to compare, no provenance attestation to
check, no publish timestamp to age out, no reputation provider to query, and no
registry request for any of it to hang off. The dependency was in the folder
before you opened the folder. Running the app the README asks you to fix, which
is the whole assignment, means a plain require, and that require forks a
backdoor.
Three years of defenses against malicious packages all key off a registry fetch. Mirage Kitten makes none.
It does not stay in the challenge folder
NodeRabbit starts small. Kaspersky's first variant, from a machine in Afghanistan, carries eleven commands: read a file, write a file, list a directory, dump the network configuration, start a process, run a script, go to sleep. The Egyptian variant adds sandbox evasion, proxy support and dynamic port selection. The third, from Ethiopia, reaches twenty-three, and the new ones are where a developer's machine stops being one machine.
outlook:emails harvests mail artifacts. persist:projects:scan walks the disk
looking for git repositories. persist:project:inject writes into the ones it
finds, landing in .git/hooks/post-merge and .git/hooks/post-checkout so the
implant relaunches the next time you pull. persist:vscode installs a
malicious extension into your editor, which then loads it in every project you
open. Kaspersky's phrasing is exact about the intent:
Beyond the persistence mechanisms described above, Variant 3 introduces two additional persistence mechanisms that relaunch the malware through common developer workflows.
Your other repositories are the target. The plainer persistence underneath is
tuned per platform: a scheduled task or Run key firing at 10 AM daily on
Windows, an @reboot cron entry on Linux, a LaunchAgent in ~/Library with
RunAtLoad and KeepAlive both true on macOS.
The PollCat sibling arrives through a React challenge called
RankChallenge-react whose README shortens the clock to a one-hour session,
gated behind a six-digit code it claims rotates every thirty seconds. Same
pressure design, less time.
Run it, somewhere that is not your machine
Bromure Agentic Coding runs coding agents inside a hardware-virtualized Linux VM
on your Mac, with every security control on the host side of that boundary. A
stranger's zip is the job it was built for, because the controls describe the
room rather than the guest. None of them has to recognize colorized_terminal.
A workspace is the unit of isolation. One Ubuntu VM, its own system disk, its
own persistent home, its own credentials and its own policies, and two
workspaces never share a byte. Make a scratch workspace for the challenge. The
implant runs, the import fires, the detached process starts, and all of it
happens in a machine that came into existence this morning and holds one zip
file. When you are done, Reset disk re-clones the system disk from base and
Erase home… clears the home image; the per-boot rollback checkpoints behind
Restore home… do it at a finer grain. The @reboot cron entry the Linux
branch writes goes with them.
The Windows and macOS persistence branches have no ground to stand on. No
AppData, no Run key, no scheduled task, no ~/Library, no LaunchAgent, no
Outlook store to read. The guest is Linux and it is not your Linux.
persist:projects:scan sees only the folders you shared. Host folders are
attached as virtiofs mounts at /home/ubuntu/<basename>, capped at eight per
workspace, and they are the only part of your Mac's filesystem the VM can reach.
Share the challenge folder and nothing else, and the git-hook injection finds
one repository: the one that was already hostile. Keep the list short for a
second reason. A shared folder is a live window into your Mac, and a compromise
wipe leaves it alone by design.
fs:read comes back with placeholders. Bromure replaces every credential you
configure with a structure-preserving fake derived from the real value and a
per-install salt through HKDF-SHA256: sk-ant-api03-brm-…, a ghp_ token of
the right length, glpat-…, brm-k8s-…, brm-docker-…. The fakes are written
into the environment variables and into ~/.git-credentials,
~/.docker/config.json, ~/.kube/config, ~/.aws/config and the MCP configs,
which is the list an infostealer enumerates. The real values stay encrypted on
the Mac, and the host proxy swaps them onto the wire scoped to the destination
host each one was minted for. AWS goes further: the guest signs with a fake
secret key and the host re-signs, so a request that skips the proxy dies at AWS
with InvalidSignatureException. SSH private key bytes never enter the VM
at all, and only signatures cross. No toggle governs any of this, because the
proxy is the VM's only route to the network.
The C2 hosts are what an allow-list is for. Most of NodeRabbit's command
servers live under *.azurewebsites.net, and PollCat's do too. No reputation
feed will ever blocklist that domain, and no domain-age heuristic will flag it.
An allow-list does not care: in Guardrails, set Unmatched traffic to
Deny and write the two or three lines the job needs. Enforcement is host-side
and doubled: the virtual switch matches every flow by destination IP
and DNS-snooped hostname across all protocols, and the proxy matches again by
TLS server name and HTTP method. Transparent interception diverts the guest's
port-80 and port-443 traffic into the proxy with nothing inside the VM able to
unset it, which is the answer to the variant that shipped proxy support and
dynamic port selection.
And if the implant tries to carry a token out, the fake becomes a tripwire. The proxy scans every outbound request, headers and body, for a fake heading to a host it was not minted for. On a match it refuses the request without forwarding a byte, pauses the VM on the spot, files a red Credential brokering row in the Security Timeline, and marks the workspace compromised so the next launch wipes the disk and home first. You rotate nothing afterward, because what the implant held was a placeholder.
You can see the whole conversation. With tracing at Activity only the
proxy writes one metadata row per request and no bodies at all.
bromure-cli trace hostnames prints every distinct host the workspace contacted
with counts, so three azurewebsites.net addresses you never listed are one
command away rather than a forensic engagement.
The workspace to make before the zip arrives
A scratch workspace with no credentials configured beyond the agent's own
key, one shared folder for the archive, and Unmatched traffic set to
Deny in Guardrails. Add allow web registry.npmjs.org and whatever
else the project fetches. Saving pushes the rules to running sessions without
a reboot, so you can loosen them one line at a time as the build complains.
The two commands after you are done
bromure-cli trace hostnames <workspace> tells you where the code went, and
bromure-cli trace leaks <workspace> prints No leaks detected. ✓ when
nothing tried to carry a token out. Then Reset disk and Erase home…,
and the workspace is back to the image it started from.
The reader was never the control
Read server.js anyway. That is good advice and a poor defense, because the
next archive will put the import on line 340 of a file you have no reason to
open, or in a minified bundle, or behind a dynamic require assembled from two
strings. Mirage Kitten's three rules are a hedge against your attention, and
attention runs out at 4pm on a Friday with an hour left on the clock.
The place the code runs does not run out. The import fires either way and the detached process starts either way, so what you still get to choose is what that process finds when it looks around: a machine holding your GitHub token, your kubeconfig, your SSH key and forty repositories, or a Linux VM holding a zip file, a set of placeholders, and a network socket that goes only where you said.
Take the challenge. Use the coding agent the README told you not to use, and let
it read server.js first.
Install Bromure Agentic Coding and give the zip a machine that
does not mind.