The wshu.net npm Campaign Delivers a Multi-Stage Infostealer

SafeDep Team
11 min read

Table of Contents

TL;DR

A single actor published 15 npm packages across 13 throwaway scopes on June 4, 2026, most of them in a roughly 40 minute window (07:27 to 08:05 UTC), then expanded the version ranges over the following two weeks (64 malicious versions in total). Each package looks like a small developer utility (a JWT helper, a retry wrapper, a logger, a date library) but ships a roughly 260 to 282 KB obfuscated blob that a postinstall hook executes the moment the package is installed. That blob is a downloader. It fetches a roughly 10.6 MB Rust-compiled binary from GitHub Releases and runs it, and that binary is the infostealer. The packages share one publisher infrastructure (@wshu.net emails), one obfuscation toolchain, and one execution wrapper. Amazon Inspector’s security research team independently documented the same cluster as Operation Friday Harvest on June 19, 2026, including the second-stage binary that our static pass could not recover from the encrypted payload strings.

Impact:

  • Installing any malicious version runs an obfuscated downloader at install time, before any of your code executes
  • The downloader pulls and runs a multi-platform (Linux, macOS, Windows) Rust infostealer that targets 30+ crypto wallets, browser credentials (Chrome, Brave, Firefox, Edge), cloud tokens (AWS, GCP, Azure, Kubernetes), SSH keys, Discord and Telegram sessions, developer credentials (npm, .env, .npmrc, GitHub and PyPI tokens), and database client connection strings
  • The binary installs persistence as a systemd user service disguised as a normal daemon (colord, haveged), and exfiltrates over the Telegram Bot API (Windows) or multipart HTTP POST (Linux and macOS)
  • @petitcode/eb-retry and @briskforge/envcheck fire the payload at runtime too, the first time you call their exported function, which survives npm install --ignore-scripts
  • The latest tag of every package was scrubbed to hide the hook, so a quick metadata check is not a safe signal

Indicators of Compromise (IoC):

Every affected version across the 15 packages is listed below, one row each, with its postinstall, payload file, blob SHA256, publisher email, and a status column. Of the 64 versions, 44 still carry an active payload hook, 12 are scrubbed latest tags, 4 are intermediate releases with no hook, 3 are scope-reservation stubs, and 1 was unpublished. The SHA256 is filled only for the versions we downloaded and analyzed. Use the search box to filter (try a SHA256, a scope, or payload) and the download button to pull the raw CSV.

wshu-net-campaign-iocs.csv
ecosystemnameversionpostinstallpayload_filepayload_sha256publisher_emailstatusnotes
1npm@apexcraft/nano-key1.2.4node lib/seed.jslib/seed.js[email protected]payload
2npm@apexcraft/nano-key1.2.5node lib/seed.jslib/seed.js[email protected]payload
3npm@apexcraft/nano-key1.3.2node ./dist/cjs/seed.cjsdist/cjs/seed.cjs[email protected]payload
4npm@apexcraft/nano-key1.3.3node ./dist/cjs/seed.cjsdist/cjs/seed.cjs[email protected]payload
5npm@apexcraft/nano-key1.3.4node ./dist/cjs/seed.cjsdist/cjs/seed.cjs[email protected]payload
6npm@apexcraft/nano-key1.3.5node ./dist/cjs/seed.cjsdist/cjs/seed.cjs[email protected]payload
7npm@apexcraft/nano-key1.3.6node ./dist/cjs/seed.cjsdist/cjs/seed.cjs[email protected]payload
8npm@apexcraft/nano-key1.3.7node ./dist/cjs/seed.cjsdist/cjs/seed.cjs618dfffb6829356c131fded9f4c6528b73b4f9d7ff1fc1d3b457599a12584e29[email protected]payloadanalyzed (root lineage)
9npm@apexcraft/nano-key1.3.8(none)-[email protected]scrubbedlatest, payload removed
10npm@briskforge/envcheck0.5.2node lib/preflight.jslib/preflight.js[email protected]payload
11npm@briskforge/envcheck0.5.3node lib/preflight.jslib/preflight.js[email protected]payload
12npm@briskforge/envcheck0.5.4node lib/preflight.jslib/preflight.js26ddfae644673e0ad65b63caaaf67c0f7dc6c2b2b4127bb5271f8d03fb62091a[email protected]payloadanalyzed; dual-trigger (require-time)
13npm@briskforge/envcheck0.5.5(none)-[email protected]scrubbedlatest, payload removed
14npm@bytemend/mfebus1.4.0node dist/bootstrap.jsdist/bootstrap.js[email protected]payload
15npm@bytemend/mfebus1.4.1node dist/bootstrap.jsdist/bootstrap.js[email protected]payload
16npm@bytemend/mfebus1.4.2node dist/bootstrap.jsdist/bootstrap.jsa9a28f2e9f7e0348092682940b3bf63d47a63afc18eb8bfe628e5ddab0d73b47[email protected]payloadanalyzed
17npm@bytemend/mfebus1.4.3(none)-[email protected]no-hook
18npm@bytemend/mfebus1.4.4(none)-[email protected]no-hook
19npm@bytemend/mfebus1.4.5(none)-[email protected]scrubbedlatest, payload removed
20npm@chunklab/hexparse1.0.7node lib/prelude.jslib/prelude.js[email protected]payload
21npm@chunklab/hexparse1.1.4node ./script/prelude.cjsscript/prelude.cjs[email protected]payload
22npm@chunklab/hexparse1.1.5node ./script/prelude.cjsscript/prelude.cjs[email protected]payload
23npm@chunklab/hexparse1.1.6node ./script/prelude.cjsscript/prelude.cjs24c8f9b8ac17c2f88cc01d44543963206472112510962b68cf5f74d598b3b065[email protected]payloadanalyzed
24npm@chunklab/hexparse1.1.7(none)-[email protected]scrubbedlatest, payload removed
25npm@frostnode/probe0.0.1(none)-[email protected]stubscope-reservation, no payload
26npm@frostnode/waitfor0.9.0node lib/tickinit.jslib/tickinit.js[email protected]payload
27npm@frostnode/waitfor0.10.3node ./dist/cjs/tickinit.cjsdist/cjs/tickinit.cjs[email protected]payload
28npm@frostnode/waitfor0.10.4node ./dist/cjs/tickinit.cjsdist/cjs/tickinit.cjs[email protected]payload
29npm@frostnode/waitfor0.10.5node ./dist/cjs/tickinit.cjsdist/cjs/tickinit.cjs2de602e6422a991346aaf0b74ed6bd525215f5177b9f7f267ccb4d82e919273d[email protected]payloadanalyzed; require(_0x2cb1b0['UGeLH'])
30npm@frostnode/waitfor0.10.6(none)-[email protected]scrubbedlatest, payload removed
31npm@gleamkit/probe0.0.1(none)-[email protected]stubscope-reservation, no payload
32npm@glitchpad/throttler2.1.1node dist/primer.jsdist/primer.js[email protected]payload
33npm@glitchpad/throttler2.2.1node ./primer.cjsprimer.cjs[email protected]payload
34npm@glitchpad/throttler2.2.2node ./primer.cjsprimer.cjs[email protected]payload
35npm@glitchpad/throttler2.2.3node ./primer.cjsprimer.cjs68b4fe54a4c05cd0115535ebd4aa8d3cccb03ea5a685f440314814ba1b89e875[email protected]payloadanalyzed; blob == @lazyutil/[email protected]
36npm@glitchpad/throttler2.2.4(none)-[email protected]scrubbedlatest, payload removed
37npm@lazyutil/dater0.8.1node lib/tzinit.jslib/tzinit.js[email protected]payload
38npm@lazyutil/dater0.9.2node ./dist/lib/tzinit.cjsdist/lib/tzinit.cjs[email protected]payload
39npm@lazyutil/dater0.9.3node ./dist/lib/tzinit.cjsdist/lib/tzinit.cjs[email protected]payload
40npm@lazyutil/dater0.9.4node ./dist/lib/tzinit.cjsdist/lib/tzinit.cjs68b4fe54a4c05cd0115535ebd4aa8d3cccb03ea5a685f440314814ba1b89e875[email protected]payloadanalyzed; blob == @glitchpad/[email protected]
41npm@lazyutil/dater0.9.5(none)-[email protected]scrubbedlatest, payload removed
42npm@nullzero/urlcat1.4.3(none)-[email protected]no-hook
43npm@nullzero/urlcat1.4.4(none)-[email protected]no-hook
44npm@nullzero/urlcat1.4.5(none)-[email protected]scrubbedlatest, payload removed
45npm@nullzero/urlcat1.4.2node lib/encoder.jslib/encoder.js[email protected]unpublishedremoved before capture; payload unverified
46npm@petitcode/eb-retry1.3.3node lib/warmup.jslib/warmup.js[email protected]payload
47npm@petitcode/eb-retry1.3.4node lib/warmup.jslib/warmup.js[email protected]payload
48npm@petitcode/eb-retry1.3.5node lib/warmup.jslib/warmup.js32d02f806d58a6670f7cc9b93f1d85b22e0e0f535e1f90a62d86918033896f54[email protected]payloadanalyzed; dual-trigger (require-time)
49npm@petitcode/eb-retry1.3.6(none)-[email protected]scrubbedlatest, payload removed
50npm@thymelab/logfx2.15.3node dist/bootstrap.jsdist/bootstrap.js[email protected]payload
51npm@thymelab/logfx2.15.4node dist/bootstrap.jsdist/bootstrap.js[email protected]payload
52npm@thymelab/logfx2.15.5node dist/bootstrap.jsdist/bootstrap.js4e927f22ad04f4ac9b487ae11412fc2a55210188789ac29f3a47ad77931907a5[email protected]payloadanalyzed; decoy logger
53npm@thymelab/logfx2.15.6(none)-[email protected]scrubbedlatest, payload removed
54npm@tinyfox/shapecheck0.7.4node lib/bootstrap.jslib/bootstrap.js[email protected]payload
55npm@tinyfox/shapecheck0.8.5node dist/bootstrap.cjsdist/bootstrap.cjs[email protected]payload
56npm@tinyfox/shapecheck0.8.6node dist/bootstrap.cjsdist/bootstrap.cjs[email protected]payload
57npm@tinyfox/shapecheck0.8.7node dist/bootstrap.cjsdist/bootstrap.cjs0d27ca72b6f02faf4db95effb18347a7e2fa2def2034707bf9e56fa217879a3b[email protected]payloadanalyzed; decoy type validator
58npm@tinyfox/shapecheck0.8.8(none)-[email protected]scrubbedlatest, payload removed
59npm@zynkit/jwtbytes0.4.3node lib/prelude.jslib/prelude.js[email protected]payload
60npm@zynkit/jwtbytes0.5.1node dist/prelude.cjsdist/prelude.cjs[email protected]payload
61npm@zynkit/jwtbytes0.5.2node dist/prelude.cjsdist/prelude.cjs[email protected]payload
62npm@zynkit/jwtbytes0.5.3node dist/prelude.cjsdist/prelude.cjsd06ee17d30ebb333ab2e5b6e8a1324fcf95edaaae17b6793ec0f3647338efda1[email protected]payloadanalyzed; decoy base-N encoders
63npm@zynkit/jwtbytes0.5.4(none)-[email protected]scrubbedlatest, payload removed
64npm@zynkit/probe0.0.1(none)-[email protected]stubscope-reservation, no payload
64 rows
| 9 columns
  • @zynkit/[email protected], @frostnode/[email protected], and @gleamkit/[email protected] are payload-free stubs by the same actor, used to reserve their scopes
  • Second-stage delivery and exfiltration infrastructure: GitHub Releases under github[.]com/angelmaybeth21-oss/test (repo removed, account live, created 2026-06-03), secondary account smilingdusty233, and the Telegram Bot API (api[.]telegram[.]org, 149[.]154[.]166[.]110)
  • Second-stage binary SHA256: Linux ELF 2457b2e775a5fe7a9e022ba77074a1b9aacb41b4fc0cc1d8a3dc66546599c5de, macOS Mach-O b1c7b17f31a84e2596250121c3610ae5e0d592651940dd6c0dd74506f0f38313, Windows win.js 11fe3a47333f63fd0e0a32ea16351eb302659aba983c07e4ea3dc9b09b618509
  • Host artifacts: /tmp/_installer-0/, ~/.local/bin/<daemon>, ~/.config/systemd/user/<daemon>.service, ~/.local/state/<daemon>/{install.nonce,machine.id}
  • @nullzero/[email protected] was published from nullzero-rlnozk@wshu[.]net but has since been unpublished, so its payload could not be statically inspected. The versions that remain (1.4.3 to 1.4.5) carry no install hook
  • @lazyutil/[email protected] and @glitchpad/[email protected] ship the byte-identical payload blob (SHA256 68b4fe54a4c05cd0115535ebd4aa8d3cccb03ea5a685f440314814ba1b89e875)
  • Publisher npm emails: <scope>-<6 random chars>@wshu[.]net (for example apexcraft-8uiljr@wshu[.]net, briskforge-5psyxc@wshu[.]net, frostnode-gk8pbf@wshu[.]net)
  • Author identity in every package.json: <scope>@pm[.]me, homepage github[.]com/<scope>
  • Obfuscated bootstrap filenames seen so far: seed, bootstrap, prelude, warmup, primer, preflight, tzinit, tickinit, encoder (.cjs / .js)
  • All scopes created June 4, 2026, between 07:27 and 08:05 UTC

Analysis

One actor, many scopes

These packages share a publisher even where they share no code. Every malicious version came from an npm account whose email follows one template, the scope name, a hyphen, six random characters, then @wshu[.]net.

Each package.json carries a matching author block pointing at a per-scope identity:

// @zynkit/jwtbytes package.json
"author": {
"name": "zynkit",
"email": "[email protected]",
"url": "https://github.com/zynkit"
}

The npm registry time.created timestamps put every scope inside a tight window on June 4, 2026, the earliest at 07:27 UTC and the latest just after 08:05. One scope per package keeps each account looking like an independent small project, which is why a maintainer based search does not connect them. The shared signal is the wshu[.]net publisher domain. wshu[.]net is a public disposable-email provider, so the domain alone also catches unrelated throwaway accounts, and npm’s public search does not index publisher emails anyway. The tighter fingerprint is the shared obfuscation template plus the 2026-06-04 publish burst. Investigators keep finding more scopes, so treat this member list as a lower bound.

Execution trigger

Every package declares a postinstall hook that runs its payload file directly:

// @zynkit/jwtbytes package.json
"scripts": {
"postinstall": "node dist/prelude.cjs"
}

The payload file is structured so that running it as a script fires the payload immediately. The obfuscated logic is wrapped in a guarded function, and the file ends by calling that function when it is the entry module:

// @zynkit/jwtbytes dist/prelude.cjs
'use strict';
let _fired = false;
function runPrepare() {
if (_fired) return;
_fired = true;
(function(_0x4b6d34,_0x558c8c){var _0x2dc988={_0x3f5b57:'$Dz^', /* ...~282KB of obfuscation... */
_0x175f();
}
function onInstall() { runPrepare(); }
exports.runPrepare = runPrepare;
exports.onInstall = onInstall;
exports.default = runPrepare;
module.exports.runPrepare = runPrepare;
if (require.main === module) onInstall();

node dist/prelude.cjs makes require.main === module true, so onInstall() runs runPrepare() and the payload executes. The same wrapper, byte for byte in its readable parts, appears in every payload file we inspected, renamed per scope: seed.cjs, bootstrap.js, bootstrap.cjs, prelude.cjs, warmup.js, primer.cjs, preflight.js, tzinit.cjs, and tickinit.cjs.

@petitcode/eb-retry adds a second trigger. Its main entry, a near verbatim copy of the legitimate retry wrapper used as a decoy, calls into the payload on its first public function:

// @petitcode/eb-retry lib/index.js
var retrier = require('retry');
var warmup = require('./warmup');
function retry(fn, opts) {
warmup.runPrepare();
// ...legitimate retry logic copied from the real package...
}

So even if postinstall is skipped with --ignore-scripts, the payload still runs the first time application code calls retry(). @briskforge/envcheck does the same, and even documents it in a comment above the hook:

// @briskforge/envcheck lib/preflight.js
// Internal preflight hook ... runs once per process from the library entry
// point, and once again as a standalone script during postinstall.

Its lib/index.js calls require('./preflight') and preflight.runPrepare(), giving the same install-time plus runtime double trigger.

The decoy surface

Each package ships real, working utility code so it passes a glance. @zynkit/jwtbytes includes genuine base32, base58, base64, hex, and ascii85 encoders. @tinyfox/shapecheck ships a runtime type validator. @glitchpad/throttler ships a real throttler. The malicious file sits beside that code and dwarfs it. In @zynkit/jwtbytes, the largest legitimate source file is about 13 KB while dist/prelude.cjs is 282 KB. The blob is always the single largest file in the package, and the gap is wide: from roughly 4x the largest real file in @glitchpad/throttler to over 200x in @petitcode/eb-retry. When you spot one large opaque file next to a handful of small readable modules, check the size gap first.

Payload

The payload is a javascript-obfuscator artifact. It uses hex named identifiers, a while (!![]) array rotation IIFE that reorders a string array at load time, and a base64 plus RC4 string decoder (the _0x25ff function, indexing into a 1267 entry array with a 0x184 offset). Each string is individually RC4 keyed, and the keys come from control flow flattened proxy objects rather than literals at the call site.

The blobs fall into a handful of build clusters, each with its own obfuscator seed, string-array function, and re-entrancy guard variable. The string-array function we decoded differs by scope (_0x175f in @zynkit/jwtbytes, _0xe119 in @briskforge/envcheck, _0x15fd in @frostnode/waitfor), which lines up with the five clusters (A through E) that Amazon Inspector mapped. Packages in the same cluster can carry a byte-identical blob: @lazyutil/[email protected] and @glitchpad/[email protected] (both cluster B) ship the exact same compiled payload (SHA256 68b4fe54...), a byte-level link between two otherwise unrelated scopes.

The payload also resolves modules by a name decrypted at runtime, keeping them out of a static module graph. The same obscured require appears, with the same variable name, across several of the packages:

// resolved at runtime from a decrypted string, identical var name across packages
require(_0x45af03['GyrZN']);

Because every string literal is RC4 encrypted behind that proxy machinery, the concrete URLs and file paths are not readable from a static pass, and we did not execute the payload to recover them. Running the obfuscated control flow, even inside a Node vm context, is not a safe boundary for code we already know to be malicious, so we did not do it.

Amazon Inspector ran the payload in a controlled environment and recovered what the encrypted strings hide. The obfuscated JavaScript is a loader. It spawns a detached child process behind environment-variable guards, downloads a roughly 10.6 MB Rust-compiled binary from a GitHub Releases URL (github[.]com/angelmaybeth21-oss/test/releases/download/v1.0.0/{linux,mac,win.js}), and executes it. The repository is now removed, but the publishing account angelmaybeth21-oss (created 2026-06-03, the day before the npm burst) and a second account smilingdusty233 are still live.

The Rust binary is the infostealer. Per Amazon Inspector’s analysis it sweeps 30+ cryptocurrency wallets, browser credential stores, cloud provider tokens, SSH keys, Discord and Telegram sessions, developer credentials, and database client configs, then installs persistence as a systemd user service masquerading as a benign daemon (colord, haveged). The Windows variant exfiltrates over the Telegram Bot API (api[.]telegram[.]org); the Linux and macOS variants use multipart HTTP POST and gate exfiltration behind anti-VM checks, which is why the endpoint was hard to confirm from a sandbox.

A version history built to look clean

The payload never sits at latest. For every member, it lives in a band of mid versions while the latest tag points somewhere safer. The actor cleans up two ways.

The first is scrubbing. After the stealer versions go live, the actor publishes one more version with an empty scripts block and no payload, then lets npm move latest to it. @frostnode/waitfor shows the shape directly in its postinstall field across versions:

// @frostnode/waitfor, postinstall by version
0.9.0 node lib/tickinit.js earlier release
0.10.3 node ./dist/cjs/tickinit.cjs payload
0.10.4 node ./dist/cjs/tickinit.cjs payload
0.10.5 node ./dist/cjs/tickinit.cjs payload
0.10.6 (none) latest, scrubbed

So npm view @zynkit/jwtbytes reports no install hook while npm install @zynkit/[email protected] still pulls the stealer from the mid version. A reviewer who checks the default version sees clean metadata. A lockfile or a transitive dependency that already resolved 0.5.3 keeps the payload.

The second is unpublishing. @nullzero/[email protected] carried the stealer (postinstall: node lib/encoder.js, per external triage), but the actor removed that version from the registry before we could pull the tarball. The scope still exists, the publisher is still nullzero-rlnozk@wshu[.]net, and the surviving versions 1.4.3 to 1.4.5 are scrubbed. A deleted version leaves the smallest footprint of all: no artifact to analyze, and npm view lists only clean releases.

Both moves target the same blind spot. A scanner or a human who judges a package by its current latest sees a working utility. The stealer is one or two versions back, where an install pinned to an older range, a warm cache, or a registry mirror still reaches it. The same @wshu.net account published the early version, the payload, and the scrubbed tag, so treat every version of every listed scope as untrusted no matter how clean a single release looks.

Conclusion

This is a templated campaign. One loader, one second-stage binary, one obfuscation toolchain, and one publisher infrastructure, replicated across throwaway scopes with renamed bootstrap files and innocuous utility names. Do not install any version of any listed package. If one already reached a developer machine or CI runner, treat that host as fully compromised. The second-stage binary reaches crypto wallets, browser stores, cloud and SSH credentials, messaging sessions, and developer tokens, so rotate all of them, kill the systemd user service, remove its artifacts under ~/.local/bin, ~/.config/systemd/user, and ~/.local/state, and invalidate active sessions.

The discoverable members stop where npm’s public search does. Maintainer based pivoting goes nowhere because each account holds a single payload package. The through-line across every member is the publisher infrastructure, the per-scope <name>-<rand>@wshu[.]net burner emails. Because wshu[.]net is a shared disposable-email provider, the domain on its own is noisy, so the reliable pivot pairs it with the obfuscation fingerprint, the 2026-06-04 publish burst, and the angelmaybeth21-oss delivery account. Only npm Trust and Safety can run that correlation server side to surface the scopes that are not yet known. We reported the cluster with wshu[.]net as the starting pivot, and Amazon Inspector reached the same set independently.

References

  • vet
  • malware
  • npm
  • supply-chain
  • credential-theft
  • infostealer
  • obfuscation

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

MYRA: A Full Linux RAT Distributed via npm

MYRA: A Full Linux RAT Distributed via npm

The npm package apintergrationpost is a red team RAT called MYRA with native C rootkit, triple persistence, fileless execution, live screen streaming, and process masquerade. This analysis documents...

SafeDep Team
Background
SafeDep Logo

Ship Code.

Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.