---
title: "The wshu.net npm Campaign Delivers a Multi-Stage Infostealer"
description: "One actor seeded 15 npm packages across 13 throwaway scopes in a single morning, each shipping a ~270KB obfuscated downloader behind a postinstall hook. The downloader pulls a Rust infostealer from GitHub Releases that drains crypto wallets, browser credentials, cloud tokens, and SSH keys. Amazon Inspector tracks the same cluster as Operation Friday Harvest."
url: "https://safedep.io/blog/wshu-net-npm-credential-stealer-campaign"
author: "SafeDep Team"
published: "2026-06-23"
---

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

## 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](https://www.linkedin.com/pulse/operation-friday-harvest-coordinated-npm-campaign-delivering-tran-lmjlc/) 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](https://safedep.io/data/blogs/wshu-net-npm-credential-stealer-campaign.csv)

- `@zynkit/probe@0.0.1`, `@frostnode/probe@0.0.1`, and `@gleamkit/probe@0.0.1` 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/urlcat@1.4.2` 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/dater@0.9.4` and `@glitchpad/throttler@2.2.3` 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`.

```
apexcraft-8uiljr@wshu.net    bytemend-dwzfhq@wshu.net
chunklab-bpriqx@wshu.net     zynkit-sk393b@wshu.net
petitcode-eq1efk@wshu.net    tinyfox-yjwiqz@wshu.net
glitchpad-revqgz@wshu.net    thymelab-v0et8w@wshu.net
briskforge-5psyxc@wshu.net   lazyutil-78muyg@wshu.net
frostnode-gk8pbf@wshu.net    nullzero-rlnozk@wshu.net
```

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

```json
// @zynkit/jwtbytes package.json
"author": {
  "name": "zynkit",
  "email": "zynkit@pm.me",
  "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:

```json
// @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:

```javascript
// @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`](https://www.npmjs.com/package/retry) wrapper used as a decoy, calls into the payload on its first public function:

```javascript
// @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:

```javascript
// @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`](https://github.com/javascript-obfuscator/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/dater@0.9.4` and `@glitchpad/throttler@2.2.3` (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:

```javascript
// 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/jwtbytes@0.5.3` 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/urlcat@1.4.2` 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

- Chi Tran, Amazon Inspector. [Operation Friday Harvest: a coordinated npm campaign delivering an infostealer](https://www.linkedin.com/pulse/operation-friday-harvest-coordinated-npm-campaign-delivering-tran-lmjlc/). Independent analysis of the same cluster, including the second-stage Rust binary and Telegram C2.
- [`javascript-obfuscator`](https://github.com/javascript-obfuscator/javascript-obfuscator). The obfuscation toolchain behind the loader.
- [`retry`](https://www.npmjs.com/package/retry). The legitimate package whose code `@petitcode/eb-retry` copies as a decoy.
