Tech17 min read

DPRK npm packages mimic Rollup polyfills: fire via require(), not postinstall

IkesanContents

TL;DR

What happened JFrog reported 6 npm packages posing as Rollup polyfill tooling: rollup-packages-polyfill-core, rollup-runtime-polyfill-core, swift-parse-stream, quirky-token, react-icon-svgs, rollup-plugin-polyfill-connect. Any dev machine or CI that loaded the CommonJS side through a Rollup or build config counts as executed

What’s different Not postinstall — the entry package’s CommonJS load runs npm install --no-save on a stage-two package, which fetches JavaScript from JSONKeeper and evals it. This sits entirely outside npm v12’s install-script allowlist

What to check Lockfiles, internal npm proxies, CI caches, pack/scdata/ldata in temp directories, traffic to 216.126.236[.]244, VS Code/Windsurf/Cursor history, and exposure around .aws, .azure, .ssh, .gemini, .claude


JFrog Security Research reported a cluster of npm packages posing as Rollup polyfill tooling.
The Hacker News covered it on July 3.
This isn’t sloppy typosquatting on the name alone — it copies the legitimate rollup-plugin-polyfill-node’s README, repository URL, homepage URL, and package layout.

JFrog names rollup-packages-polyfill-core and rollup-runtime-polyfill-core as the entry points.
Both reuse the same rollup, polyfill, core, and node vocabulary as the legitimate package.
Skim a dependency review too fast and the names read like something in Rollup’s Node.js polyfill family.

The legitimate rollup-plugin-polyfill-node sits at roughly 295K weekly downloads and over 1.2 million in the past month, per JFrog’s numbers at investigation time.
The attacker planted new packages in that same naming space rather than typo-squatting the exact name.
Since none of the fake names are a one-character edit of the real one, simple typo-distance detection doesn’t catch them.

The entry point differs from how UNC1069 went after axios maintainers.
That campaign used social engineering to take over a legitimate maintainer’s account and ship a poisoned version of a real package.
Here, the attacker doesn’t compromise an existing package at all — they publish new packages under similar-sounding names and slip through the gap in dependency review.
As StepSecurity noted in its axios post-mortem, account-takeover campaigns like UNC1069’s have gotten harder as npm’s mandatory 2FA and SLSA provenance-backed publishing have spread.
Publishing a brand-new package under your own account skips both the account takeover and the CI tampering.

How stage two loads through the CommonJS require

The entry point here isn’t an npm install-time lifecycle script.
JFrog’s analysis found the malicious code appended only to the entry package’s CommonJS file, dist/index.js — the ESM counterpart, dist/es/index.js, carried none of it.

Sticking to CJS is about execution timing.
CommonJS require() evaluates a module synchronously the moment it’s called.
A side effect written at the top level runs before the importing code ever gets to run its own logic.
ESM’s import also evaluates modules statically, but bundlers tree-shake away unused exports.
For the attacker, the CJS side is the one that’s guaranteed to actually execute.

The three malicious node-ipc versions reported in May 2026 used the same mechanism.
That one appended an obfuscated IIFE to the end of node-ipc.cjs, and a single require("node-ipc") was enough to daemonize a child process.
The ESM-side node-ipc.js had nothing added.
This makes the CJS-entrypoint trigger a reused pattern across at least two North Korea-linked campaigns.

In any environment where the Rollup config file rollup.config.js or Vite’s vite.config.ts is written in CJS, or where the bundler resolves a CJS entry point, the attack code runs on every build.
In the node-ipc case, require got called through an Electron app or an IPC library; here, evaluating the build tool’s config file is the trigger.
Builds run repeatedly during development, so making the build environment the trigger point fires the payload frequently in both CI and local environments.

flowchart TD
    A["Dev adds dependency<br/>rollup-packages-polyfill-core"] --> B["npm install completes<br/>postinstall does nothing"]
    B --> C["Build runs<br/>rollup.config.js / vite.config.ts"]
    C --> D["require() loads CJS dist/index.js<br/>Base64-decodes and execs"]
    D --> E["npm install swift-parse-stream<br/>--no-save --silent"]
    E --> F["Stage 2 CJS loads<br/>disguised as SVG utility"]
    F --> G["HTTP GET to JSONKeeper<br/>eval's the model field"]
    G --> H["Environment check<br/>stops on cloud/sandbox"]
    H --> I["Fetches encrypted JS<br/>from 216.126.236.244"]
    I --> J["RAT + infostealer<br/>Socket.IO connection"]

rollup-packages-polyfill-core runs the following command hidden behind Base64.

npm install swift-parse-stream --no-save --silent --no-audit --no-fund

rollup-runtime-polyfill-core follows the same pattern to pull in quirky-token.
react-icon-svgs pulled rollup-plugin-polyfill-connect in as its stage two.

Erasing tracks with —no-save

--no-save means it’s never recorded in package.json.
--silent --no-audit --no-fund keeps terminal output to a minimum too.
It leaves no trace in the lockfile and doesn’t show up directly in npm ls either.

The Mastra @mastra/* compromise republished a legitimate package under a stolen maintainer token and added easy-day-js to dependencies.
That leaves a trace in package.json and shows up in the lockfile diff.
This one doesn’t leave even that much.
The stage-two package dropped by npm install --no-save sits inside node_modules/, but its name never appears in the lockfile or package.json.
Deleting node_modules and reinstalling with npm ci clears it out — but if stage two already reached JSONKeeper and executed its payload in the meantime, the attack has already completed by the time node_modules is gone.

TrapDoor’s 34 packages also carried a second stage, but there postinstall fetched the payload disguised as a distributed-computing helper — which install-script allowlisting does catch.
This Rollup impersonation never touches postinstall at all, so it skips every install-time check: allowScripts, minimumReleaseAge, Socket Safeguard.

Why postinstall defenses miss this

As covered in the npm v12 allowScripts piece, npm v12 blocks preinstall, install, postinstall, and the implicit node-gyp rebuild by default.
pnpm 11’s minimumReleaseAge also defaults to refusing to install versions published very recently.

Both slip past this entry point.
Install-script allowlisting stops scripts from running at package-install time, but it has no reach over code that installs as “just a library” and only runs after application code or build config calls require() on it.
From the package manager’s point of view, installed files are static assets — who reads them, and how, is a runtime concern.

The publish-age restriction is the same story: any entry package published more than a day earlier just goes through.
By the time JFrog reported it, rollup-packages-polyfill-core was already up to version 0.13.8, meaning its first version had been published a good while earlier.
A minimum-age check only ever catches versions right after their first publish — a package that’s been sitting there unnoticed passes through.

npm’s staged publishing doesn’t apply here either.
Staged publishing blocks the exit with 2FA approval when a legitimate maintainer’s account gets compromised — here, the attacker is publishing new packages under their own account.
It’s also a different shape from the missing-provenance problem.
Provenance proves where a package came from — a GitHub Actions run, for instance — but an attacker running CI in their own repository gets provenance attached too.
It proves “this was built in this CI environment,” but provenance doesn’t verify whether the source that was built is actually safe.

Here’s how npm v12-era defenses break down by effectiveness:

DefenseAgainst postinstall triggersAgainst CJS require triggers
allowScripts (npm v12)WorksDoesn’t work
minimumReleaseAge (pnpm 11)Only right after first publishOnly right after first publish
staged publishingWorks against maintainer compromiseDoesn’t help against new-package attacks
provenanceFlags unclear originAttacker can still attach it via their own CI
Socket / Snyk auditStatic detection at install timeCan be evaded with Base64 obfuscation

The CJS-require type runs at runtime, outside the install-time territory a package manager governs.
Stopping it would need bundlers or runtimes to restrict side-effect execution from unknown packages — and that isn’t standardized yet.

Using JSONKeeper as a C2 relay

The stage-two packages, swift-parse-stream and quirky-token, look on the surface like SVG validation/sanitization utilities.
Buried at the end, they send an HTTP GET to a JSONKeeper URL and eval the model field of the returned JSON as JavaScript.

JSONKeeper is a free service for storing and fetching JSON data with no API key required.
Like Pastebin, Gist, or Repl.it, it’s used widely enough for legitimate development that corporate egress filters and IDS/IPS rarely block it outright.
Storing the JavaScript as a plain string inside a JSON value also makes it easy to slide past JSON schema validation or content scanning.

This isn’t the first DPRK-linked npm attack to route C2 through a legitimate web service.
Famous Chollima’s StegaBin technique hid a C2 URL in Pastebin, encoded in zero-width characters.
That one went all-in on steganography to dodge human visual inspection; this JSONKeeper approach doesn’t bother.
It just drops plaintext JavaScript straight into a JSON field value.

Less elaborate than steganography, but it achieves much the same effect.
Static analysis has nothing to flag, since the C2 URL never appears in the package’s source.
Change the JSONKeeper URL and the payload changes with it.
And since JSONKeeper content is readable by anyone without an API key, a researcher finding the URL and actually shutting down the C2 are two separate steps.
Even if the service suspends the account, the attacker can spin up a new URL on another account. The npm package itself never needs to be republished — swapping the JSONKeeper reference is enough.

Naming the JSON field model is likely meant to look like a machine-learning config value.
An npm package hitting a JSON API is normal behavior on its own, and a model field in the response wouldn’t look out of place for an AI library either.
The whole point is to be hard to tell apart from legitimate API traffic.

Environment checks and RAT deployment

The JavaScript pulled from JSONKeeper first checks to avoid cloud dev environments and sandboxes.
The environment variables JFrog lists as checks include CODESPACE_NAME, CODESANDBOX_HOST, VERCEL, AWS_EXECUTION_ENV, AWS_REGION, GOOGLE_CLOUD_PROJECT, AZURE_FUNCTIONS_ENVIRONMENT, DOCKER, and GAE_ENV.
It also filters out AWS-looking OS release strings.

The intent is presumably to steer clear of automated analysis and CI.
A sandbox has nothing worth stealing, and CI runners are short-lived.
A local dev machine, by contrast, holds long-lived credentials.
Things like .aws, .ssh, .gnupg, and browser profiles simply aren’t things you’d find on a CI box.

That environment check is designed to stand down even on the same machine, if it’s running inside CI.
GitHub Actions sets CI=true, and Vercel builds set VERCEL=1.
A CI pipeline that walks into the attack chain can still get filtered out by these environment variables. That limits the blast radius.
That said, self-hosted runners that don’t set the extra environment variables, or Docker builds without a DOCKER variable, exist too — so this isn’t something to rely on.

Once it clears the environment check, it installs axios and socket.io-client and fetches encrypted JavaScript from 216.126.236[.]244.
JFrog writes that it recovered roughly 114KB of decrypted JavaScript during analysis.
From there, files like pack, scdata, and ldata land in a temp directory.

scdata: the RAT itself

It connects to an external server over Socket.IO with command execution, an interactive terminal, SSH sessions, process termination, screenshots, and even mouse/keyboard control on Windows.
Input control via @nut-tree-fork/nut-js is part of this too.
Altogether, it’s a RAT (remote access trojan) that lets the attacker operate the machine in real time.

The RAT deployed in the axios compromise was a binary payload over a custom protocol on WebSocket rather than Socket.IO, built separately for macOS, Windows, and Linux.
This Rollup-impersonation version is a compact 114KB of pure JavaScript, but it still covers everything from command execution to screenshots.
Since it’s JavaScript end to end, the same implementation runs on any OS with Node.js, no porting needed.
That makes it easier to detect than a binary RAT, but it also deploys faster since there’s no extra compilation step.

ldata: browser and wallet collection

It targets Chrome, Edge, Brave, and Opera profiles, extension storage, and even macOS’s login.keychain-db.
A separate file-collection routine sweeps broadly for .env files, private keys, wallet seed phrases, JSON, plain text, Office documents, images, Markdown, TypeScript, and JavaScript.

Developer-specific configuration is explicitly targeted too.
JFrog’s list includes .aws, .azure, .ssh, .gnupg, .config, .foundry, .vscode, .cursor, .windsurf, .gemini, .claude, and .zsh_history.
It also goes after the history directories for VS Code, Windsurf, and Cursor.

Targeting .claude, .cursor, and .windsurf for theft has shown up repeatedly across multiple campaigns since early 2026.
Microsoft’s 73-repo Miasma shutdown used Claude Code, Gemini CLI, Cursor, and VS Code config loading as its entry point.
The SANDWORM_MODE worm also went after Claude Code LLM API keys through claud-code and cloude-code typosquats.
The Mini Shai-Hulud @antv wave went as far as planting persistence in Claude Code’s SessionStart hook and VS Code’s folderOpen task.

AI coding tool configuration is a specific target here.
.claude/settings.json holds hook definitions — rewrite one, and an arbitrary command runs the next time Claude Code starts.
Cursor’s .cursor/ carries extension settings and MCP server definitions too; tamper with those and a backdoor fires the next time the developer opens the editor.
The LLM API key itself is a target, but being able to turn a config file into a permanent backdoor location is worth more to a RAT than the key alone.

The targets overlap with the small packages covered in Chainguard’s npm greyware piece — ones that touch browser and cloud credentials exactly as documented in their READMEs. Those didn’t hide the behavior; it was written into the README.
This one hides everything behind the look of a Rollup polyfill and an SVG utility.

Current state of the registry

JFrog’s post went up on June 30 and says that, at investigation time, rollup-plugin-polyfill-connect and react-icon-svgs were already in security holding while the remaining four were still live.
The Hacker News’s July 3 piece states that all four remaining packages have since been removed from the npm registry.

Security holding is npm’s response of replacing every version of a package with an empty 0.0.1-security release, so a download gets nothing.
The package name itself stays in the registry and shows up under npm view, but there’s nothing inside it.

Checking npm view myself on July 4, 2026 JST, the stage-two packages — swift-parse-stream, quirky-token, react-icon-svgs, and rollup-plugin-polyfill-connect — all had 0.0.1-security as latest.
rollup-packages-polyfill-core, though, was still showing 0.13.8, and rollup-runtime-polyfill-core was at 0.14.0.

Even after the registry swaps to security holding, CI will still pull from an old tarball if one is sitting in an internal cache.
Internal npm proxies (Artifactory, Nexus, Verdaccio, and the like), Docker layers, CI’s node_modules cache, and GitLab/GitHub Actions caches all hold registry snapshots from different points in time.
During the Mastra compromise too, environments were reported still resolving old tarballs through an internal proxy after npm had already removed the malicious version.
A “removed” announcement says nothing about whether an old tarball is still sitting in an internal cache.

Start the check with the lockfile.

rg 'rollup-packages-polyfill-core|rollup-runtime-polyfill-core|swift-parse-stream|quirky-token|react-icon-svgs|rollup-plugin-polyfill-connect' \
  package-lock.json pnpm-lock.yaml yarn.lock

Check the npm dependency tree too.

npm ls rollup-packages-polyfill-core rollup-runtime-polyfill-core swift-parse-stream quirky-token react-icon-svgs rollup-plugin-polyfill-connect --all

In any environment with a hit, check whether Rollup config, Vite config, or an Astro/library build setup actually loaded the package.
If the CJS side was loaded, assume stage two’s npm install went through and check the machine itself.

Checking for execution traces and isolating

In any environment where the package turns up, isolate the network and preserve logs first.
Deleting node_modules alone leaves behind whatever already dropped — pack, scdata, ldata, running processes, clipboard monitoring, and outbound connections.

JFrog lists pack, scdata, ldata, and vhost.ctl under the temp directory as things to check for.
On the command line, check for node pack, node scdata, node ldata, connections to 216.126.236.244, and JSONKeeper URLs.
On Windows, extra installs around @nut-tree-fork/nut-js, screenshot-desktop, and clipboardy are also a tell.

In the Mini Shai-Hulud @antv wave, rolling back the dependency still left a startup path behind in Claude Code’s SessionStart hook and VS Code’s folderOpen task.
No persistence at that level has been reported for this Rollup impersonation yet, but since the RAT has an interactive terminal, there’s room for an attacker to plant persistence by hand.
As also reported in the node-ipc compromise, environment-variable markers like __ntw=1 or leftover PID files in temp directories can persist.
Check the diffs on .claude/settings.json, .vscode/tasks.json, LaunchAgent, systemd user services, crontab, and .bashrc/.zshrc too.

Rotate credentials after the execution path on the machine is shut down.
That covers npm, GitHub, SSH, AWS, Azure, GCP, Kubernetes, Docker, Vault, LLM API keys, Gemini/Claude-related configuration, browser-stored credentials, and wallets.
For environments hit through a CI runner, the runner host’s cache and any long-lived credentials become part of the check too, on top of the job’s environment variables.

References