Back to all posts
Published on · by Renaud Deraison

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.js was 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_modules directory rather than publishing it to the npm registry.

When imported, the package silently launched an implant from node_modules/.cache/.320697f1/index.js as 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.

The supply chain that never touches a registryFront-Technical-Challenge.zipREADME.mdthree hours · no AI assistants"server.js is bug-free"server.jsline 1: require('colorized_terminal')the file you were told to skipnode_modules/[email protected]already installed · not on npmyou run the appthe import forks an implantnode_modules/.cache/.320697f1/index.jsdetached background processNodeRabbit calls homeplugplay.azurewebsites.netrgbteller.azurewebsites.net11 commands, then 23what never fires, and whyno registry request is ever madeage gate — no publish timestampintegrity hash — no lockfile entryprovenance — nothing was publishedreputation feed — no package to look upscript stripping — no install steptyposquat check — no name to comparewhat is left is the machine it runs on
The archive arrives with its dependencies already installed, so the supply chain never touches a registry. Line one of the file the README told you not to open imports a package that exists nowhere else, and importing it forks an implant out of a dot-directory inside node_modules. No install step, no lockfile check, no publish timestamp: the controls that guard package fetches have no fetch to guard.

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.

on the laptopthe implant inherits your whole accountpersist:projects:scanevery repo on disk gets a post-merge hookpersist:vscodean extension that loads in every projectoutlook:emails · fs:read~/.aws · ~/.kube · ~/.git-credentialscleanupfind the hooks, rotate the keys, hopein a workspaceone Ubuntu VM, its own disk, its own homethe scan sees what you mountedshared folders only, eight at mostpersistence lands in a disk you can re-cloneReset disk · Erase home · boot checkpointsevery credential file holds a fakeghp_… · brm-k8s-… · sk-ant-api03-brm-…cleanupwipe the disk and home, rotate nothing
One archive, two blast radii. On a laptop, the implant inherits the whole account: every git repository it can find gets a hook, the editor gets an extension, mail and cloud config are readable, and the credentials in the shell environment are the real ones. In a Bromure Agentic Coding workspace the same commands run against a Linux VM that holds placeholder credentials, mounts only the folders you shared, and reaches the network through a socket on the host.

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.